Source code for wcc_etc.sensor

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"