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 pivot wavelength of the bandpass (filter-only; the PSF is rendered at
the source-weighted Simulation.effective_wavelength instead).
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
Must be 1: the QE is part of the bandpass (throughput) file and is
never applied separately. 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 {}.
"""
if qe != 1:
raise ValueError(
f"qe={qe!r} is not applied: QE is part of the sensor throughput "
"file; pass it there (qe must be 1)."
)
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, gain_mode=None):
"""
Create a Sensor instance from a name string 'kind:band'.
Parameters
----------
name : str
Sensor specification, e.g., 'zwo:r'.
gain_mode : str, optional
Gain mode for detectors that have several (qCMOS: 'high' or 'low').
Returns
-------
Sensor
"""
sensor, band = name.split(":")
return cls.from_kind_and_band(sensor, band, gain_mode=gain_mode)
[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)
# Detectors with selectable gain (qCMOS 32x / 1x) carry one table per
# mode; fold the chosen one into the flat config (#94).
gain_mode = config.get("gain_mode")
if gain_mode is not None:
modes = config.get("gain_modes", {})
if gain_mode not in modes:
raise ValueError(
f"Unknown gain_mode {gain_mode!r}; known: {sorted(modes)}"
)
config |= modes[gain_mode]
# 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:
if sensor_temp is None:
raise ValueError(
"sensor_temp is required for sensors with a dark-current table"
)
gain = parse_and_interpolate(config.get("path_gain_curve"), gain_setting)
read_noise = parse_and_interpolate(
config.get("path_read_noise"), gain_setting
)
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")
# Explicit margin on the datasheet read noise for noise sources the
# model does not carry (RTS, non-linearity). 1.0 means the datasheet
# value; the bundled configs use 2.0 (#97).
if read_noise is not None:
read_noise = read_noise * config.get("read_noise_margin", 1.0)
# 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, gain_mode=None):
"""
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').
gain_mode : str, optional
Gain mode for detectors that have several (qCMOS: 'high' or 'low').
Returns
-------
Sensor
"""
from .io import get_sensor_config
# grabs the configuration associated to this sensor
config = get_sensor_config(kind, band, gain_mode=gain_mode)
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
[docs]
def show(self, ax=None, label=None, **kwargs):
"""
Plot the sensor's filter throughput curve.
Parameters
----------
ax : matplotlib.axes.Axes, optional
Axes to plot on. If None, a new figure is created.
label : str, optional
Legend label, passed to ax.plot.
**kwargs
Keyword arguments passed to ax.plot.
Returns
-------
(fig, ax)
The figure and the axis drawn on.
"""
from .plotting import plot_bandpass_mpl
fig, ax, _curve = plot_bandpass_mpl(self.bandpass, ax=ax, label=label, **kwargs)
return fig, ax
# ================ #
# Properties #
# ================ #
plot = show
@property
def bandpass(self):
"""
The sensor bandpass (SpectralElement).
"""
return self._bandpass
@property
def wavelength(self):
"""
The pivot wavelength of the bandpass -- a property of the filter alone.
The pivot wavelength is preferred over 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, while the pivot wavelength is the photometrically meaningful
effective wavelength of the bandpass and is independent of where the
throughput happens to peak.
It is, however, computed from the throughput curve and *nothing else*,
so it is the same for every source: 582 nm through the WCC broad band
whether the star is an O5V or an M5V, even though the photons those two
actually deliver through that band average 541 nm and 716 nm. Since the
diffraction scale is linear in wavelength, the PSF is rendered at
:attr:`wcc_etc.Simulation.effective_wavelength` -- the source-weighted
wavelength -- and not at this pivot (issue #65). Use this for
filter-level bookkeeping, not for anything that has to know the source
colour.
See Also
--------
wcc_etc.spectral.effective_wavelength : the source-weighted counterpart.
"""
# 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
@property
def saturation_e(self):
"""
The per-frame saturation ceiling in electrons: the smaller of the full
well (meta['well_depth']) and the charge at which the ADC clips,
(adc_max - bias_level) * gain. Bias consumes ADC headroom; it does not
raise the well. Every saturation API compares electrons to this (#64).
"""
adc_e = ((self.adc_max - self.bias_level) * self.gain).to(u.electron).value
well_e = self.meta.get("well_depth")
return min(adc_e, well_e if well_e is not None else float("inf")) * u.electron
@property
def saturation_limit(self):
"""
Which limit binds in saturation_e: 'well' (full well) or 'adc' (ADC clip).
"""
adc_e = ((self.adc_max - self.bias_level) * self.gain).to(u.electron).value
well_e = self.meta.get("well_depth")
return "well" if well_e is not None and well_e <= adc_e else "adc"