Source code for owl.instruments.illuminate

import collections
import contextlib
import math
import os
import subprocess
from time import sleep
from warnings import warn

import numpy as np
import pyilluminate
from packaging.version import Version
from pyilluminate.illuminate import with_thread_lock

from ._illuminate_registry import (
    known_devices,
    ramona_optics_firmware_info,
    ramona_optics_led_positions,
)

__all__ = ['LEDHole', 'Illuminate', 'FakeIlluminate']


class TLC5955:
    ANALOG_MAX_CURRENT_BIT_DEPTH = 3
    ANALOG_BRIGHTNESS_CONTROL_BIT_DEPTH = 7
    ANALOG_DOT_CORRECTION_BIT_DEPTH = 7
    ANALOG_GRAYSCALE_BIT_DEPTH = 16

    MAX_ANALOG_BRIGHTNESS_CONTROL_FACTOR = 1
    MIN_ANALOG_BRIGHTNESS_CONTROL_FACTOR = 0.1
    MAX_ANALOG_DOT_CORRECTION_FACTOR = 1
    MIN_ANALOG_DOT_CORRECTION_FACTOR = 0.262

    # it is assumed that these are sorted in ascending order
    MAX_CURRENT_VALUES = np.array([
        3.2E-3,
        8E-3,
        11.2E-3,
        15.9E-3,
        19.1E-3,
        23.9E-3,
        27.1E-3,
        31.9E-3
    ])

    # functions used to calculate current based on analog settings
    # https://www.ti.com/lit/ds/symlink/tlc5955.pdf
    # Page 17 (Equation 1)
    @staticmethod
    def get_max_current_value(mc):
        mc = np.asarray(mc)
        # Numpy fancy indexing allows you to index one array with
        # another array.
        return TLC5955.MAX_CURRENT_VALUES[mc]

    @staticmethod
    def get_brightness_control_factor(bc):
        bc = np.asarray(bc)
        bc_ratio = bc / (2**TLC5955.ANALOG_BRIGHTNESS_CONTROL_BIT_DEPTH - 1)
        return (
            TLC5955.MIN_ANALOG_BRIGHTNESS_CONTROL_FACTOR +
            (1 - TLC5955.MIN_ANALOG_BRIGHTNESS_CONTROL_FACTOR) * bc_ratio
        )

    @staticmethod
    def get_dot_correction_factor(dc):
        dc = np.asarray(dc)
        dc_ratio = dc / (2**TLC5955.ANALOG_DOT_CORRECTION_BIT_DEPTH - 1)
        return (
            TLC5955.MIN_ANALOG_DOT_CORRECTION_FACTOR +
            (1 - TLC5955.MIN_ANALOG_DOT_CORRECTION_FACTOR) * dc_ratio
        )

    @staticmethod
    def get_grayscale_factor(gs):
        gs = np.asarray(gs)
        return gs / (2**TLC5955.ANALOG_GRAYSCALE_BIT_DEPTH - 1)

    @staticmethod
    def get_max_current(*, gs=None, mc=None, bc=None, dc=None):

        # find maximum current based on gs and dc
        if gs and dc:
            gs_factor = TLC5955.get_grayscale_factor(gs)
            max_mc_factor = TLC5955.MAX_CURRENT_VALUES[-1]
            max_bc_factor = TLC5955.MAX_ANALOG_BRIGHTNESS_CONTROL_FACTOR
            dc_factor = TLC5955.get_dot_correction_factor(dc)
            max_current = gs_factor * max_mc_factor * max_bc_factor * dc_factor
        # find maximum current based on analog settings
        elif mc and bc and dc:
            mc_value = TLC5955.get_max_current_value(mc)
            bc_factor = TLC5955.get_brightness_control_factor(bc)
            dc_factor = TLC5955.get_dot_correction_factor(dc)
            max_current = mc_value * bc_factor * dc_factor
        else:
            raise NotImplementedError

        return max_current

    @staticmethod
    def get_min_current(gs, dc):

        gs_factor = TLC5955.get_grayscale_factor(gs)
        min_mc_factor = TLC5955.MAX_CURRENT_VALUES[0]
        min_bc_factor = TLC5955.MIN_ANALOG_BRIGHTNESS_CONTROL_FACTOR
        dc_factor = TLC5955.get_dot_correction_factor(dc)
        min_current = gs_factor * min_mc_factor * min_bc_factor * dc_factor

        return min_current

    @staticmethod
    def get_analog_settings_for_current(current, gs, dc):
        # calculated based on max and min BC and DC values
        # and current grayscale value

        dc_factor = TLC5955.get_dot_correction_factor(dc)
        min_current = TLC5955.get_min_current(gs, dc)
        max_current = TLC5955.get_max_current(gs=gs, dc=dc)

        current = np.clip(current, min_current, max_current)
        # if any(current < min_current) or any(current > max_current):
        #     raise ValueError(f"At this grayscale value, "
        #                      f"currents should not exceed {np.round(max_current, 7)}"
        #                      f" or be below {np.round(min_current, 7)}.")

        # choose the lowest necessary MC values TODO: this causes color issues
        # then choose corresponding BC value
        # DC is set to 0 to give more color precision at low currents
        # this limits the upper bound of available values

        gs_factor = TLC5955.get_grayscale_factor(gs)
        min_mc_values = np.array([
            0 if not gs[i]
            else current[i] / (dc_factor[i] * gs_factor[i])
            for i in range(3)
        ])
        mc = [
            np.argwhere(
                m <= TLC5955.MAX_CURRENT_VALUES
            )[0][0] if not math.isnan(m) else 0
            for m in min_mc_values
        ]

        bc = TLC5955._get_bc_for_current(current, mc, dc, gs)
        bc = tuple(b if g != 0 else 0
                   for b, g in zip(bc, gs))

        return tuple(mc), tuple(bc), tuple(dc)

    @staticmethod
    def _get_bc_for_current(current, mc, dc, gs):
        mc_value = TLC5955.get_max_current_value(mc)
        gs_factor = TLC5955.get_grayscale_factor(gs)
        dc_factor = TLC5955.get_dot_correction_factor(dc)

        bc_factor = np.array([
            0 if not gs[i] else current[i]
            / (mc_value[i] * dc_factor[i] * gs_factor[i]) for i in range(3)
        ])

        bc_ratio = (
            (bc_factor - TLC5955.MIN_ANALOG_BRIGHTNESS_CONTROL_FACTOR) /
            (1. - TLC5955.MIN_ANALOG_BRIGHTNESS_CONTROL_FACTOR)
        )

        bc = np.rint(
            bc_ratio *
            (2**TLC5955.ANALOG_BRIGHTNESS_CONTROL_BIT_DEPTH - 1)
        ).astype(int)

        return bc

    @staticmethod
    def get_channel_current(mc, bc, dc, gs):
        gs_factor = TLC5955.get_grayscale_factor(gs)
        mc_factor = TLC5955.get_max_current_value(mc)
        bc_factor = TLC5955.get_brightness_control_factor(bc)
        dc_factor = TLC5955.get_dot_correction_factor(dc)

        current = gs_factor * mc_factor * bc_factor * dc_factor
        return tuple(current)


class LEDHole:
    """Class representing a hole with 8 LEDs for the MCAM.

    There are 8 LEDs around a hole. For illustration purposes, lets number
    them from 1 to 8.

    .. code-block::

           1  2
         8      3
         7      4
           6  5

    They refer to which direction the OBJECT is illuminated from.

    * ``top`` refers to 1 and 2
    * ``bottom`` refers to 6 and 5
    * ``left`` refers to 3 and 4
    * ``right`` refers to 8 and 7

    * ``top_left`` refers to 1
    * ...
    * ``right_top`` refers to 3
    * ``all`` refers to all leds ``[1, 2, ..., 8]``

    and so on
    """
    __slots__ = [
        'right',
        'left',
        'bottom',
        'top',
        'top_left',
        'top_right',
        'bottom_left',
        'bottom_right',
        'right_top',
        'right_bottom',
        'left_top',
        'left_bottom',
        'all'
    ]

    def __init__(self, led_list, led_positions):
        led_list = np.array(led_list)

        # Use -1 and -2 for indexing because we follow python indexing
        # notation
        # (y, x), or (z, y, x) are likely going to be how we store the
        # locations of these coordinates
        max_x = led_positions[led_list, -1].max()
        min_x = led_positions[led_list, -1].min()
        min_y = led_positions[led_list, -2].min()
        max_y = led_positions[led_list, -2].max()

        right = led_list[np.isclose(led_positions[led_list, -1], max_x)]
        left = led_list[np.isclose(led_positions[led_list, -1], min_x)]
        bottom = led_list[np.isclose(led_positions[led_list, -2], max_y)]
        top = led_list[np.isclose(led_positions[led_list, -2], min_y)]

        top_left = top[top[-1].argmin()]
        top_right = top[top[-1].argmax()]
        bottom_left = bottom[bottom[-1].argmin()]
        bottom_right = bottom[bottom[-1].argmax()]
        right_top = right[right[-2].argmin()]
        right_bottom = right[right[-2].argmax()]
        left_top = left[left[-2].argmin()]
        left_bottom = left[left[-2].argmax()]

        self.right = right.tolist()
        self.left = left.tolist()
        self.bottom = bottom.tolist()

        self.top = top.tolist()
        self.top_left = top_left.tolist()
        self.top_right = top_right.tolist()
        self.bottom_left = bottom_left.tolist()
        self.bottom_right = bottom_right.tolist()
        self.right_top = right_top.tolist()
        self.right_bottom = right_bottom.tolist()
        self.left_top = left_top.tolist()
        self.left_bottom = left_bottom.tolist()
        self.all = led_list

    def __repr__(self):
        return (
            f"LEDHole(top_left={self.top_left}, "
            f"top_right={self.top_right}, "
            f"right_top={self.right_top}, "
            f"right_bottom={self.right_bottom}, "
            f"bottom_right={self.bottom_right}, "
            f"bottom_left={self.bottom_left},"
            f"left_bottom={self.left_bottom},"
            f"left_top={self.left_top})"
        )


