313 lines
16 KiB
Python
313 lines
16 KiB
Python
from astropy import units as u
|
|
from .ASensor import ASensor
|
|
from ..IRadiant import IRadiant
|
|
from ..Entry import Entry
|
|
import numpy as np
|
|
from typing import Union
|
|
from ..psf.Airy import Airy
|
|
from ..psf.Zemax import Zemax
|
|
from ..SpectralQty import SpectralQty
|
|
from .PixelMask import PixelMask
|
|
import astropy.constants as const
|
|
from logging import info
|
|
|
|
|
|
class Imager(ASensor):
|
|
"""
|
|
A class for modelling a Image-sensor
|
|
"""
|
|
__encircled_energy: Union[str, float, u.Quantity]
|
|
|
|
@u.quantity_input(pixel_size="length", read_noise=u.electron ** 0.5 / u.pix, center_offset=u.pix,
|
|
dark_current=u.electron / u.pix / u.second, pixel_geometry=u.pix)
|
|
def __init__(self, parent: IRadiant, quantum_efficiency: Union[str, u.Quantity],
|
|
pixel_geometry: u.Quantity, pixel_size: u.Quantity, read_noise: u.Quantity, dark_current: u.Quantity,
|
|
f_number: Union[int, float], common_conf: Entry, center_offset: u.Quantity = np.array([0, 0]) << u.nm,
|
|
shape: str = None, contained_energy: Union[str, int, float] = "FWHM",
|
|
contained_pixels: u.Quantity = None):
|
|
"""
|
|
Initialize a new Image-sensor model.
|
|
Initialize a new Image-sensor model.
|
|
|
|
Parameters
|
|
----------
|
|
parent : IRadiant
|
|
The parent element of the optical component from which the electromagnetic radiation is received.
|
|
quantum_efficiency : Union[str, u.Quantity]
|
|
The quantum efficiency of the detector. This can be either the path to the file containing the values of
|
|
the spectral quantum efficiency or the overall quantum efficiency as int, float or astropy quantity.
|
|
pixel_geometry : u.Quantity
|
|
The geometry of the pixel array as Quantity in pixels with two entries:
|
|
[number of pixels in x-direction, number of pixels in y-direction]
|
|
pixel_size : length-Quantity
|
|
The edge length of a pixel (assumed to be square).
|
|
read_noise : Quantity
|
|
The RMS-read noise per detector pixel in electrons^0.5 / pixel.
|
|
dark_current : Quantity
|
|
The dark current of a detector pixel in electrons / (pixels * s).
|
|
f_number : Union[int, float]
|
|
The f-number of the optical system.
|
|
common_conf : Entry
|
|
The common-Entry of the configuration.
|
|
center_offset : u.Quantity
|
|
The offset of the PSF-center relative to the center of the detector array as length-quantity with two
|
|
entries: [offset in x-direction, offset in y-direction]
|
|
shape : str
|
|
The shape of the photometric aperture. Can be either square or circle
|
|
contained_energy : Union[str, int, float]
|
|
The energy contained within the photometric aperture.
|
|
contained_pixels : u.Quantity
|
|
The pixels contained within the photometric aperture.
|
|
"""
|
|
super().__init__(parent)
|
|
if type(quantum_efficiency == str):
|
|
self.__quantum_efficiency = SpectralQty.fromFile(quantum_efficiency, u.nm, u.electron / u.photon)
|
|
elif type(quantum_efficiency) == u.Quantity:
|
|
self.__quantum_efficiency = quantum_efficiency
|
|
self.__pixel_geometry = pixel_geometry
|
|
self.__array = np.zeros((int(pixel_geometry.value[0]), int(pixel_geometry.value[1])))
|
|
self.__pixel_size = pixel_size
|
|
self.__read_noise = read_noise
|
|
self.__dark_current = dark_current
|
|
self.__f_number = f_number
|
|
self.__center_offset = center_offset
|
|
self.__shape = shape
|
|
self.__contained_energy = contained_energy
|
|
if contained_pixels:
|
|
self.__contained_pixels = contained_pixels
|
|
self.__common_conf = common_conf
|
|
# Calculate central wavelength
|
|
self.__central_wl = self.__common_conf.wl_min() + (
|
|
self.__common_conf.wl_max() - self.__common_conf.wl_min()) / 2
|
|
# Parse PSF
|
|
if hasattr(common_conf, "psf") and common_conf.psf().lower() == "airy":
|
|
# Use an airy disk as PSF
|
|
self.__psf = Airy(self.__f_number, self.__central_wl, common_conf.d_aperture(), common_conf.psf.osf,
|
|
pixel_size)
|
|
else:
|
|
# Read PSF from Zemax file
|
|
self.__psf = Zemax(common_conf.psf(), self.__f_number, self.__central_wl, common_conf.d_aperture(),
|
|
common_conf.psf.osf, pixel_size)
|
|
|
|
@u.quantity_input(exp_time="time")
|
|
def getSNR(self, exp_time: u.Quantity):
|
|
"""
|
|
Calculate the signal to background ratio (SNR) for the given exposure time using the CCD-equation.
|
|
|
|
Parameters
|
|
----------
|
|
exp_time : time-Quantity
|
|
The exposure time to calculate the SNR for.
|
|
|
|
Returns
|
|
-------
|
|
snr : float
|
|
The calculated SNR
|
|
"""
|
|
|
|
snr = self.__calcSNR(*self.__exposePixels(), exp_time)
|
|
return snr.value
|
|
|
|
def getExpTime(self, snr: float) -> u.Quantity:
|
|
"""
|
|
Calculate the necessary exposure time in order to achieve the given SNR.
|
|
|
|
Parameters
|
|
----------
|
|
snr : float
|
|
The SNR for which the necessary exposure time shall be calculated.
|
|
|
|
Returns
|
|
-------
|
|
exp_time : Quantity
|
|
The necessary exposure time in seconds.
|
|
"""
|
|
# # Calculate the number of exposed pixels which will be used for calculating the noise
|
|
# n_pix_exposed = self.__calcExposedPixels()
|
|
# # Calculate the electron current from the target and the background
|
|
# signal_current, background_current = self.__calcElectronCurrent(n_pix_exposed)
|
|
# # Fix the physical units of the SNR
|
|
# snr = snr * u.electron**0.5
|
|
#
|
|
# # Calculate the ratio of the background- and dark-current to the signal current as auxiliary variable
|
|
# current_ratio = (background_current + n_pix_exposed * self.__dark_current) / signal_current
|
|
# # Calculate the necessary exposure time as inverse of the CCD-equation
|
|
# exp_time = snr ** 2 * (1 + current_ratio + np.sqrt(
|
|
# (1 + current_ratio) ** 2 + 4 * (self.__read_noise * n_pix_exposed) ** 2 / snr ** 2)) /\
|
|
# (2 * signal_current)
|
|
# return exp_time
|
|
|
|
@u.quantity_input(signal_current=u.electron / u.s, background_current=u.electron / u.s,
|
|
read_noise=u.electron ** 0.5, dark_current=u.electron / u.s, exp_time="time")
|
|
def __calcSNR(self, signal_current: u.Quantity, background_current: u.Quantity, read_noise: u.Quantity,
|
|
dark_current: u.Quantity, exp_time: u.Quantity) -> u.dimensionless_unscaled:
|
|
# Calculate the SNR using the CCD-equation
|
|
snr = signal_current.sum() * exp_time / np.sqrt(
|
|
exp_time * (signal_current.sum() + background_current.sum() + dark_current.sum()) + read_noise.sum() ** 2)
|
|
# Return the value of the SNR, ignoring the physical units (electrons^0.5)
|
|
return snr.value * u.dimensionless_unscaled
|
|
|
|
def __exposePixels(self) -> (u.electron / u.s, u.electron / u.s, u.electron, u.electron / u.s):
|
|
signal_current, size, obstruction, background_current = self.__calcPixelElectronCurrent()
|
|
mask = PixelMask(self.__pixel_geometry, self.__pixel_size, self.__center_offset)
|
|
if size.lower() == "extended":
|
|
d_photometric_ap = 0 * u.pix
|
|
mask.createPhotometricAperture("circle", d_photometric_ap / 2, np.array([0, 0]) << u.pix)
|
|
else:
|
|
if hasattr(self, "__contained_pixels"):
|
|
d_photometric_ap = np.sqrt(self.__contained_pixels.value) * u.pix
|
|
mask.createPhotometricAperture("square", d_photometric_ap / 2, np.array([0, 0]) << u.pix)
|
|
else:
|
|
d_photometric_ap = self.__calcPhotometricAperture(obstruction)
|
|
mask.createPhotometricAperture(self.__shape, d_photometric_ap / 2)
|
|
background = mask * background_current * u.pix
|
|
read_noise = mask * self.__read_noise * u.pix
|
|
dark_current = mask * self.__dark_current * u.pix
|
|
info("The radius of the photometric aperture is %.2f pixels." % (d_photometric_ap.value / 2))
|
|
info("The photometric aperture contains " + str(np.count_nonzero(mask)) + " pixels.")
|
|
if size.lower() != "extended":
|
|
mask = self.__psf.mapToPixelMask(mask,
|
|
getattr(getattr(self.__common_conf, "jitter_sigma", None), "val", None),
|
|
obstruction)
|
|
signal = mask * signal_current
|
|
return signal, background, read_noise, dark_current
|
|
|
|
def __calcPhotometricAperture(self, obstruction: float) -> u.Quantity:
|
|
# Calculate the reduced observation angle
|
|
# jitter_sigma = self.__common_conf.jitter_sigma() if hasattr(self.__common_conf, "jitter_sigma") else None
|
|
jitter_sigma = getattr(getattr(self.__common_conf, "jitter_sigma", None), "val", None)
|
|
reduced_observation_angle = self.__psf.calcReducedObservationAngle(self.__contained_energy, jitter_sigma,
|
|
obstruction)
|
|
|
|
# Calculate angular width of PSF
|
|
observation_angle = (reduced_observation_angle * self.__central_wl / self.__common_conf.d_aperture() *
|
|
180.0 / np.pi * 3600).decompose() * u.arcsec
|
|
# Calculate FOV of a single pixel
|
|
pixel_fov = (self.__pixel_size / (self.__f_number * self.__common_conf.d_aperture()) * 180.0 /
|
|
np.pi * 3600).decompose() * u.arcsec
|
|
# Calculate the radius of the photometric aperture in pixels
|
|
d_photometric_ap = observation_angle / pixel_fov
|
|
return d_photometric_ap * u.pix
|
|
|
|
def __calcPixelElectronCurrent(self) -> (u.electron / u.s, str, float, u.electron / (u.pix * u.s)):
|
|
"""
|
|
Calculate the detected electron current of the signal and the background.
|
|
|
|
Returns
|
|
-------
|
|
signal_current : Quantity
|
|
The electron current on the detector caused by the target in electrons / s.
|
|
size : str
|
|
The size of the target.
|
|
obstruction : float
|
|
The obstruction factor.
|
|
background_current : Quantity
|
|
The electron current on the detector caused by the background in electrons / (s * pix).
|
|
"""
|
|
# Calculate the photon current of the background
|
|
background_photon_current = self._parent.calcBackground() * np.pi * (
|
|
self.__pixel_size.to(u.m) ** 2 / u.pix) / (4 * self.__f_number ** 2 + 1) * (1 * u.sr)
|
|
# Calculate the incoming photon current of the target
|
|
signal, size, obstruction = self._parent.calcSignal()
|
|
signal_photon_current = signal * np.pi * self.__common_conf.d_aperture() ** 2
|
|
# Calculate the electron current of the background and thereby handling the photon energy as lambda-function
|
|
background_current = (
|
|
background_photon_current / (lambda wl: (const.h * const.c / wl).to(u.W * u.s) / u.photon) *
|
|
self.__quantum_efficiency).integrate()
|
|
# Calculate the electron current of the signal and thereby handling the photon energy as lambda-function
|
|
signal_current = (signal_photon_current / (lambda wl: (const.h * const.c / wl).to(u.W * u.s) / u.photon) *
|
|
self.__quantum_efficiency).integrate()
|
|
return signal_current, size, obstruction, background_current
|
|
|
|
@staticmethod
|
|
def check_config(sensor: Entry, conf: Entry) -> Union[None, str]:
|
|
"""
|
|
Check the configuration for this class
|
|
|
|
Parameters
|
|
----------
|
|
sensor : Entry
|
|
The configuration entry to be checked.
|
|
conf: Entry
|
|
The complete configuration.
|
|
|
|
Returns
|
|
-------
|
|
mes : Union[None, str]
|
|
The error message of the check. This will be None if the check was successful.
|
|
"""
|
|
if not hasattr(sensor, "f_number"):
|
|
return "Missing container 'f_number'."
|
|
mes = sensor.f_number.check_float("val")
|
|
if mes is not None:
|
|
return "f_number: " + mes
|
|
if not hasattr(sensor, "pixel_geometry"):
|
|
return "Missing container 'pixel_geometry'."
|
|
mes = sensor.pixel_geometry.check_quantity("val", u.pix)
|
|
if mes is not None:
|
|
return "pixel_geometry: " + mes
|
|
if hasattr(sensor, "center_offset") and isinstance(sensor.center_offset, Entry):
|
|
mes = sensor.center_offset.check_quantity("val", u.pix)
|
|
if mes is not None:
|
|
return "center_offset: " + mes
|
|
|
|
# Check pixel
|
|
if not hasattr(sensor, "pixel"):
|
|
return "Missing container 'pixel'."
|
|
if not hasattr(sensor.pixel, "quantum_efficiency"):
|
|
return "Missing container 'quantum_efficiency'."
|
|
mes = sensor.pixel.quantum_efficiency.check_float("val")
|
|
if mes is not None:
|
|
mes = sensor.pixel.quantum_efficiency.check_file("val")
|
|
if mes is not None:
|
|
return "pixel -> quantum_efficiency: " + mes
|
|
if not hasattr(conf.pixel, "pixel_size"):
|
|
return "Missing container 'pixel_size'."
|
|
mes = sensor.pixel.pixel_size.check_quantity("val", u.m)
|
|
if mes is not None:
|
|
return "pixel -> pixel_size: " + mes
|
|
if not hasattr(sensor.pixel, "dark_current"):
|
|
return "Missing container 'dark_current'."
|
|
mes = sensor.pixel.dark_current.check_quantity("val", u.electron / (u.pix * u.s))
|
|
if mes is not None:
|
|
return "pixel -> dark_current: " + mes
|
|
if not hasattr(sensor.pixel, "sigma_read_out"):
|
|
return "Missing container 'sigma_read_out'."
|
|
mes = sensor.pixel.sigma_read_out.check_quantity("val", u.electron ** 0.5 / u.pix)
|
|
if mes is not None:
|
|
return "pixel -> sigma_read_out: " + mes
|
|
if not hasattr(sensor.pixel, "well_capacity"):
|
|
return "Missing container 'well_capacity'."
|
|
mes = sensor.pixel.well_capacity.check_quantity("val", u.electron)
|
|
if mes is not None:
|
|
return "pixel -> well_capacity: " + mes
|
|
|
|
# Check photometric aperture
|
|
if conf.astroscene.target.size == "point":
|
|
if not hasattr(sensor, "photometric_aperture"):
|
|
return "Missing container 'photometric_aperture'."
|
|
if hasattr(sensor.photometric_aperture, "contained_pixels"):
|
|
mes = sensor.photometric_aperture.contained_pixels.check_quantity("val", u.pix)
|
|
if mes is not None:
|
|
return "photometric_aperture -> contained_pixels: " + mes
|
|
else:
|
|
if not hasattr(sensor.photometric_aperture, "shape"):
|
|
return "Missing container 'shape'."
|
|
mes = sensor.photometric_aperture.shape.check_selection("val", ["square", "circle"])
|
|
if mes is not None:
|
|
return "photometric_aperture -> shape: " + mes
|
|
if not hasattr(sensor.photometric_aperture, "contained_energy"):
|
|
return "Missing container 'contained_energy'."
|
|
mes = sensor.photometric_aperture.contained_energy.check_float("val")
|
|
if mes is not None:
|
|
if conf.common.psf().lower() == "airy":
|
|
mes = sensor.photometric_aperture.contained_energy.check_selection("val",
|
|
["peak", "FWHM", "fwhm",
|
|
"min"])
|
|
if mes is not None:
|
|
return "photometric_aperture -> contained_energy: " + mes
|
|
else:
|
|
mes = sensor.photometric_aperture.contained_energy.check_selection("val", ["FWHM", "fwhm"])
|
|
if mes is not None:
|
|
return "photometric_aperture -> contained_energy: " + mes
|