Source code for msfc_ccd._sensors
from typing import ClassVar, Literal
import abc
import dataclasses
import numpy as np
import astropy.units as u
import named_arrays as na
import optika
__all__ = [
"TeledyneCCD230",
]
[docs]
@dataclasses.dataclass(repr=False)
class AbstractSensor(
optika.mixins.Printable,
):
"""An interface for an imaging sensor or an ensemble of imaging sensors."""
num_tap_x: ClassVar[int] = 2
"""The number of taps along the long axis of the CCD sensor."""
num_tap_y: ClassVar[int] = 2
"""The number of taps along the short axis of the CCD sensor."""
@property
@abc.abstractmethod
def manufacturer(self) -> str:
"""The company which produced the sensor."""
@property
@abc.abstractmethod
def family(self) -> str:
"""The model number or product family of this sensor."""
@property
@abc.abstractmethod
def serial_number(self) -> None | str:
"""A unique number which identifies this sensor."""
@property
@abc.abstractmethod
def material(self) -> optika.sensors.materials.AbstractSiliconSensorMaterial:
"""The light-sensitive material used by this sensor."""
@property
@abc.abstractmethod
def num_pixel(self) -> na.Cartesian2dVectorArray[int, int]:
"""The number of pixels along the horizontal and vertical axes."""
@property
@abc.abstractmethod
def num_pixel_active(self):
"""The number of pixels that are used to detect light."""
@property
@abc.abstractmethod
def width_pixel(self) -> u.Quantity:
"""The physical size of a single pixel on the imaging sensor."""
@property
def width_active(self):
"""The physical size of the light sensitive area of the sensor."""
result = self.width_pixel * self.num_pixel_active
return result.to(u.mm)
@property
@abc.abstractmethod
def num_blank(self) -> int:
"""The number of blank columns at the start of each row."""
@property
@abc.abstractmethod
def num_overscan(self) -> int:
"""The number of overscan columns at the end of each row."""
@property
@abc.abstractmethod
def num_masked(self) -> int:
"""The number of masked rows at the start of each tap."""
@property
@abc.abstractmethod
def cte(self) -> u.Quantity:
"""The charge transfer efficiency of the sensor."""
@property
@abc.abstractmethod
def readout_noise(self) -> u.Quantity:
"""The standard deviation of the error on each pixel value."""
@property
def temperature(self):
"""The operating temperature of this sensor."""
return self.material.temperature
[docs]
def dark_current(
self,
temperature: None | u.Quantity | na.AbstractScalar = None,
):
"""
Calculate the rate of charge accumulation when the sensor is not illuminated.
Parameters
----------
temperature
The temperature of the sensor.
If :obj:`None`, the value of :attr:`temperature` is used.
"""
[docs]
@dataclasses.dataclass(repr=False)
class TeledyneCCD230(
AbstractSensor,
):
"""The standard sensor used by the MSFC cameras."""
manufacturer: str = "Teledyne/e2v"
"""The company which produced the sensor."""
family: str = "CCD230-42"
"""The model number or product family of this sensor."""
serial_number: None | str = None
"""A unique number which identifies this sensor."""
grade: None | str = None
"""
The quality of the device.
Grade 0 is the best possible and Grade 5 is the worst possible.
"""
material: None | optika.sensors.materials.AbstractSiliconSensorMaterial = None
"""
The light-sensitive material used by this sensor.
If :obj:`None`, :func:`optika.sensors.materials.e2v_ccd97` will be used.
"""
width_pixel: u.Quantity = 15 * u.um
"""The physical size of a single pixel on the imaging sensor."""
num_pixel_x: int = 2048
"""The number of pixels along the horizontal axis of the CCD sensor."""
num_pixel_y: int = 2064
"""The number of pixels along the vertical axis of the CCD sensor."""
num_blank: int = 50
"""The number of blank columns at the start of each row."""
num_overscan: int = 2
"""The number of overscan columns at the end of each row."""
num_masked: int = 8
"""
The number of masked rows at the start of each tap.
These rows are read out before the image, but they hold charge from the
part of the frame store closest to the image area rather than light from
the image itself.
They lie under the edge of the mask, so they have a dark current rate
about 80 times that of the image area, and they receive a fraction of
the light that leaks under the edge of the mask.
"""
cte: u.Quantity = 99.9995 * u.percent
"""The charge transfer efficiency of the sensor."""
readout_noise: u.Quantity = 4 * u.electron
"""The standard deviation of the error on each pixel value."""
readout_mode: Literal["full-frame", "transfer"] = "transfer"
"""
The frame readout mode of the sensor.
Either the entire sensor is read at the same time (``"full-frame"``),
or half of the sensor is used for storage (``"transfer"``).
"""
width_package_x: u.Quantity = 42 * u.mm
"""The horizontal size of the physical sensor package."""
width_package_y: u.Quantity = 61 * u.mm
"""The vertical size of the physical sensor package."""
def __post_init__(self):
if self.material is None:
self.material = optika.sensors.materials.e2v_ccd97()
@property
def num_pixel(self) -> na.Cartesian2dVectorArray:
return na.Cartesian2dVectorArray(
x=self.num_pixel_x,
y=self.num_pixel_y,
)
@property
def num_pixel_active(self):
"""
The number of pixels that are used to detect light.
If :attr:`readout_mode` is ``"full-frame"``, then this is the same
as :attr:`num_pixel`.
If :attr:`readout_mode` is ``"transfer"``, then the vertical component
of :attr:`num_pixel` is divided by 2 since half of the sensor is now used
for charge storage.
"""
result = self.num_pixel
if self.readout_mode == "transfer":
result = result.replace(y=result.y // 2)
return result
@property
def width_package(self) -> na.Cartesian2dVectorArray:
"""The vertical and horizontal width of the physical sensor package."""
return na.Cartesian2dVectorArray(
x=self.width_package_x,
y=self.width_package_y,
)
@classmethod
def _frac_Qd_Qdo(cls, temperature: u.Quantity | na.AbstractScalar):
T = temperature
return 1.14e6 * T * np.square(T) * np.exp(-9080 * u.K / T) / u.K**3
[docs]
def dark_current(
self,
temperature: None | u.Quantity | na.AbstractScalar = None,
):
r"""
Calculate the rate of charge accumulation when the sensor is not illuminated.
This is the typical dark current given by the datasheet of the
CCD230-42, version 5,
.. math::
\frac{Q_d}{Q_{do}} = 1.14 \times 10^6 \, T^3 e^{-9080 / T},
where :math:`Q_d` is the dark current at the temperature :math:`T`,
in kelvin, and :math:`Q_{do}` is the dark current at 293 K,
scaled so that the dark current at 248 K is the typical
0.2 electron / pixel / s.
The datasheet only claims this model from 230 K to 300 K,
and warns that below 230 K other sources of dark current,
which depend less steeply on temperature, may dominate.
The ESIS cameras run below that range, and their measured dark current
there is far above this model.
Parameters
----------
temperature
The temperature of the sensor.
If :obj:`None`, the value of :attr:`temperature` is used.
.. nblinkgallery::
:caption: Examples
:name: rst-link-gallery
../reports/dark-current
"""
if temperature is None:
temperature = self.temperature
Q_248K = 0.2 * u.electron / u.s
f = self._frac_Qd_Qdo(248 * u.K)
Q_do = Q_248K / f
return Q_do * self._frac_Qd_Qdo(temperature)