from copy import deepcopy
from astropy import units as u
from .meta import _MetaHolder_
from .utils import parse_and_interpolate, parse_element
[docs]
class Sensor(_MetaHolder_):
"""
A class representing the sensor (detector) properties.
Attributes
----------
bandpass : SpectralElement
The total throughput (filter + sensor QE + telescope optics).
wavelength : Quantity
The effective (pivot) wavelength of the bandpass, used to render the PSF.
area : Quantity
The total area of the sensor.
gain : Quantity
The sensor gain (e-/ADU).
dark_current : Quantity
The dark current (e-/s/pix).
read_noise : Quantity
The read noise (e-/pix).
pixel_size : Quantity
The pixel size (microns/pix).
bit_depth : int or None
The ADC bit depth. None if not configured.
bias_level : Quantity
The additive bias/offset level in ADU.
adc_max : Quantity
The ADC full-scale clip ceiling in ADU.
"""
_mutable_parameters = [
"bandpass",
"bandpass_name",
"pixel_size",
"read_noise",
"dark_current",
"gain",
"area",
"temperature",
"bit_depth",
"bias_level",
]
def __init__(
self,
bandpass,
pixel_size,
read_noise,
dark_current,
gain,
area,
temperature=None,
qe=1, # part of the total throughput for now.
well_depth=None,
bit_depth=None,
bias_level=None,
meta={},
):
"""
Initialize the sensor.
Parameters
----------
bandpass : SpectralElement or str
The bandpass element or its specification.
pixel_size : float or Quantity
The pixel size (microns if float).
read_noise : float or Quantity
The read noise (e- if float).
dark_current : float or Quantity
The dark current (e-/s if float).
gain : float or Quantity
The gain (e-/adu if float).
area : float or Quantity
The sensor area (mm^2 if float).
temperature : float or Quantity, optional
The sensor temperature. Default is None.
qe : float, optional
Quantum Efficiency (usually included in bandpass). Default is 1.
well_depth : float or Quantity, optional
The full well depth. Default is None.
bit_depth : int, optional
The ADC bit depth. Used to compute adc_max = 2**bit_depth - 1. Default is None.
bias_level : float, optional
The additive bias/offset level in ADU. Default is None (treated as 0).
meta : dict, optional
Additional metadata. Default is {}.
"""
init_parameters = {
key: value
for key, value in locals().items()
if key not in ["self", "bandpass", "meta"]
and value is not None
and not key.startswith("__")
}
# overwrite meta with manually given ones.
self.set_bandpass(bandpass)
super().__init__(meta | init_parameters)
[docs]
@classmethod
def from_name(cls, name):
"""
Create a Sensor instance from a name string 'kind:band'.
Parameters
----------
name : str
Sensor specification, e.g., 'zwo:r'.
Returns
-------
Sensor
"""
sensor, band = name.split(":")
return cls.from_kind_and_band(sensor, band)
[docs]
@classmethod
def from_config(cls, config_or_name):
"""
Create a Sensor instance from a configuration or name.
Parameters
----------
config_or_name : dict or str
Configuration dictionary or name string.
Returns
-------
Sensor
"""
if isinstance(config_or_name, str):
return cls.from_name(config_or_name)
config = deepcopy(config_or_name)
# read the total throughput allowing 2 formating
throughput = config.get("throughput", config.get("path_total_throughput"))
bandpass = parse_element(throughput)
# basic sensor information:
pixel_size = config.get("pixel_size")
sensor_area = config.get("sensor_area") * u.mm**2
# optional
sensor_temp = config.get("sensor_temp", None)
if sensor_temp is not None:
sensor_temp *= u.Celsius
# gain
gain_setting = config.get("gain_setting", None)
if gain_setting is not None:
gain = parse_and_interpolate(config.get("path_gain_curve"), gain_setting)
read_noise = (
parse_and_interpolate(config.get("path_read_noise"), gain_setting) * 2
) # multiply by 2 to allow for unmodelled noise sources
dark_current = parse_and_interpolate(
config.get("path_dark_current"), sensor_temp.to("Celsius").value
) # careful temperature here.
well_depth = parse_and_interpolate(
config.get("path_well_depth"), gain_setting
)
else: # no default allowed here, must be provided.
gain = config.get("gain")
read_noise = config.get("read_noise")
dark_current = config.get("dark_current")
well_depth = config.get("well_depth")
# ADC properties
bit_depth = config.get("bit_depth")
bias_level = config.get("bias_level")
return cls(
bandpass=bandpass,
pixel_size=pixel_size,
read_noise=read_noise,
dark_current=dark_current,
gain=gain,
area=sensor_area,
temperature=sensor_temp,
well_depth=well_depth,
bit_depth=bit_depth,
bias_level=bias_level,
qe=1, # forced qe=1 as included in total throughput
meta=config,
)
[docs]
@classmethod
def from_kind_and_band(cls, kind, band):
"""
Load the instance given the detector kind and filter.
Parameters
----------
kind : str
Kind of sensor of the WCC:
- 'zwo' ('sony', 'imx', 'imx455' accepted)
- 'qcmos'
band : str
Name of the band associated to the sensor (e.g., 'bb', 'u', 'r', 'z').
Returns
-------
Sensor
"""
from .io import get_sensor_config
# grabs the configuration associated to this sensor
config = get_sensor_config(kind, band)
return cls.from_config(config["sensor"])
# ================ #
# Methods #
# ================ #
[docs]
def set_bandpass(self, bandpass):
"""
Set the sensor bandpass.
Parameters
----------
bandpass : str or SpectralElement
"""
self._bandpass = parse_element(bandpass)
[docs]
def get_plate_scale(self, telescope):
"""
Calculate the plate scale in arcsec/pix.
Parameters
----------
telescope : Telescope
The telescope object.
Returns
-------
Quantity
The plate scale in arcsec/pix.
"""
# why 206265
return (
self.pixel_size.to("m/pix")
/ telescope.diameter_primary.to("m")
/ telescope.f_num
* 206265
* u.arcsec
) # arcsec/pix
# ================ #
# Properties #
# ================ #
@property
def bandpass(self):
"""
The sensor bandpass (SpectralElement).
"""
return self._bandpass
@property
def wavelength(self):
"""
The effective (pivot) wavelength of the bandpass.
This is the representative wavelength used to render the monochromatic
diffraction PSF. We use the pivot wavelength rather than the
peak-transmission wavelength (``wpeak``): ``wpeak`` returns the
wavelength of maximum throughput, which for a roughly flat-topped filter
is essentially arbitrary within the band and biases the PSF size (the
Airy scale is linear in wavelength). The pivot wavelength is the
photometrically meaningful effective wavelength of the bandpass and is
independent of where the throughput happens to peak.
"""
# store in memory as a bit slow
if not hasattr(self, "_wavelength") or self._wavelength is None:
self._wavelength = self.bandpass.pivot().to(u.nm)
return self._wavelength
@property
def area(self):
"""
The sensor area.
"""
return self.meta["area"]
@property
def gain(self):
"""
The sensor gain (e-/ct).
"""
return self.meta["gain"] * (u.electron / u.ct)
@property
def dark_current(self):
"""
The dark current (e-/s/pix).
"""
return self.meta["dark_current"] * (u.electron / (u.s * u.pix))
@property
def read_noise(self):
"""
The read noise (e-/pix).
"""
return self.meta["read_noise"] * u.electron / u.pix
@property
def pixel_size(self):
"""
The pixel size (um/pix).
"""
return self.meta["pixel_size"] * u.um / u.pix
@property
def bit_depth(self):
"""
The ADC bit depth (int), or None if not configured.
"""
return self.meta.get("bit_depth")
@property
def bias_level(self):
"""
The additive bias/offset level in ADU (u.ct). Defaults to 0.
"""
return self.meta.get("bias_level", 0) * u.ct
@property
def adc_max(self):
"""
The ADC full-scale (clip ceiling) in ADU (u.ct): 2**bit_depth - 1.
"""
bit_depth = self.bit_depth
if bit_depth is None:
raise ValueError("bit_depth is not set; cannot compute adc_max")
return (2**bit_depth - 1) * u.ct