Source code for ledsa.analysis.Experiment

import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import List

import numpy as np


[docs] @dataclass class Layer: """ Represents a spatial layer with a bottom and top border. :ivar bottom_border: The z-coordinate of the bottom border of the layer :vartype bottom_border: float :ivar top_border: The z-coordinate of the top border of the layer :vartype top_border: float """ bottom_border: float top_border: float def __contains__(self, obj): """ Check if a given object's z-coordinate falls within this layer. :param obj: The object to check :type obj: object with attribute 'pos_z' :return: True if object is contained within the layer, False otherwise :rtype: bool """ if self.bottom_border <= obj.pos_z < self.top_border: return True return False
[docs] @dataclass class Layers: """ Represents a collection of spatial layers. :ivar amount: The number of layers :vartype amount: int :ivar bottom_border: The z-coordinate of the bottom-most border of the layers :vartype bottom_border: float :ivar top_border: The z-coordinate of the top-most border of the layers :vartype top_border: float """ amount: int bottom_border: float top_border: float layers: [Layer] = field(init=False) borders: np.ndarray = field(init=False) def __post_init__(self): self.layers = [] self.borders = np.linspace(self.bottom_border, self.top_border, self.amount + 1) for i in range(self.amount): self.layers.append(Layer(self.borders[i], self.borders[i + 1])) def __getitem__(self, layer): return self.layers[layer] def __str__(self): return f'num_of_layers: {self.amount}, bottom_border: {self.bottom_border}, top_border: {self.top_border}\n' def __repr__(self): return f'Layers(amount={self.amount}, bottom_border={self.bottom_border}, top_border={self.top_border})'
[docs] @dataclass class Camera: """ Contains a camera's position in 3D space. :ivar pos_x: The x-coordinate of the camera :vartype pos_x: float :ivar pos_y: The y-coordinate of the camera :vartype pos_y: float :ivar pos_z: The z-coordinate of the camera :vartype pos_z: float """ pos_x: float pos_y: float pos_z: float def __str__(self): return f'Camera: ({self.pos_x}, {self.pos_y}, {self.pos_z})\n' def __repr__(self): return f'Camera(pos_x={self.pos_x}, pos_y={self.pos_y}, pos_z={self.pos_z})'
[docs] @dataclass class LED: """ Represents a LED light source in 3D space. :ivar id: The identifier for the LED :vartype id: int :ivar pos_x: The x-coordinate of the LED :vartype pos_x: float :ivar pos_y: The y-coordinate of the LED :vartype pos_y: float :ivar pos_z: The z-coordinate of the LED :vartype pos_z: float """ id: int pos_x: float pos_y: float pos_z: float
[docs] class Experiment: """ Represents an experimental setup involving layers, an LED array, and a camera. :ivar layers: The spatial layers involved in the experiment. :vartype layers: Layers :ivar led_array: The identifier for the LED array. :vartype led_array: int :ivar camera: The camera involved in the experiment. :vartype camera: Camera :ivar leds: List of LED light sources. :vartype leds: List[LED] :ivar led_number: Number of LEDs on LED array. :vartype led_number: int :ivar path: File path for experiment data. :vartype path: Path :ivar channel: The camera channel to be analysed. :vartype channel: int :ivar merge_led_arrays: Whether to merge LED arrays. :vartype merge_led_arrays: bool """ def __init__(self, layers: Layers, led_array: int, camera: Camera, path=Path('.'), channel=0, merge_led_arrays=False): """ :param layers: The spatial layers involved in the experiment. :type layers: Layers :param led_array: The identifier for the LED array. :type led_array: int :param camera: The camera involved in the experiment. :type camera: Camera :param path: The file path for experiment data, defaults to current directory. :type path: Path, optional :param channel: The camera channel to be analysed, defaults to 0. :type channel: int, optional :param merge_led_arrays: Whether to merge LED arrays, defaults to False. :type merge_led_arrays: bool, optional """ self.num_leds = None self.layers = layers self.led_array = led_array self.camera = camera self.leds = [] self.path = path self.channel = channel self.merge_led_arrays = merge_led_arrays try: self.set_leds() except IOError as err: print(err) def __str__(self): out = f'channel: {self.channel}, led_array: {self.led_array}\n' + \ str(self.layers) + \ str(self.camera) return out
[docs] def calc_traversed_dist_per_layer(self, led: LED) -> np.ndarray: """ Calculate the distance traversed by light from an LED through each layer to the camera. :param led: The LED light source :type led: LED :return: Array of distances traversed in each layer :rtype: np.ndarray """ horizontal_dist = np.sqrt((self.camera.pos_x - led.pos_x) ** 2 + (self.camera.pos_y - led.pos_y) ** 2) alpha = np.arctan((led.pos_z - self.camera.pos_z) / horizontal_dist) if alpha == 0: distance_per_layer = self.calc_traversed_dist_in_plane(led) else: distance_per_layer = self.calc_traversed_dist_per_layer_with_nonzero_alpha(alpha, led) if not self.distance_calculation_is_consistent(distance_per_layer, led): distance_per_layer = None return distance_per_layer
[docs] def calc_traversed_dist_in_plane(self, led: LED) -> np.ndarray: """ Calculate the distance traversed by light from an LED in a plane to the camera. :param led: The LED light source :type led: LED :return: Array of distances traversed in each layer within the plane :rtype: np.ndarray """ distance_per_layer = np.zeros(self.layers.amount) horizontal_dist = np.sqrt((self.camera.pos_x - led.pos_x) ** 2 + (self.camera.pos_y - led.pos_y) ** 2) for layer in range(self.layers.amount): layer_bottom = self.layers.borders[layer] layer_top = self.layers.borders[layer + 1] if layer_bottom <= self.camera.pos_z < layer_top: distance_per_layer[layer] = horizontal_dist return distance_per_layer
[docs] def calc_traversed_dist_per_layer_with_nonzero_alpha(self, alpha: float, led: LED) -> np.ndarray: """ Calculate the distance traversed by light from an LED through each layer to the camera, taking into account a non-zero angle of incidence. :param alpha: The angle of incidence :type alpha: float :param led: The LED light source :type led: LED :return: Array of distances traversed in each layer considering the angle :rtype: np.ndarray """ distance_per_layer = np.zeros(self.layers.amount) for layer in range(self.layers.amount): layer_bottom = self.layers.borders[layer] layer_top = self.layers.borders[layer + 1] th = self.calc_traversed_height_in_layer(led.pos_z, layer_bottom, layer_top) distance_per_layer[layer] = np.abs(th / np.sin(alpha)) return distance_per_layer
[docs] def calc_traversed_height_in_layer(self, led_height: float, layer_bot: float, layer_top: float) -> float: """ Calculate the vertical distance (height) traversed by light from an LED within a layer. :param led_height: The z-coordinate of the LED :type led_height: float :param layer_bot: The z-coordinate of the bottom of the layer :type layer_bot: float :param layer_top: The z-coordinate of the top of the layer :type layer_top: float :return: The vertical distance traversed within the layer :rtype: float """ top_end = max(self.camera.pos_z, led_height) bot_end = min(self.camera.pos_z, led_height) bot = max(bot_end, layer_bot) top = min(top_end, layer_top) h = top - bot if h < 0: h = 0 return h
[docs] def distance_calculation_is_consistent(self, distance_per_layer: np.ndarray, led: LED, silent=True) -> bool: """ Check if the computed distance values for each layer are consistent with the Euclidean distance. :param distance_per_layer: Array of distances traversed in each layer :type distance_per_layer: np.ndarray :param led: The LED light source :type led: LED :param silent: If set to False, prints out debugging information, defaults to True :type silent: bool, optional :return: True if computations are consistent, False otherwise :rtype: bool """ if np.abs(np.sum(distance_per_layer) - np.sqrt((self.camera.pos_x - led.pos_x) ** 2 + (self.camera.pos_y - led.pos_y) ** 2 + (self.camera.pos_z - led.pos_z) ** 2)) > 1e-6: if not silent: print("error in distance computation, camera_x: {}, camera_y: {} camera_z: {}, led_x: {}, led_y: {}, " "led_z: {}".format( self.camera.pos_x, self.camera.pos_y, self.camera.pos_z, led.pos_x, led.pos_y, led.pos_z)) return False return True
[docs] def set_leds(self) -> None: """ Initialize the LED instances involved in the experiment based on loaded data. """ ids = self.get_led_ids() x, y, z = self.get_led_positions(ids) for i in range(len(ids)): self.leds.append(LED(ids[i], x[i], y[i], z[i])) self.num_leds = len(ids)
[docs] def get_led_ids(self) -> np.ndarray: """ Retrieve the IDs for the LEDs involved in the experiment. :return: Array of LED IDs :rtype: np.ndarray """ file_name_extension = '_merge' if self.merge_led_arrays != 'None' else '' file_name = f'led_array_indices_{self.led_array:03d}{file_name_extension}.csv' file_path = os.path.join(self.path, 'analysis', file_name) led_array_indices = np.loadtxt(file_path, dtype=int) return led_array_indices
[docs] def get_led_positions(self, ids: np.ndarray) -> List[np.ndarray]: """ Retrieve the 3D positions for a set of LEDs. :param ids: Array of LED identifiers :type ids: np.ndarray :return: List of arrays containing x, y, and z coordinates for each LED :rtype: List[np.ndarray] """ file_path = os.path.join(self.path, 'analysis', 'led_search_areas_with_coordinates.csv') search_areas_all = np.loadtxt(file_path, delimiter=',') search_areas_led_array = [] for led_id in ids: search_areas_led_array.append(search_areas_all[led_id]) search_areas_led_array = np.array(search_areas_led_array) return [search_areas_led_array[:, 3], search_areas_led_array[:, 4], search_areas_led_array[:, 5]]