class IlluminateTools:
    # Override the known mac addresses for the specific class
    _known_devices = known_devices
    _known_serial_numbers = known_devices.index.to_list()

    @property
    def all_leds(self):
        return list(np.arange(self.N_leds, dtype=int))

    @property
    @with_thread_lock
    def _revision(self):
        if self.serial_number in self._known_devices.index:
            return self._known_devices.loc[self.serial_number].revision
        else:
            return 1

    @property
    def rgb_leds(self):
        return list(
            np.setdiff1d(
                np.setdiff1d(self.all_leds, self.ir_leds),
                self.uv_leds
            )
        )

    @property
    def uv_leds(self):
        return list(self._uv_leds)

    @uv_leds.setter
    def uv_leds(self, value):
        self._uv_leds = value

    @property
    def ir_leds(self):
        return list(self._ir_leds)

    @ir_leds.setter
    def ir_leds(self, value):
        self._ir_leds = value

    def _setup_led_positions(self):
        import xarray as xr
        if self._flip_along_x:
            x_mirror = (self.led_positions[:, -1].max() +
                        self.led_positions[:, -1].min()) / 2
            self._led_positions[:, -1] = (-self._led_positions[:, -1] +
                                          2 * x_mirror)

        if self._flip_along_y:
            y_mirror = (self.led_positions[:, -2].max() +
                        self.led_positions[:, -2].min()) / 2
            self._led_positions[:, -2] = (-self._led_positions[:, -2] +
                                          2 * y_mirror)

        if self.device_name in [
                'c-012-vireo-brightfield',
                'c-011-vireo-brightfield',
                'c-008-falcon-transmission',
                'c-007-falcon'
        ]:
            self._camera_pitch = 13.5E-3
            self._N_cameras = (9, 6)
            if self.device_name == 'c-007-falcon':
                self.uv_leds = np.arange(self.N_leds - 108, self.N_leds, dtype=int)
            else:
                self.uv_leds = np.arange(0, dtype=int)

            if self.device_name == 'c-008-falcon-transmission':
                if self._revision >= 4:
                    self.ir_leds = np.arange(self.N_leds)[1::2]
                else:
                    self.ir_leds = np.arange(0, dtype=int)
            else:
                self.ir_leds = np.arange(0, dtype=int)

            # Saved LED coordinates are actually transposed compared to the
            # canonical orientation of the MCAM Falcon
            self.led_positions.data[:, 1:3] = self.led_positions.data[:, 2:0:-1]
            if self.device_name == 'c-007-falcon':
                if not self._flip_along_y:
                    self.led_positions.data[:, 1] *= -1
                if not self._flip_along_x:
                    self.led_positions.data[:, 2] *= -1
            else:  # c-008
                if self._flip_along_y:
                    self.led_positions.data[:, 1] *= -1
                if not self._flip_along_x:
                    self.led_positions.data[:, 2] *= -1

            # The LED coordinates, by default, are centered around the
            # middle of the board.
            # We will want to center them around the center of the MCAM Falcon
            self.led_positions.data[:, 1:3] += \
                self._camera_pitch * (np.asarray(self._N_cameras) / 2 - 0.5)

            self._x_positions = np.unique(self.led_positions.data[:, 2])
            self._y_positions = np.unique(self.led_positions.data[:, 1])
            self._x_positions.sort()
            self._y_positions.sort()

        elif self.device_name == 'c-001-multi-camera':
            # The boards have a 1 mm offset in each dimension I think
            self.led_positions[:, 1:3] += 0.001
            self._N_cameras = (6, 4)

            # TODO: sort these in a 3x5 array
            self.uv_leds = np.array(
                [
                    19, 37, 55, 73,
                    91, 125, 140, 155,
                    170, 185, 217, 232,
                    247, 262, 277
                ],
                dtype=int
            )
            self.ir_leds = np.arange(0, dtype=int)

            # make the lines shorter
            led_positions = self.led_positions
            # TO generate this list, go into the C code,
            # copy paste the list
            # Then
            #   - replace '},' with '], led_positions),  # noqa
            #   - replace '{' with 'LEDHole(['
            self.holes = np.array(
                [
                    LEDHole([14, 13, 11, 7, 3, 4, 10, 6], led_positions),
                    LEDHole([21, 22, 25, 29, 24, 28, 32, 31], led_positions),
                    LEDHole([47, 43, 46, 42, 40, 50, 49, 39], led_positions),
                    LEDHole([61, 65, 60, 64, 68, 67, 57, 58], led_positions),
                    LEDHole([83, 79, 82, 78, 85, 76, 75, 86], led_positions),
                    LEDHole([97, 101, 100, 96, 102, 103, 94, 93], led_positions),
                    LEDHole([114, 117, 120, 119, 111, 112, 118, 115], led_positions),
                    LEDHole([132, 129, 127, 126, 130, 135, 134, 133], led_positions),
                    LEDHole([144, 147, 141, 142, 145, 148, 149, 150], led_positions),
                    LEDHole([162, 159, 163, 165, 164, 156, 157, 160], led_positions),
                    LEDHole([174, 177, 178, 175, 180, 172, 179, 171], led_positions),
                    LEDHole([189, 192, 190, 195, 194, 186, 187, 193], led_positions),
                    LEDHole([212, 211, 203, 204, 209, 206, 210, 207], led_positions),
                    LEDHole([219, 218, 226, 225, 224, 222, 221, 227], led_positions),
                    LEDHole([236, 233, 234, 237, 239, 240, 241, 242], led_positions),
                    LEDHole([252, 256, 255, 254, 251, 257, 248, 249], led_positions),
                    LEDHole([263, 272, 271, 270, 269, 267, 266, 264], led_positions),
                    LEDHole([286, 278, 279, 281, 282, 287, 285, 284], led_positions),
                    LEDHole([303, 302, 295, 294, 300, 301, 297, 298], led_positions),
                    LEDHole([308, 309, 316, 315, 314, 312, 311, 317], led_positions),
                    LEDHole([328, 331, 322, 323, 330, 329, 325, 326], led_positions),
                    LEDHole([339, 342, 343, 344, 345, 337, 336, 340], led_positions),
                    LEDHole([358, 353, 354, 356, 357, 359, 351, 350], led_positions),
                    LEDHole([373, 372, 371, 370, 368, 367, 365, 364], led_positions)
                ]
            )
            self.holes = self.holes.reshape(self._N_cameras[1], self._N_cameras[0])
            self.holes = self.holes.transpose()

            if self._flip_along_y:
                self.holes = self.holes[::-1, :]

            if self._flip_along_x:
                self.holes = self.holes[:, ::-1]

            # experimental_API
            hole_leds = np.zeros(shape=(*self.holes.shape, 8), dtype=int)
            for i in np.ndindex(self.holes.shape):
                hole_leds[(*i, 0)] = self.holes[i].top_left
                hole_leds[(*i, 1)] = self.holes[i].top_right
                hole_leds[(*i, 2)] = self.holes[i].right_top
                hole_leds[(*i, 3)] = self.holes[i].right_bottom
                hole_leds[(*i, 4)] = self.holes[i].bottom_right
                hole_leds[(*i, 5)] = self.holes[i].bottom_left
                hole_leds[(*i, 6)] = self.holes[i].left_bottom
                hole_leds[(*i, 7)] = self.holes[i].left_top

            self.hole_leds = xr.DataArray(
                hole_leds, dims=['image_y', 'image_x', 'hole_position'],
                coords={'hole_position': ['top_left', 'top_right',
                                          'right_top', 'right_bottom',
                                          'bottom_right', 'bottom_left',
                                          'left_bottom', 'left_top'],
                        'image_y': np.arange(self._N_cameras[0]),
                        'image_x': np.arange(self._N_cameras[1])
                        }
            )

        else:
            raise RuntimeError(
                f"Device {self.device_name} is not a Ramona Optics Illuminate "
                "device."
            )

        uv_leds = self.uv_leds

        chroma = xr.DataArray(
            np.full(self.N_leds, fill_value='rgb', dtype='<U3'),
            dims=['led_number'])
        chroma[uv_leds] = 'uv'
        chroma[self.ir_leds] = 'ir'
        self._led_positions = self._led_positions.assign_coords(
            chroma=chroma)

    @property
    def color_850_ir(self):
        if len(self._ir_leds) == 0:
            raise NotImplementedError(
                "IR color properties are only available for "
                "IR enabled illumination boards"
            )
        return self.color[2]

    @color_850_ir.setter
    def color_850_ir(self, value):
        if len(self._ir_leds) == 0:
            raise NotImplementedError(
                "IR color properties are only available for "
                "IR enabled illumination boards"
            )
        self.color = (0, 0, value)

    @property
    def color_940_ir(self):
        if len(self._ir_leds) == 0:
            raise NotImplementedError(
                "IR color properties are only available for "
                "IR enabled illumination boards"
            )
        return sum(self.color[:2]) / 2

    @color_940_ir.setter
    def color_940_ir(self, value):
        if len(self._ir_leds) == 0:
            raise NotImplementedError(
                "IR color properties are only available for "
                "IR enabled illumination boards"
            )
        self.color = (value, value, 0)

    def draw_edge(self, num_leds=4, led_type='rgb', set_leds=True):
        """Lights the leds on the parameter of the board.

        You can be used for quick global dark field illumination to remove the
        reflection from reflective samples.

        Currently, only the reflection illumination board is supported.

        Parameters
        ----------
        num_leds: int
            The number of LEDs on the perimeter to light up.

        led_type: 'rgb', 'uv', 'ir', or 'all'
            The type of LED to turn on around the perimeter.

        set_leds: bool
            If set to ``False``, this will just return a list of LEDs that would be set
            instead of directly changing the LED pattern on the board.

        Returns
        -------
        led_list: List[int]
            The list of LEDs that were turned on by this function.

        """
        if self.device_name not in ["c-007-falcon", "c-008-falcon-transmission"]:
            raise NotImplementedError(
                "Drawing the edge is only supported for the Falcon reflection "
                "and transmission units. "
                "Contact us at help@ramonaoptics.com with a screenshot of your screen "
                "including this error message if you need this functionality for other "
                "Illumination boards."
            )

        """ In the edge_leds calculation below, the coordinates for thresholding the
        leds on the edges can be changed. For example, the j in self.y_positions[j]
        can be altered to a different value to make the edge threshold closer
        or further from the edge of the board."""
        if num_leds > min(len(self._y_positions), len(self._x_positions)):
            raise ValueError(f'not enough leds thickness={num_leds}.')

        all_leds = np.arange(self.N_leds, dtype=int)
        y_plus_edge = np.where(self.led_positions[all_leds, 2]
                               < self._x_positions[num_leds])[0]
        y_minus_edge = np.where(self.led_positions[all_leds, 2]
                                > self._x_positions[-(num_leds + 1)])[0]
        x_plus_edge = np.where(self.led_positions[all_leds, 1]
                               < self._y_positions[num_leds])[0]
        x_minus_edge = np.where(self.led_positions[all_leds, 1]
                                > self._y_positions[-(num_leds + 1)])[0]

        edge_leds = np.concatenate((y_plus_edge, y_minus_edge, x_plus_edge, x_minus_edge))
        # need to cast to set in order to perform intersection
        edge_leds = set(np.unique(edge_leds))

        if led_type == 'rgb':
            edge_leds = edge_leds.intersection(self.rgb_leds)
        elif led_type == 'uv':
            edge_leds = edge_leds.intersection(self.uv_leds)
        elif led_type == 'ir':
            edge_leds = edge_leds.intersection(self.ir_leds)
        edge_leds = list(edge_leds)

        if set_leds:
            self.led = edge_leds
        return edge_leds

    def draw_grid(self, grid_index=0, set_leds=True):
        """Lights the leds on board in a grid pattern.

        Currently, it is used to separate coherent and incoherent illumination
        in the BF illumination module. Supported only on the c012.

        Parameters
        ----------
        grid_index: int
            To decide which grid pattern to be turned on.

        led_type: 'rgb', 'uv', or , 'ir', 'all'
            The type of LED to turn on around the perimeter.

        set_leds: bool
            If set to ``False``, this will just return a list of LEDs that would be set
            instead of directly changing the LED pattern on the board.

        Returns
        -------
        led_list: List[int]
            The list of LEDs that were turned on by this function.

        """
        if self.device_name not in ["c-012-vireo-brightfield"]:
            raise NotImplementedError(
                "The grid pattern is only supported on the c012 brightfield board."
                "Contact us at help@ramonaoptics.com with a screenshot of your screen "
                "including this error message if you need this functionality for other "
                "Illumination boards."
            )
        if grid_index == 0:
            grid_leds = [n for n in range(4 * 48) if n % 32 < 16]
        elif grid_index == 1:
            grid_leds = [n for n in range(0, 4 * 48, 4) if n % 32 >= 16]
        else:
            raise ValueError("Grid index should be 0 or 1")
        if set_leds:
            self.led = grid_leds
        return grid_leds

    def draw_square(self, Y_index, X_index, width=1.25,
                    set_leds=True, led_type='rgb'):
        """Illuminate the LEDs in a square at a given micro camera index.

        Units provided are in "microcamera" units, where 1 camera corresponds
        to the pitch between microcameras.

        For the Falcon Illumination units, this is 13.5 mm.

        Parameters
        ----------
        Y_index : int
            Y coordinate of the center of the square.

        X_index : int
            X coordinate of the center of the square.

        width : float
            Width of the square.

        set_leds : bool
            If set to False, this will just return the LEDs without sending them to the
            LED board, so they can be used in sequences.

        led_type: 'rgb', 'uv', or , 'ir', 'all'
            The type of LED to turn on around the perimeter.

        Returns
        -------
        leds: List[int]
            LEDs that correspond to the ones below the camera index.

        """

        supported_boards = ['c-008-falcon-transmission', 'c-007-falcon']
        if self.device_name not in supported_boards:
            raise NotImplementedError(
                "Draw square is only supported for the units:\n"
                f"{supported_boards}\n"
                "Contact Ramona Optics if you need this functionality for other "
                "Illumination boards."
            )

        width = width * self._camera_pitch
        position = np.asarray((Y_index, X_index)) * self._camera_pitch
        led_positions = abs(self.led_positions[:, 1:3] - position)
        good_values = (led_positions < (width / 2)).sum(axis=1) == 2
        good_led_numbers = led_positions.where(good_values, drop=True)
        leds = good_led_numbers['led_number'].data

        leds = set(leds)
        if led_type == 'rgb':
            leds = leds.intersection(self.rgb_leds)
        elif led_type == 'uv':
            leds = leds.intersection(self.uv_leds)
        elif led_type == 'ir':
            leds = leds.intersection(self.ir_leds)
        leds = list(leds)

        if set_leds:
            self.led = leds
        return leds

    def draw_circle(self, Y_index, X_index, radius=1, set_leds=True, led_type='rgb'):
        """Illuminate the LEDs in a circle centered at a given index.

        Units provided are in "microcamera" units, where 1 camera corresponds
        to the pitch between microcameras.

        For the Falcon Illumination units, this is 13.5 mm.

        Parameters
        ----------
        Y_index : int
            Y coordinate of the center of the circle.

        X_index : int
            X coordinate of the center of the circle.

        radius : float
            Radius of the circle.

        set_leds : bool
            If set to False, this will just return the LEDs without sending them to the
            LED board so they can be used in sequences

        led_type: 'rgb', 'uv', or , 'ir', 'all'
            The type of LED to turn on around the perimeter.

        Returns
        -------
        leds: List[int]
            LEDs that correspond to the circle directly below the indicated camera index.

        """

        supported_boards = ['c-008-falcon-transmission', 'c-007-falcon']
        if self.device_name not in supported_boards:
            raise NotImplementedError(
                "Draw hole is only supported for the units:\n"
                f"{supported_boards}\n"
                "Contact Ramona Optics if you need this functionality for other "
                "Illumination boards."
            )

        radius = radius * self._camera_pitch
        position = np.asarray((Y_index, X_index)) * self._camera_pitch

        led_positions = self.led_positions[:, 1:3] - position
        good_values = ((led_positions) ** 2).sum(axis=1) < radius ** 2
        good_led_numbers = led_positions.where(good_values, drop=True)
        leds = good_led_numbers['led_number'].data

        leds = set(leds)
        if led_type == 'rgb':
            leds = leds.intersection(self.rgb_leds)
        elif led_type == 'uv':
            leds = leds.intersection(self.uv_leds)
        elif led_type == 'ir':
            leds = leds.intersection(self.ir_leds)
        leds = list(leds)

        if set_leds:
            self.led = leds
        return leds

    def _compute_rgb_lux_settings(self, desired_lux, ir_brightness=None):
        """Provides PWM for settings a given lux

        Assumes board is operating in ir850_analog_fullarray.

        Parameters
        ----------
        desired_lux:
            The desired lux of the flash.

        ir_brightness:
            The desired IR brightness to compute the RGB lux settings for.
            If non, the current brightness settings are used.

        Returns
        -------
        power_fraction_required:
            Fraction of the maximum PWM required to recreate the desired lux.
            This number will be clipped between 0 and 1.

        estimated_lux:
            The estimated LUX (assuming white light) for the provided settings.
            This is expected to be different if the PWM value are clipped to 0
            or 1.

        """
        measured_ir_brightness = np.asarray([
            0, 12.5, 25, 37.5, 50, 62.5, 75, 87.5, 100
        ]) / 100

        lux_reading_white = np.asarray([
            320, 2800, 4670, 5700, 6020, 5620, 4623, 3090, 1090
        ])

        p = np.polyfit(measured_ir_brightness, lux_reading_white, deg=2)

        if ir_brightness is None:
            ir_brightness = self.get_brightness()

        lux_at_ir_brightness = (
            p[0] * ir_brightness ** 2 + p[1] * ir_brightness + p[2]
        )
        power_fraction_required = desired_lux / lux_at_ir_brightness

        power_fraction_required = min(power_fraction_required, 1.)
        power_fraction_required = max(power_fraction_required, 0.)

        estimated_lux = lux_at_ir_brightness * power_fraction_required
        return power_fraction_required, estimated_lux

    def draw_hole(self, Y_index, X_index, radius=1):
        """Illuminate LEDs around a single hole."""
        supported_boards = ['c-001-multi-camera']
        if self.device_name not in supported_boards:
            raise NotImplementedError(
                "Draw hole is only supported for the units:\n"
                f"{supported_boards}\n"
                "Contact Ramona Optics if you need this functionality for other "
                "Illumination boards."
            )

        # Use the Python objects, they are more flexible for the future
        self.led = self.holes[Y_index, X_index].all
        # hole = X_index * self.N_cameras_Y + (self.N_cameras_Y - Y_index - 1)
        # return self.ask('hole.' + str(hole))

    def find_nearest(self, y, x, led_type='rgb'):
        """Finds the nearest LED to the given coordinate.


        Parameters
        ----------
        y : float
            y coordinate in meters.
        x : float
            x coordinate in meters.

        led_type : {'any', 'rgb', 'uv', 'ir'}
            LED chromaticity to search for.

        Returns
        -------
        led_index : int
            LED index closest to the provided coordinate according to the
            Euclidean distance (i.e. L2-norm).
        """
        if led_type == 'any':
            leds = self.all_leds
        elif led_type == 'rgb':
            leds = self.rgb_leds
        elif led_type == 'uv':
            leds = self.uv_leds
        elif led_type == 'ir':
            leds = self.ir_leds
        else:
            raise ValueError(f'Unknown led_type={led_type}')

        ind = np.argmin((self.led_positions.loc[:, 'x'][leds].data - x) ** 2 +
                        (self.led_positions.loc[:, 'y'][leds].data - y) ** 2)
        return leds[ind]

    @property
    @with_thread_lock
    def device_info(self):
        """Provide the serial number and other device info in a dictionary.

        The returned dictionary contains the following keys:
        ``['serial_number', 'device_name']``
        """
        # TODO: migrate this to pyilluminate
        return {
            'serial_number': self.serial_number,
            'device_name': self.device_name,
        }

    @property
    def analog_brightness_settings(self):
        """Electrical current settings for the LEDs.

        The analog brightness settings are only valid for Illuminate boards
        that have the necessary hardware. As of today, only the
        c-008-falcon-transmission board supports analog current control through
        the use of special features in the TLC5955 [1].

        In the parameter definition below, ``MC`` refers to the maximum current
        setting, ``BC`` refers to the brightness control setting, and ``DC`` refers
        to the dot correction setting.

        The settings are expected to be organized as a tuple of length 3. Each
        tuple should contain either a tuple of 3 integers or a single integer.

        >>> light.analog_brightness_settings = ((MC_R, MC_G, MC_B),
        ...                                     (BC_R, BC_G, BC_B),
        ...                                     (DC_R, DC_G, DC_B))

        Notes
        -----
        Setting MC, BC, and DC to 0 will not turn that LED channel off.

        _[1] https://www.ti.com/lit/ds/symlink/tlc5955.pdf

        """
        if not self._has_analog_control:
            raise ValueError(f"Function not supported by board {self.device_name}")

        if self._analog_brightness_settings is not None:
            return self._analog_brightness_settings

        s = tuple(map(int, self._ask_string("gab").split('.')))
        max_current = s[:3]
        brightness_control = s[3:6]
        dot_correction = s[6:9]
        self._analog_brightness_settings = (max_current, brightness_control, dot_correction)
        return self._analog_brightness_settings

    @analog_brightness_settings.setter
    def analog_brightness_settings(self, settings):
        if not self._has_analog_control:
            raise ValueError(f"Function not supported by board {self.device_name}")

        if (not isinstance(settings, collections.abc.Iterable)
           or len(settings) != 3):
            raise ValueError("Must pass one argument containing "
                             "MC, BC, DC data")

        mc, bc, dc = settings  # order of arguments

        if not isinstance(mc, collections.abc.Iterable):
            mc = (mc, mc, mc)
        if not isinstance(bc, collections.abc.Iterable):
            bc = (bc, bc, bc)
        if not isinstance(dc, collections.abc.Iterable):
            dc = (dc, dc, dc)

        # convert to integers since are low level directives to the
        # TLC driver itself.
        mc = tuple(map(int, mc))
        bc = tuple(map(int, bc))
        dc = tuple(map(int, dc))

        if any(val < 0 or val > 2**TLC5955.ANALOG_MAX_CURRENT_BIT_DEPTH - 1
           for val in mc):
            raise ValueError(f"MC is out of range"
                             f"(0-{2**TLC5955.ANALOG_MAX_CURRENT_BIT_DEPTH - 1})")
        if any(val < 0 or
           val > 2**TLC5955.ANALOG_BRIGHTNESS_CONTROL_BIT_DEPTH - 1
           for val in bc):
            raise ValueError(f"BC is out of range"
                             f"(0-{2**TLC5955.ANALOG_BRIGHTNESS_CONTROL_BIT_DEPTH - 1})")
        if any(val < 0 or val > 2**TLC5955.ANALOG_DOT_CORRECTION_BIT_DEPTH - 1
           for val in dc):
            raise ValueError(f"DC is out of range"
                             f"(0-{2**TLC5955.ANALOG_DOT_CORRECTION_BIT_DEPTH - 1})")

        self.ask(f"sab.{mc[0]}.{mc[1]}.{mc[2]}"
                 f".{bc[0]}.{bc[1]}.{bc[2]}"
                 f".{dc[0]}.{dc[1]}.{dc[2]}")
        self._analog_brightness_settings = (mc, bc, dc)

    @property
    def channel_current(self):
        """The analog current output of each channel.

        This function helps estimate the average analog current
        provided to each LED given the present analog and grayscale
        (pulse width modulation or PWM for short) settings for
        the LED Board.

        For example, the C008-Transmission (based on the TCL5955 controller)
        can output a current between 0.08384 mA to 31.9 mA when PWM is disabled.

        The analog current can not be set to 0 unless the PWM settings are
        also set to 0.

        Examples
        --------

        Demonstrating the need to set `color` value to 0 first
        in order to set current to 0
        >>> light = Illuminate()
        # It is best to change the analog settings when the PWM is set to 0
        >>> light.color = 0
        >>> light.analog_brightness_settings = (0, 0, 0)
        >>> light.color = (255, 255, 0)
        >>> print(light.channel_current)
        (8.384E-5, 8.384E-5, 0.0)

        To set the output current to 0, the PWM settings must be set to zero.
        >>> light.color = 0
        >>> print(light.channel_current)
        (0.0, 0.0, 0.0)

        Set brightness based on expected color ratio and brightness
        >>> color_ratio = (0.813, 0.168, 0.557)
        >>> brightness_percentage = 0.1
        >>> light.color = (255, 255, 255)
        >>> max_current = light.get_maximum_channel_current()
        >>> channel_current = tuple(m * c * brightness_percentage
        ...                         for m, c in zip(max_current, color_ratio))
        >>> light.channel_current = channel_current
        >>> light.fill_array()
        >>> print(light.channel_current)
        (0.000678, 0.000143, 0.000464)
        """
        if not self._has_analog_control:
            raise ValueError(f"Function not supported by board {self.device_name}")

        mc, bc, dc = self.analog_brightness_settings
        grayscale = self._color
        current = TLC5955.get_channel_current(mc, bc, dc, grayscale)
        return current

    @channel_current.setter
    @with_thread_lock
    def channel_current(self, current):
        # current limits are defined by current grayscale value
        # TODO: possible conflict between grayscale and analog settings if
        # you can't set them separately due to one of the settings being over current
        # while the other is not changed yet

        if not self._has_analog_control:
            raise ValueError(f"Function not supported by board {self.device_name}")

        else:
            if not isinstance(current, collections.abc.Iterable):
                current = (current, current, current)
            current = np.array(current, dtype=float)
            _, _, dc = self.analog_brightness_settings
            grayscale = self._color
            mc, bc, dc = TLC5955.get_analog_settings_for_current(current, grayscale, dc)
            self.analog_brightness_settings = (mc, bc, dc)

    def get_maximum_channel_current(self, num_leds=None, *, color_ratio=None, dc=None):
        if num_leds is None:
            num_leds = self.N_leds
        if color_ratio is not None:
            if len(color_ratio) != 3:
                raise ValueError("Provided color_ratio has to have 3 entries")
            if any(c > 1 or c < 0 for c in color_ratio):
                raise RuntimeError("Provided color_ratio entries must not be greater "
                                   "than 1 or less than 0.")
            grayscale = (
                ((1 << TLC5955.ANALOG_GRAYSCALE_BIT_DEPTH) - 1) *
                np.asarray(color_ratio)
            )
            grayscale = grayscale.astype('int')
            grayscale = tuple(grayscale)
        else:
            grayscale = self._color

        # We choose to keep "dc" the same when the user is requesting
        # to find the maximum analog_brightness settings
        # changing the "dc" or "DotCorrection" value requires the user to
        # reassert the grayscale pattern This will cause the analog brightness
        # to be updated in a 2 step process.
        # Second, while the TLC5955 has a dc control, the seocnd chip we are
        # considering does not. Therefore, we choose to keep it constant.
        if dc is None:
            _, _, dc = self.analog_brightness_settings
        else:
            dc = tuple(dc)
            if len(dc) != 3:
                raise ValueError("Provided dc has to have 3 entries")
        max_current = TLC5955.get_max_current(gs=grayscale, dc=dc)

        total_current = sum(max_current * num_leds)
        # 2022/01: Maxwell
        # when setting max value to exactly that of the maximum_current
        # A rounding error seems to show up causing the LEDs to turn off.
        allowable_maximum_current = self.maximum_current * 0.95
        if total_current > allowable_maximum_current:
            max_current *= allowable_maximum_current / total_current

        return max_current

    @property
    def led_current_amps(self):
        """Maximum current in amps per LED channel."""
        if self._has_analog_control:
            mc, bc, dc = self.analog_brightness_settings
            led_current_amps = TLC5955.get_max_current(mc=mc, bc=bc, dc=dc)
        else:
            led_current_amps = 0.02
        return led_current_amps

    @property
    def total_output_current(self) -> int:
        if self.device_name == "c-008-falcon-transmission":
            # Supported by the c-008-falcon-transmission boards
            pass
        elif self.device_name == "c-007-falcon":
            raise ValueError(f"Function not supported by board {self.device_name}")
        elif self.device_name == "c-001-multi-camera":
            raise ValueError(f"Function not supported by board {self.device_name}")

        return float(self._ask_string("gtc"))

    def find_max_brightness(self, num_leds, color_ratio=None):
        """Calculate the maximum brightness for each color channel of an LED
        that won't exceed the TLC's internal current limit.

        Parameters
        ----------
        num_leds: int
            The number of LEDs to be illuminated.
        color_ratio: (float, float, float)
            The required ratio for the brightness values
            of each color channel (r, g, b)

        Returns
        -------
        brightness: (float, float, float)
            The maximum scaled brightness for each color channel.
        """
        uint16_max = 65535

        if color_ratio is None:
            color_ratio = self.color
        color_ratio = np.asarray(color_ratio)
        color_ratio = color_ratio / np.sum(color_ratio)

        # equation modified from TLC5955 driver
        total_brightness = (self.maximum_current * uint16_max /
                            (num_leds * self._scale_factor *
                             np.asarray(self.led_current_amps)))

        max_brightness = total_brightness * color_ratio

        max_one_led_brightness = np.max(max_brightness)
        if max_one_led_brightness > 255:
            max_brightness = max_brightness * 255 / max_one_led_brightness
        return tuple(max_brightness)

    def find_row(self, led, direction='horizontal', led_type='any'):
        """Given an LED, find the row which it belongs to.

        Parameters
        ----------
        led: int

        led_type: {'any', 'rgb', 'uv', 'ir'}

        direction: {'horizontal', 'vertical'}

        Returns
        -------
        leds: list
            List of the indices of all LEDs in the same row as the provided led.
        """

        if led_type == 'any':
            leds = self.all_leds
        elif led_type == 'rgb':
            leds = self.rgb_leds
        elif led_type == 'uv':
            leds = self.uv_leds
        elif led_type == 'ir':
            leds = self.ir_leds
        else:
            raise ValueError(f'Unknown led_type={led_type}.')

        if not np.isin(led, leds, assume_unique=True):
            raise ValueError(f'Provided led ({led}) not in {led_type}.')

        tolerance = 10E-6  # meters
        if direction == 'horizontal':
            ind = np.where(np.abs(
                self.led_positions.loc[:, 'y'][leds] - self.led_positions.loc[:, 'y'][led]) <
                tolerance)
        elif direction == 'vertical':
            ind = np.where(np.abs(
                self.led_positions.loc[:, 'x'][leds] - self.led_positions.loc[:, 'x'][led]) <
                tolerance)
        else:
            raise ValueError(f'Unknown direction={direction}.')
        return [leds[i] for i in ind[0]]

    @with_thread_lock
    def fill_array(self, led_type='rgb'):
        """Turn on all leds of a given type.

        Parameters
        ----------
        led_type: {'any', 'rgb', 'uv', 'ir'}
        """
        if self.device_name == "c-011-vireo-brightfield":
            if led_type in ['rgb', 'any']:
                return super().fill_array()
            else:
                raise ValueError(f'Unknown LED type: "{led_type}" for board.')
            return

        # c-008 rev 1 only has RGB LEDS
        if self.device_name == "c-008-falcon-transmission":
            if self._revision >= 4:
                if led_type == 'any':
                    return super().fill_array()
                elif led_type == 'ir':
                    self.ask('fillCheckboard01')
                    self._led = list(self.ir_leds)
                elif led_type == 'rgb':
                    self.ask('fillCheckboard00')
                    self._led = list(self.rgb_leds)
                else:
                    raise ValueError(f'Unknown LED type: "{led_type}" for board.')
            else:
                if led_type in ['rgb', 'any']:
                    return super().fill_array()
                else:
                    raise ValueError(f'Unknown LED type: "{led_type}" for board.')
            return

        if self.device_name == "c-007-falcon":
            if led_type == 'any':
                return super().fill_array()
            elif led_type == "uv":
                self.ask('fillUV')
                self._led = list(self.uv_leds)
            elif led_type == 'rgb':
                self.ask('fillRGB')
                self._led = list(self.rgb_leds)
            else:
                raise ValueError(f'Unknown LED type: "{led_type}" for board.')
            return

        # Older boards that don't have dedicated commands
        if led_type == 'any':
            super().fill_array()
        elif led_type == 'rgb':
            self.led = self.rgb_leds
        elif led_type == 'uv':
            self.led = self.uv_leds
        else:
            raise ValueError(f'Unknown led_type={led_type}')

    def _read_led_positions(self):
        # Read from file. This is much more reliable than reading
        # from USB
        # LED positions already in z, y, x
        # These file were generated from
        # https://gitlab.com/ramonaoptics/falcon/pymcam/-/merge_requests/4

        # It can also be generated by going to C file, copying and
        # pasting the led_positions array and using the following
        # skeleton code
        """
        import numpy as np
        # Replace the { with [ and the } with ]
        led_positions = np.asarray([ ... ])
        led_positions = led_positions[:, ::-1][:, :3]
        import ro_json

        with open('c008_led_positions.json', 'w+', encoding='utf-8') as f:
            ro_json.dump(led_positions, f, indent=4)
        """
        filename = ramona_optics_led_positions.loc[self.device_name].led_positions_filename
        self._led_positions = _read_led_positions(filename)

    @with_thread_lock
    def _update_firmware(self):
        serial_number = self.serial_number
        self.close()
        Illuminate.update_firmware(serial_number)

        # Give firmware time to reset before re-opening the board
        sleep(1)
        self.open()

    @property
    def _has_analog_control(self):
        # Only the TLC5955 boards have analog control functionality
        return self.device_name in [
            "c-008-falcon-transmission",
            "c-011-vireo-brightfield",
            "c-012-vireo-brightfield"
        ]

    @property
    def available_illumination_modes(self):
        available_illumination_modes = []
        if self.device_name == "c-012-vireo-brightfield":
            available_illumination_modes = [
                'visible_analog_535nm_diffuser',
                'visible_analog_535nm_focused',
                'visible_pwm_535nm_diffuser',
                'visible_pwm_535nm_focused',
            ]
        else:
            for led_type in self.available_led_types:
                for led_control_mode in self.available_led_control_modes:
                    for led_pattern in self.available_led_patterns:
                        available_illumination_modes.append(
                            f'{led_type}_{led_control_mode}_{led_pattern}'
                        )
        return available_illumination_modes

    @property
    def available_led_types(self):
        available_led_types = ['visible', 'red', 'green', 'blue']
        if len(self.ir_leds) > 0:
            available_led_types += ['ir850']
        return available_led_types

    @property
    def available_led_control_modes(self):
        available_led_control_modes = ['pwm']
        if self._has_analog_control:
            available_led_control_modes += ['analog']
        return available_led_control_modes

    @property
    def available_led_patterns(self):
        available_led_patterns = ['fullarray']
        if self.device_name in ['c-008-falcon-transmission', 'c-007-falcon']:
            available_led_patterns.append('perimeter')
        if self.device_name == "c-012-vireo-brightfield":
            available_led_patterns.append('535nm_diffuser')
            available_led_patterns.append('535nm_focused')
        return available_led_patterns

    @contextlib.contextmanager
    def _with_hold_autoclear_autoupdate(self):
        old_autoclear = self.autoclear
        old_autoupdate = self.autoupdate
        try:
            self.autoclear = False
            self.autoupdate = False
            yield
        finally:
            self.autoclear = old_autoclear
            self.autoupdate = old_autoupdate

    @property
    def background_color(self):
        """The background RGB color set when the IR leds are on.

        Only valid in 'ir850_analog_fullarray' is used.

        The color of the RGB leds.
        """
        return self._background_color

    @property
    def background_lux(self):
        """The target brightness (in Lux) of the RGB leds.

        Only valid when 'ir850_analog_fullarray' is used.
        """
        return self._background_lux

    def _get_rgb_color_for_lux(
        self,
        lux, *,
        color_ratio=None,
        ir_brightness=None,
        ir_power_fraction_used=None,
    ):
        if lux == 0.:
            return (0., 0., 0.)

        # Note
        if self.get_illumination_mode() != "ir850_analog_fullarray":
            raise RuntimeError("This function is only valid for ir850_analog_fullarray")

        if color_ratio is None:
            color_ratio = (1., 1., 1.)

        if ir_brightness is None or ir_power_fraction_used is None:
            if ir_brightness is not None or ir_power_fraction_used is not None:
                raise RuntimeError(
                    "Both ir_brightness and ir_power_fraction_used need to be "
                    "either specified, or unspecified."
                )
            # Normalize to 255 so that we can multiply easily
            mc, bc, dc = self.analog_brightness_settings
            per_led_current = np.asarray(
                TLC5955.get_channel_current(
                    mc, bc, dc, (2**TLC5955.ANALOG_GRAYSCALE_BIT_DEPTH - 1,) * 3)
            )

            N_leds = len(self.ir_leds)
            total_ir_current = N_leds * per_led_current[2]

            ir_power_fraction_used = total_ir_current / self.maximum_current

            ir_brightness = self._get_analog_brightness(
                N_leds=N_leds,
                channel_current=(0, 0, per_led_current[2]),
            )

        if lux == 0:
            return (0, 0, 0)

        # This LUX computation is pretty wonky, but it should give
        # consistent results. It is "tuned" to work with "white" light
        # but will monotonically increasing values as a function of the requested "lux"
        power_fraction, _estimated_lux = self._compute_rgb_lux_settings(
            desired_lux=lux,
            ir_brightness=ir_brightness,
        )
        # We will want to clip to 95% max brightness so as not to trigger
        # some safety settings in the Illumination board.
        power_fraction = min(power_fraction, .95)

        if color_ratio is None:
            color_ratio = np.asarray((1., 1., 1.))

        if len(color_ratio) != 3:
            raise ValueError("background_color_ratio must be len 3")
        color_ratio = np.array(color_ratio, dtype='float', copy=True)
        color_ratio /= color_ratio.sum()

        rgb_fraction = 1 - ir_power_fraction_used
        rgb_color = power_fraction * rgb_fraction * 255 * color_ratio
        rgb_color = tuple(min(p, 255) for p in rgb_color)
        return rgb_color

    @with_thread_lock
    def set_brightness(
        self,
        brightness_fraction,
        illumination_mode=None,
        *,
        color_ratio=None,
        background_lux=0,
        background_color_ratio=None,
        wait=True,
    ):
        """Set the brightness for a given illumination mode and color.

        Parameters
        ----------
        brightness_fraction: float
            A value from 0 to 1 that sets the illumination board to that
            fraction of the max possible brightness.
        illumination_mode: str
            The desired mode of illumination. If none is given will keep
            the currenet illumination mode.
        color_ratio: tuple of floats
            A tuple of length 3 that lists the ratio between the channels.
            Any values can be given, but the ratios will be normalized so
            that they sum to 1. This is only valid for the 'visible'
            illumination modes.
        """

        visible_led_types = ['rgb', 'red', 'green', 'blue']
        if illumination_mode is not None:

            if illumination_mode not in self.available_illumination_modes:
                raise ValueError(f'Given led type `{illumination_mode}` '
                                 f'is not one of the available led types: '
                                 f'{self.available_illumination_modes}')
            (led_type,
             led_control_mode,
             led_pattern,
             suggested_color_ratio) = _parse_illumination_mode(illumination_mode)
        else:
            if self._illumination_mode_led_type == 'visible':
                led_type = 'rgb'
                suggested_color_ratio = None
            elif self._illumination_mode_led_type == 'ir850':
                led_type = 'ir'
                suggested_color_ratio = None
            elif self._illumination_mode_led_type == 'red':
                led_type = 'red'
                suggested_color_ratio = (1, 0, 0)
            elif self._illumination_mode_led_type == 'green':
                led_type = 'green'
                suggested_color_ratio = (0, 1, 0)
            elif self._illumination_mode_led_type == 'blue':
                led_type = 'blue'
                suggested_color_ratio = (0, 0, 1)
            else:
                suggested_color_ratio = None
                led_type = self._illumination_mode_led_type
            led_control_mode = self._illumination_mode_led_control_mode
            led_pattern = self._illumination_mode_led_pattern

        # if brightness fraction is 0 we want to clear instead of setting
        # the color to retain the color ratio for later changes
        if brightness_fraction == 0:
            if led_type == 'rgb':
                self._illumination_mode_led_type = 'visible'
            elif led_type in ['red', 'green', 'blue']:
                self._illumination_mode_led_type = led_type
            elif led_type == 'ir':
                self._illumination_mode_led_type = 'ir850'
            else:
                self._illumination_mode_led_type = led_type
            self._illumination_mode_led_control_mode = led_control_mode
            self._illumination_mode_led_pattern = led_pattern
            self.clear()
            return

        if brightness_fraction < 0 or brightness_fraction > 1:
            raise ValueError(
                "brightness_fraction must be within the range [0, 1]."
            )

        if led_pattern == 'perimeter':
            edge_leds = self.draw_edge(
                led_type='ir' if led_type == 'ir' else 'rgb',
                num_leds=1,
                set_leds=False
            )
            N_leds = len(edge_leds)
        elif led_pattern == 'fullarray' and led_type == 'ir':
            N_leds = len(self.ir_leds)
        elif led_pattern == 'fullarray' and led_type in visible_led_types:
            N_leds = len(self.rgb_leds)
        elif led_pattern == '535nm_diffuser':
            grid_leds = self.draw_grid(
                grid_index=0,
                set_leds=False
            )
            N_leds = len(grid_leds)
        elif led_pattern == '535nm_focused':
            grid_leds = self.draw_grid(
                grid_index=1,
                set_leds=False
            )
            N_leds = len(grid_leds)

        # When changing between LED modes, we must clear the array to avoid
        # a case where there is too much current provided to the LEDs
        if self._illumination_mode_led_control_mode != led_control_mode:
            self.clear()

        if color_ratio is None:
            if suggested_color_ratio is not None:
                color_ratio = suggested_color_ratio
            else:
                color_ratio = self.color

        if led_type not in visible_led_types:
            # Keep this index synchronized with
            # color_850_ir
            color_ratio = (0, 0, 1)

        if any(c < 0 for c in color_ratio):
            raise RuntimeError("Provided color_ratio entries must not be less than 0.")
        if all(c == 0 for c in color_ratio):
            raise RuntimeError(
                "Provided color_ratio entries must not all equal 0. "
                "if color_ratio was not provided ensure the ``color`` "
                "parameter is not set to (0, 0, 0)"
            )
        color_ratio = np.asarray(color_ratio)
        color_ratio = color_ratio / np.sum(color_ratio)
        if led_control_mode == 'analog' and led_type in visible_led_types:
            # We don't use the high level channel_current API since it
            # has some strange behavior when settings are identically 0
            # instead, we use a method to ensure that will set the settings
            # more adequately when we expect that the settings for the
            # grayscale clock (pwm) are binary. Either always 0 in time, or
            # always 1 in time.
            #
            # We set dc to 30, instead of the default 127
            # so that we get more range in MC and BC.
            dc = (30, 30, 30)
            max_channel_current = self.get_maximum_channel_current(
                N_leds,
                color_ratio=color_ratio,
                dc=dc,
            )

            gs_max = 2 ** TLC5955.ANALOG_GRAYSCALE_BIT_DEPTH - 1
            gs = (gs_max, gs_max, gs_max)
            mc, bc, dc = TLC5955.get_analog_settings_for_current(
                max_channel_current * brightness_fraction,
                gs,
                dc
            )

            if led_type not in visible_led_types:
                # use the IR 850 nm channel
                # for all 3 channels so that we can "flash" with light that
                # is of an RGB color other than RGB[2] (blue)
                mc = (mc[2],) * 3
                bc = (bc[2],) * 3
                dc = (dc[2],) * 3

            duty_cycle = tuple(
                255 if (c != 0) else 0
                for c in color_ratio
            )

            # with these controllers the color parameter controls the amount of time the
            # light channel is on from 0 (not on) to 255 (on all the time).
            self.color = duty_cycle
            self.analog_brightness_settings = mc, bc, dc
        elif led_control_mode == 'analog' and led_type == 'ir':
            # We don't use the high level channel_current API since it
            # has some strange behavior when settings are identically 0
            # instead, we use a method to ensure that will set the settings
            # more adequately when we expect that the settings for the
            # grayscale clock (pwm) are binary. Either always 0 in time, or
            # always 1 in time.
            #
            # We set dc to 30, instead of the default 127
            # so that we get more range in MC and BC.
            dc = (30, 30, 30)
            max_channel_current = self.get_maximum_channel_current(
                N_leds,
                color_ratio=color_ratio,
                dc=dc,
            )

            gs_max = 2 ** TLC5955.ANALOG_GRAYSCALE_BIT_DEPTH - 1
            gs = (gs_max, gs_max, gs_max)
            mc, bc, dc = TLC5955.get_analog_settings_for_current(
                max_channel_current * brightness_fraction, gs, dc)

            per_led_current = np.asarray(
                TLC5955.get_channel_current(
                    mc, bc, dc, (2**TLC5955.ANALOG_GRAYSCALE_BIT_DEPTH - 1,) * 3)
            )

            total_ir_current = N_leds * per_led_current[2]

            # use the IR 850 nm channel
            # for all 3 channels so that we can "flash" with light that
            # is of an RGB color other than RGB[2] (blue)
            mc = (mc[2],) * 3
            bc = (bc[2],) * 3
            dc = (dc[2],) * 3

            ir_pwm = (0., 0., 255.)

            power_fraction_used = total_ir_current / self.maximum_current

            ir_brightness = self._get_analog_brightness(
                N_leds=N_leds,
                channel_current=(0, 0, per_led_current[2]),
            )

            # There are two cases we need to consider
            # Increase in PWM for the RGB leds
            # Decreasing in PWM for the RGB leds

            # if we are increase in PWM then we want to set the analog setting
            # first
            # if we are decreasing in PWM, then we want to set the new PWM for
            # the RGB LEDs.
            old_rgb_color = self._background_color

            # We need to set the illumination mode here so that it is in ir850_analog_fullarray
            # for _get_rgb_color_for_lux()
            self._illumination_mode_led_type = 'ir850'
            self._illumination_mode_led_control_mode = led_control_mode
            self._illumination_mode_led_pattern = led_pattern

            new_rgb_color = self._get_rgb_color_for_lux(
                lux=background_lux,
                color_ratio=background_color_ratio,
                ir_brightness=ir_brightness,
                ir_power_fraction_used=power_fraction_used,
            )

            # with these controllers the color parameter controls the amount of time the
            # light channel is on from 0 (not on) to 255 (on all the time).
            with self._with_hold_autoclear_autoupdate():
                if np.sum(old_rgb_color) < np.sum(new_rgb_color):
                    self.color = new_rgb_color
                    self.fill_array(led_type='rgb')
                    self.color = ir_pwm
                    self.analog_brightness_settings = mc, bc, dc

                    if led_pattern == 'fullarray':
                        self.fill_array(led_type=led_type)
                    elif led_pattern == 'perimeter':
                        self.draw_edge(led_type=led_type, num_leds=1)
                    self.update()
                else:
                    self.color = ir_pwm
                    self.analog_brightness_settings = mc, bc, dc

                    if led_pattern == 'fullarray':
                        self.fill_array(led_type=led_type)
                    elif led_pattern == 'perimeter':
                        self.draw_edge(led_type=led_type, num_leds=1)
                    self.update()
                    self.color = new_rgb_color
                    self.fill_array(led_type='rgb')
                    # Needs two update commands....
                    self.update()
                self.color = ir_pwm

            self._background_color = new_rgb_color
            self._background_lux = background_lux
            return
        elif led_control_mode == 'pwm':
            if self._has_analog_control:
                self.analog_brightness_settings = (4, 127, 127)

            maximum_led_brightness = self.find_max_brightness(
                num_leds=N_leds, color_ratio=color_ratio)
            color = np.array(maximum_led_brightness) * brightness_fraction
            self.color = color

        if led_pattern == 'fullarray':
            if led_type in visible_led_types:
                self.fill_array(led_type='rgb')
            else:  # ir
                self.fill_array(led_type='ir')
        elif led_pattern == 'perimeter':
            if led_type in visible_led_types:
                self.draw_edge(led_type='rgb', num_leds=1)
            else:  # ir
                self.draw_edge(led_type='ir', num_leds=1)
        elif led_pattern == '535nm_diffuser':
            if led_type in visible_led_types:
                self.draw_grid(grid_index=0)
        elif led_pattern == '535nm_focused':
            if led_type in visible_led_types:
                self.draw_grid(grid_index=1)

        if led_type == 'rgb':
            self._illumination_mode_led_type = 'visible'
        elif led_type == 'red':
            self._illumination_mode_led_type = 'red'
        elif led_type == 'green':
            self._illumination_mode_led_type = 'green'
        elif led_type == 'blue':
            self._illumination_mode_led_type = 'blue'
        elif led_type == 'ir':
            self._illumination_mode_led_type = 'ir850'
        else:
            self._illumination_mode_led_type = led_type

        self._illumination_mode_led_control_mode = led_control_mode
        self._illumination_mode_led_pattern = led_pattern
        self._background_color = (0, 0, 0)
        self._background_lux = 0

    def _get_analog_brightness(self, N_leds, *, channel_current=None):
        if channel_current is None:
            channel_current = self.channel_current
        color_ratio = np.asarray(channel_current)
        color_ratio = color_ratio / np.sum(color_ratio)
        max_channel_current = self.get_maximum_channel_current(
            N_leds,
            color_ratio=color_ratio
        )
        brightness_fraction = np.sum(channel_current) / np.sum(max_channel_current)
        return brightness_fraction

    @property
    def brightness(self):
        # Warning added in 2025/08/01, remove in 2026/08/01 and update pyilluminate as well
        warn(
            "The `brightness` property is deprecated. Use `get_brightness` instead.",
            stacklevel=2
        )
        return super().brightness

    @brightness.setter
    def brightness(self, brightness_fraction):
        # Warning added in 2025/08/01, remove in 2026/08/01 and update pyilluminate as well
        warn(
            "The `brightness` property is deprecated. Use `set_brightness` instead.",
            stacklevel=2
        )
        super().brightness = brightness_fraction

    def get_brightness(self, *, _stacklevel_increment=0):
        if self._illumination_mode_led_type in ['visible', 'red', 'green', 'blue']:
            led_type = 'rgb'
        elif self._illumination_mode_led_type == 'ir850':
            led_type = 'ir'
        else:
            led_type = self._illumination_mode_led_type
        led_control_mode = self._illumination_mode_led_control_mode
        led_pattern = self._illumination_mode_led_pattern

        if led_pattern == 'perimeter':
            N_leds = len(self.draw_edge(led_type=led_type, num_leds=1, set_leds=False))
        elif led_pattern == 'fullarray' and led_type == 'ir':
            N_leds = len(self.ir_leds)
        elif led_pattern == 'fullarray' and led_type == 'rgb':
            N_leds = len(self.rgb_leds)
        elif led_pattern == '535nm_diffuser':
            grid_leds = self.draw_grid(
                grid_index=0,
                set_leds=False
            )
            N_leds = len(grid_leds)
        elif led_pattern == '535nm_focused':
            grid_leds = self.draw_grid(
                grid_index=1,
                set_leds=False
            )
            N_leds = len(grid_leds)

        if np.sum(self.color) == 0 or len(self.led) == 0:
            brightness_fraction = 0
        else:
            if led_control_mode == 'analog':
                brightness_fraction = self._get_analog_brightness(N_leds=N_leds)
            elif led_control_mode == 'pwm':
                color_ratio = np.asarray(self.color)
                color_ratio = color_ratio / np.sum(color_ratio)
                maximum_led_brightness = self.find_max_brightness(
                    num_leds=N_leds, color_ratio=color_ratio)
                brightness_fraction = np.sum(self.color) / np.sum(maximum_led_brightness)

        # We can report slightly higher than 1 due to rounding errors
        if brightness_fraction > 1.01:
            warn("The reported brightness is greater than 1.0. "
                 "This is likely due to a rounding error. "
                 "Please report this issue to help@ramonaoptics.com",
                 stacklevel=2 + _stacklevel_increment)
        else:
            # For small errors, we absorb them to avoid confusion.
            # This helps users "set brightness to 1.0" and get back "1.0"
            # and not a number greater than 1.0
            # https://gitlab.com/ramonaoptics/mcam/python-owl/-/issues/1514
            brightness_fraction = min(brightness_fraction, 1.0)
        return brightness_fraction

    def get_illumination_mode(self):
        led_type = self._illumination_mode_led_type
        led_control_mode = self._illumination_mode_led_control_mode
        led_pattern = self._illumination_mode_led_pattern
        return f'{led_type}_{led_control_mode}_{led_pattern}'


