Source code for optika.sensors._sensors

"""
Models of light sensors that can be used in optical systems.
"""

from typing import TypeVar, Sequence
import abc
import dataclasses
import numpy as np
import astropy.units as u
import named_arrays as na
import optika
from .materials import AbstractSensorMaterial, IdealSensorMaterial

__all__ = [
    "AbstractImagingSensor",
    "ImagingSensor",
]


MaterialT = TypeVar("MaterialT", bound=AbstractSensorMaterial)


[docs] @dataclasses.dataclass(eq=False, repr=False) class AbstractImagingSensor( optika.surfaces.AbstractSurface[ None, MaterialT, optika.apertures.RectangularAperture, optika.apertures.RectangularAperture, None, ], ): """ An interface describing an imaging sensor that can be used as the last surface in an optical system. """ @property def sag(self) -> optika.sags.AbstractSag: return optika.sags.NoSag() @property def rulings(self) -> None: return None @property @abc.abstractmethod def width_pixel(self) -> u.Quantity | na.AbstractCartesian2dVectorArray: """ The physical size of each pixel on the sensor. """ @property @abc.abstractmethod def axis_pixel(self) -> na.Cartesian2dVectorArray[str, str]: """ The names of the logical axes corresponding to the rows and columns of the pixel grid. """ @property @abc.abstractmethod def num_pixel(self) -> na.Cartesian2dVectorArray[int, int]: """ The number of pixels along each axis of the sensor. """ @property @abc.abstractmethod def timedelta_exposure(self) -> u.Quantity | na.AbstractScalar: """ The exposure time of the sensor. """ @property def aperture(self): """ The light-sensitive aperture of the sensor. """ return optika.apertures.RectangularAperture( half_width=self.width_pixel * self.num_pixel / 2, )
[docs] def readout( self, rays: optika.rays.RayVectorArray, wavelength: na.AbstractScalar, timedelta: None | u.Quantity | na.AbstractScalar = None, axis: None | str | Sequence[str] = None, where: bool | na.AbstractScalar = True, noise: bool = True, ) -> na.FunctionArray[ na.SpectralPositionalVectorArray, na.AbstractScalar, ]: """ Given a set of rays incident on the sensor surface, where each ray represents an expected number of photons per unit time, simulate the number of electrons that would be measured by the sensor. Parameters ---------- rays A set of incident rays in local coordinates to measure. wavelength The edges of the wavelength bins to sample. timedelta The exposure time of the measurement. If :obj:`None` (the default), the value in :attr:`timedelta_exposure` will be used. axis The logical axes along which to collect photons. where A boolean mask used to indicate which photons should be considered when calculating the signal measured by the sensor. noise Whether to add noise to the result """ if timedelta is None: timedelta = self.timedelta_exposure where = where & rays.unvignetted sgn = np.sign(rays.intensity) rays = dataclasses.replace( rays, intensity=np.abs(rays.intensity) * timedelta, ) normal = self.sag.normal(rays.position) rays = self.material.signal( rays=rays, normal=normal, noise=noise, ) rays = dataclasses.replace( rays, intensity=sgn * rays.intensity, ) rays = self.material.charge_diffusion( rays=rays, normal=normal, ) bins = na.SpectralPositionalVectorArray( wavelength=wavelength, position=na.Cartesian2dVectorLinearSpace( start=self.aperture.bound_lower.xy, stop=self.aperture.bound_upper.xy, axis=self.axis_pixel, num=self.num_pixel + 1, ), ) return na.histogram( a=na.SpectralPositionalVectorArray( wavelength=rays.wavelength, position=rays.position.xy, ), bins=bins, axis=axis, weights=rays.intensity * where, )
[docs] @dataclasses.dataclass(eq=False, repr=False) class ImagingSensor( AbstractImagingSensor, ): """ An arbitrary imaging sensor described by a pixel grid and a light-sensitive material. """ name: None | str = None """The human-readable name of this sensor.""" width_pixel: u.Quantity | na.AbstractCartesian2dVectorArray = 0 * u.um """The physical size of each pixel on the sensor.""" axis_pixel: na.Cartesian2dVectorArray[str, str] = None """ The names of the logical axes corresponding to the rows and columns of the pixel grid. """ num_pixel: na.Cartesian2dVectorArray[int, int] = None """The number of pixels along each axis of the sensor.""" timedelta_exposure: u.Quantity | na.AbstractScalar = 0 * u.s """The exposure time of the sensor.""" material: AbstractSensorMaterial = None """ A model of the light-sensitive material composing this sensor. If :obj:`None` (the default), :class:`optika.sensors.IdealImagingSensor` will be used. """ aperture_mechanical: optika.apertures.RectangularAperture = None """The shape of the physical substrate supporting the sensor.""" is_field_stop: bool = False """A flag controlling whether this sensor is the field stop for the system.""" is_pupil_stop: bool = False """A flag controlling whether this sensor is the pupil stop for the system.""" transformation: None | na.transformations.AbstractTransformation = None """The position and orientation of the sensor in the global coordinate system.""" kwargs_plot: None | dict = None """Extra keyword arguments to pass to :meth:`plot`""" def __post_init__(self) -> None: if self.material is None: self.material = IdealSensorMaterial() @property def shape(self) -> dict[str, int]: return na.broadcast_shapes( optika.shape(self.name), optika.shape(self.width_pixel), optika.shape(self.num_pixel), optika.shape(self.timedelta_exposure), optika.shape(self.transformation), )