# Depend on IlluminateTools first so that they override the pyilluminate
# functions
[docs] class Illuminate(IlluminateTools, pyilluminate.Illuminate):
[docs] @staticmethod def update_firmware(serial_number, *, device_name=None, mcu=None): """Update a device firmware. Update the device firmware by specifying its serial number. For unregistered devices, one can specify the ``device_name`` manually. Parameters ---------- serial_number: str The serial number of the device to open. device_name: str Optional parameter to specify exactly what firmware to program on the device. mcu: None or 'TEENSY31' The microcontroller unit used in the LED board. If ``device_name`` is not specified, this parameter is ignored. """ import tempfile import gdown if device_name is None: device = known_devices.loc[serial_number] device_name = device.device_name firmware_uri = device.uri mcu = device.mcu else: df = ramona_optics_firmware_info.loc[device_name] if device_name == 'c-008-falcon-transmission': if mcu is not None: df = df.where(df['mcu'] == mcu).dropna() df = df.iloc[0] firmware_uri = df.uri mcu = df.mcu firmware_filename = tempfile.mktemp( suffix='.hex', prefix=device_name + '_') result = gdown.download(firmware_uri, firmware_filename, quiet=True) if result is None: raise RuntimeError('Failed to download firmware.') cmd_list = [ 'teensy_loader_cli', '-s', f'--mcu={mcu}', ] if (os.name != 'nt') and (serial_number is not None): # This feature needs # https://github.com/PaulStoffregen/teensy_loader_cli/pull/57 cmd_list.append(f'--serial-number={serial_number}') cmd_list.append(firmware_filename) print(' '.join(cmd_list)) # Acquire lock so that we don't destroy a user's running application. lock = pyilluminate.Illuminate._make_lock(serial_number) with lock: subprocess.check_call(cmd_list) if serial_number not in known_devices.index: device_info = { 'serial_number': serial_number, 'device-name': device_name, 'mcu': mcu, } warn("This device is not known to Ramona Optics. " "Please contact help@ramonaoptics.com with a screenshot " "of your screen including this error message referring to " "this warning with the following information: " + str(device_info))
@staticmethod def reboot_stuck_device(serial_number=None, mcu=None): if mcu is None: if serial_number is not None: mcu = known_devices.loc[serial_number].mcu else: mcu = 'TEENSY31' cmd_list = [ 'teensy_loader_cli', '-b', '-s', f'--mcu={mcu}', ] if serial_number is not None and os.name != 'nt': # This feature needs # https://github.com/PaulStoffregen/teensy_loader_cli/pull/57 cmd_list.append(f'--serial-number={serial_number}') # Acquire lock so that we don't destroy a user's running application. if serial_number is not None: lock = pyilluminate.Illuminate._make_lock(serial_number) else: # Dummy context manager lock = memoryview(b'') with lock: subprocess.check_call(cmd_list)
[docs] @staticmethod def list_all_serial_numbers(serial_numbers=None): available_serial_numbers = pyilluminate.Illuminate.list_all_serial_numbers( serial_numbers=serial_numbers) return [s for s in available_serial_numbers if s in Illuminate._known_serial_numbers]
[docs] @classmethod def by_device_name(cls, device_name, *args, **kwargs): """Connect to an LED board by device name.""" friendly_name = ramona_optics_led_positions.loc[device_name].friendly_name ids = cls._known_devices.device_name == device_name available_serial_numbers = set(cls.list_all_serial_numbers()) serial_numbers = cls._known_devices[ids].index serial_numbers = available_serial_numbers.intersection(serial_numbers) if len(serial_numbers) == 0: raise RuntimeError(f"Could not find a {friendly_name} ({device_name}).") serial_number = kwargs.get('serial_number', None) if serial_number is not None: if serial_number not in serial_numbers: raise ValueError( f"There is no {friendly_name} ({device_name}) device with " f"serial number {serial_number}.") else: # Take the first device found??? This isn't super good # maybe the lock acquire mechanism should keep trying items # in the serial number list until one of them works. kwargs['serial_number'] = serial_numbers.pop() return cls(*args, **kwargs)
def __init__(self, *, N_cameras_Y=None, N_cameras_X=None, flip_along_y=False, flip_along_x=False, check_version=True, **kwargs): self._N_cameras = (N_cameras_Y, N_cameras_X) self._flip_along_y = flip_along_y self._flip_along_x = flip_along_x self._check_version = check_version self._illumination_mode_led_type = None self._illumination_mode_led_control_mode = None self._illumination_mode_led_pattern = None super().__init__(**kwargs) # TODO: fix this. this should also be set to None upon close self._analog_brightness_settings = None @with_thread_lock def open(self): try: self._open_locked() except Exception: self.close() raise def _open_locked(self): super().open() if self.device_name in ["c-012-vireo-brightfield"]: # TODO: Mark doesn't believe that this is entirely # correct since we don't set brightness like this for other # boards. # Here we are setting the background parameters to ensure that things # Start in the correct state. illumination_mode = self.available_illumination_modes[0] self.set_brightness( self.get_brightness(), illumination_mode=illumination_mode ) else: # TODO: enforce this in hardware # this was the default before the addition of the C-012 boards # in Feb. 2026 so we maintain that here explicitly self._illumination_mode_led_type = "visible" self._illumination_mode_led_control_mode = "pwm" self._illumination_mode_led_pattern = "fullarray" self._background_color = (0, 0, 0) self._background_lux = 0 if self.device_name not in self._known_devices.device_name.unique(): raise ValueError( "This is not a Ramona Optics Illuminate board. " f"The device name is {self.device_name} and unknown to " "this library.") self._setup_led_positions() if self.serial_number not in known_devices.index: warn("This device is not known to Ramona Optics. " "Please contact help@ramonaoptics.com " "referring to this warning with the following information: " + str(self.device_info)) # Serial number is known, lets ensure they are on the # latest recommended version # But only if they want us to check elif self._check_version: entry = self._known_devices.loc[self.serial_number] if Version(self.version) < Version(entry.recommended_firmware_version): if os.name == 'nt': # Do not auto-update on windows since it will # basically erase a random device... warn("The firmware on this device is out of date. " "To update it, please contact Ramona Optics.", stacklevel=2) else: warn("The firmware on this device is out of date. " "To update it, please run the command \n\n" " python -c 'from owl.instruments import Illuminate; Illuminate.update_firmware(\""f"{self.serial_number}""\")'" # noqa "\n\n", stacklevel=2)
# self._update_firmware() # The FakeIlluminate class from pyilluminate is pretty terrible. lets just # avoid it all together class FakeIlluminate(IlluminateTools, pyilluminate.Illuminate): def ask(self, *args, **kwargs): # Dummy ask function pass @property def autoupdate(self): return self._autoupdate @autoupdate.setter @with_thread_lock def autoupdate(self, value): self._autoupdate = bool(value) @property def autoclear(self): return self._autoclear @autoclear.setter @with_thread_lock def autoclear(self, value): self._autoclear = bool(value) @property def maximum_current(self): return self._maximum_current @maximum_current.setter def maximum_current(self, value): self._maximum_current = value def __init__( self, *args, device_name=None, revision=None, serial_number=None, version=None, open_device=True, **kwargs, ): self._illumination_mode_led_type = None self._illumination_mode_led_control_mode = None self._illumination_mode_led_pattern = None self._flip_along_y = False self._flip_along_x = False self._uv_leds = [] self._ir_leds = [] if serial_number in self._known_serial_numbers: # we want the user to continue to be able to change the # device_name and revision of the illumination board device = self._known_devices.loc[serial_number] if device['mcu'] != "fake_device": warn("Requested device is not a fake_device. This is not supported.", stacklevel=2) else: device = self._known_devices.loc["123456"] if serial_number is None: serial_number = '123456' if device_name is None: device_name = device['device_name'] if revision is None: revision = device['revision'] if version is None: version = device['firmware_version'] if not isinstance(version, str) and np.isnan(version): # By default, our fake devices had version 1.20.10 # This should only happen if a user requests an invalid # fake_device version = "1.22.0" self._device_name = device_name self._dummy_revision = revision self._serial_number = serial_number self._dummy_version = version super().__init__(*args, open_device=False, serial_number=serial_number, **kwargs) if serial_number != '123456' and device_name != device['device_name']: # We fail "late" here since # we must initiate the parent class so that it can shut down correctly # in the __del__ routine # that would get called even in this case where we raise an error in the __init__ raise ValueError( f'No device name {device_name} found for serial number {serial_number}' ) # We disabled open_device in the call to super() so that we can # check the serial number and raise an error # if it is valid, then continue on to opening if the user requests it if open_device: self.open() @with_thread_lock def open(self): import xarray as xr self.autoclear = True self.autoupdate = True self._background_color = (0, 0, 0) self._background_lux = 0 self._read_led_positions() self.N_leds = self._led_positions.shape[0] # For some reason we use the _led_count in pyilluminate self._led_count = self.N_leds self._interface_bit_depth = 16 if self._precision == 'float': self._scale_factor = ((1 << self._interface_bit_depth) - 1) else: if self._precision > self._interface_bit_depth: self.close() raise ValueError( f"Selected precision {self._precision} is not supported " "by this LED board. " "This board only supports bit depths up to " f"{self._interface_bit_depth} bits." "Please contact support for more assistance." ) else: self._scale_factor = ( ((1 << self._interface_bit_depth) - 1) / ((1 << self._precision) - 1) ) led_state = xr.DataArray( np.zeros((self.N_leds, 3)), dims=['led_number', 'rgb'], coords={'led_number': np.arange(self.N_leds), 'rgb': ['r', 'g', 'b']}) led_state['precision'] = self.precision led_state['firmware_version'] = self.version led_state['device_name'] = self.device_name led_state['serial_number'] = self.serial_number self._led_state = led_state self._setup_led_positions() # The following ensures that we can read the analog brightness settings # for the fake device as well. This mimics the defaults that the # hardware boots up with for the c008 and c011 boards, the only ones we # have analog control for. if self.device_name == "c-008-falcon-transmission": if self._has_analog_control: self.analog_brightness_settings = ( (4, 4, 4), (127, 127, 127), (127, 127, 127), ) elif self.device_name == "c-011-vireo-brightfield": if self._has_analog_control: self.analog_brightness_settings = ( (1, 1, 1), (127, 127, 127), (127, 127, 127), ) # Set the brightness low so we can live if self._precision == 'float': self.color = self.color_minimum_increment else: self.color = 1 if "visiable_pwm_fullarray" in self.available_illumination_modes: # this was the default before the addition of the C-012 boards # in Feb. 2026 so we maintain that here explicitly self._illumination_mode_led_type = "visible" self._illumination_mode_led_control_mode = "pwm" self._illumination_mode_led_pattern = "fullarray" else: illumination_mode = self.available_illumination_modes[0] self.set_brightness( self.get_brightness(), illumination_mode=illumination_mode ) @with_thread_lock def close(self): pass @with_thread_lock def _update_firmware(self): return @classmethod def by_device_name(cls, device_name, *args, **kwargs): kwargs['device_name'] = device_name self = cls(*args, **kwargs) return self @property def device_name(self): return self._device_name @property def _revision(self): return self._dummy_revision @property def version(self): return self._dummy_version @property def total_output_current(self): if self.device_name == 'c-008-falcon-transmission': # Normalize to 255 so that we can multiply easily led_state = np.asarray(self.led_state) / 255 mc, bc, dc = self.analog_brightness_settings per_led_current = np.asarray( TLC5955.get_channel_current( mc, bc, dc, (2**TLC5955.ANALOG_GRAYSCALE_BIT_DEPTH - 1,) * 3) ) led_current = led_state * per_led_current total_current = led_current.sum() return total_current else: raise NotImplementedError( f"We do not mock this yet for {self.device_name}...." ) def _parse_illumination_mode(illumination_mode): possibilities = { 'visible_pwm_fullarray': ('rgb', 'pwm', 'fullarray', None), 'visible_pwm_perimeter': ('rgb', 'pwm', 'perimeter', None), 'visible_analog_fullarray': ('rgb', 'analog', 'fullarray', None), 'visible_analog_perimeter': ('rgb', 'analog', 'perimeter', None), 'red_analog_fullarray': ('red', 'analog', 'fullarray', (1, 0, 0)), 'red_analog_perimeter': ('red', 'analog', 'perimeter', (1, 0, 0)), 'green_analog_fullarray': ('green', 'analog', 'fullarray', (0, 1, 0)), 'green_analog_perimeter': ('green', 'analog', 'perimeter', (0, 1, 0)), 'blue_analog_fullarray': ('blue', 'analog', 'fullarray', (0, 0, 1)), 'blue_analog_perimeter': ('blue', 'analog', 'perimeter', (0, 0, 1)), 'red_pwm_fullarray': ('red', 'pwm', 'fullarray', (1, 0, 0)), 'red_pwm_perimeter': ('red', 'pwm', 'perimeter', (1, 0, 0)), 'green_pwm_fullarray': ('green', 'pwm', 'fullarray', (0, 1, 0)), 'green_pwm_perimeter': ('green', 'pwm', 'perimeter', (0, 1, 0)), 'blue_pwm_fullarray': ('blue', 'pwm', 'fullarray', (0, 0, 1)), 'blue_pwm_perimeter': ('blue', 'pwm', 'perimeter', (0, 0, 1)), 'ir850_pwm_fullarray': ('ir', 'pwm', 'fullarray', None), 'ir850_pwm_perimeter': ('ir', 'pwm', 'perimeter', None), 'ir850_analog_fullarray': ('ir', 'analog', 'fullarray', None), 'ir850_analog_perimeter': ('ir', 'analog', 'perimeter', None), 'visible_analog_535nm_diffuser': ('rgb', 'analog', '535nm_diffuser', (1, 1, 1)), 'visible_pwm_535nm_diffuser': ('rgb', 'pwm', '535nm_diffuser', (1, 1, 1)), 'visible_analog_535nm_focused': ('rgb', 'analog', '535nm_focused', (1, 0, 0)), 'visible_pwm_535nm_focused': ('rgb', 'pwm', '535nm_focused', (1, 0, 0)), } return possibilities.get(illumination_mode, None) def _read_led_positions(filename): import ro_json import xarray as xr with open(filename, encoding='utf-8') as f: # Not specifying ignore_comments seems to add A LOT # of time on a benchmark # Not sure if this is an artifact of the benchmarking process through # but it doesn't hurt since these are internal files # and we can control these more tightly. raw_positions = ro_json.load(f, ignore_comments=False) # Cannot multiply in place due to the change in types # float = int * float raw_positions = raw_positions * np.asarray([0.01, 0.000_01, 0.000_01]) return xr.DataArray( raw_positions, dims=['led_number', 'zyx'], coords={'led_number': np.arange(raw_positions.shape[0]), 'zyx': ['z', 'y', 'x']})