from .ASensor import ASensor
from ..IRadiant import IRadiant
from ..Entry import Entry
from ...lib.logger import logger
from ..SpectralQty import SpectralQty
import numpy as np
from astropy import units as u
from astropy.constants import k_B
from astropy.table import QTable
from typing import Union
import os
[docs]class Heterodyne(ASensor):
"""
A class for modelling the behaviour of a superheterodyne spectrometer.
"""
[docs] def __init__(self, parent: IRadiant, aperture_efficiency: float, main_beam_efficiency: float,
receiver_temp: u.Quantity, eta_fss: float, lambda_line: u.Quantity, kappa: float, common_conf: Entry,
n_on: float = None, lambda_local_oscillator: u.Quantity = None):
"""
Initialize a new heterodyne detector
Parameters
----------
parent : IRadiant
The parent element of the optical component from which the electromagnetic radiation is received.
aperture_efficiency : float
The aperture efficiency of the antenna.
main_beam_efficiency : float
The main beam efficiency of the telescope.
receiver_temp : u.Quantity in Kelvins
The intrinsic noise temperature of all receiver components.
eta_fss : float
The forward scattering efficiency of the antenna.
lambda_line : u.Quantity
The wavelength to be used for calculating the SNR.
kappa : float
The backend degradation factor.
common_conf : Entry
The common-Entry of the configuration.
n_on : float
The number of on source observations.
"""
self.__aperture_efficiency = aperture_efficiency
self.__main_beam_efficiency = main_beam_efficiency
self.__receiver_temp = receiver_temp
self.__eta_fss = eta_fss
self.__lambda_line = lambda_line
self.__lambda_local_oscillator = lambda_local_oscillator
self.__kappa = kappa
self.__common_conf = common_conf
self.__n_on = n_on
super().__init__(parent)
[docs] @u.quantity_input(exp_time="time")
def calcSNR(self, background: SpectralQty, signal: SpectralQty, obstruction: float,
exp_time: u.Quantity) -> u.dimensionless_unscaled:
"""
Calculate the signal to background ratio (SNR) for the given exposure time using the CCD-equation.
Parameters
----------
background : SpectralQty
The received background radiation
signal : SpectralQty
The received signal radiation
obstruction : float
The obstruction factor of the aperture as ratio A_ob / A_ap
exp_time : time-Quantity
The exposure time to calculate the SNR for.
Returns
-------
snr : Quantity
The calculated SNR as dimensionless quantity
"""
# Calculate the signal and background temperatures
t_signal, t_background = self.calcTemperatures(background, signal, obstruction)
line_ind = np.where(t_signal.wl == self.__lambda_line)[0][0]
t_sys = t_background + 2 * self.__receiver_temp + t_signal
# Calculate the noise bandwidth
delta_nu = t_signal.wl.to(u.Hz, equivalencies=u.spectral()) / (t_signal.wl / self.__common_conf.wl_delta() + 1)
snr = []
for exp_time_ in exp_time if exp_time.size > 1 else [exp_time]:
# Calculate the RMS background temperature
if self.__n_on is None:
t_rms = 2 * t_sys * self.__kappa / np.sqrt(exp_time_ * delta_nu)
else:
t_rms = t_sys * self.__kappa * np.sqrt(1 + 1 / np.sqrt(self.__n_on)) / np.sqrt(exp_time_ * delta_nu)
# Calculate the SNR
snr_ = t_signal / t_rms
snr.append(snr_.qty[line_ind])
# Print details
self.__printDetails(t_sys.qty[line_ind], delta_nu[line_ind], t_rms.qty[line_ind], t_signal.qty[line_ind],
"t_exp=%.2f s: " % exp_time_.value)
self.__output(t_signal, t_background, t_rms, "texp_%.2f" % exp_time_.value, snr=snr_)
return u.Quantity(snr) if len(snr) > 1 else u.Quantity(snr[0])
[docs] @u.quantity_input(snr=u.dimensionless_unscaled)
def calcExpTime(self, background: SpectralQty, signal: SpectralQty, obstruction: float, snr: u.Quantity) -> u.s:
"""
Calculate the necessary exposure time in order to achieve the given SNR.
Parameters
----------
background : SpectralQty
The received background radiation
signal : SpectralQty
The received signal radiation
obstruction : float
The obstruction factor of the aperture as ratio A_ob / A_ap
snr : Quantity
The SNR for which the necessary exposure time shall be calculated as dimensionless quantity.
Returns
-------
exp_time : Quantity
The necessary exposure time in seconds.
"""
# Calculate the signal and background temperatures
t_signal, t_background = self.calcTemperatures(background, signal, obstruction)
line_ind = np.where(t_signal.wl == self.__lambda_line)[0][0]
t_sys = t_background + 2 * self.__receiver_temp + t_signal
# Calculate the noise bandwidth
delta_nu = t_signal.wl.to(u.Hz, equivalencies=u.spectral()) / (t_signal.wl / self.__common_conf.wl_delta() + 1)
exp_time = []
for snr_ in snr if snr.size > 1 else [snr]:
# Calculate the RMS background temperature
t_rms = t_signal / snr_
# Calculate the exposure time
if self.__n_on is None:
exp_time_ = ((2 * t_sys * self.__kappa / t_rms) ** 2 / delta_nu)
else:
exp_time_ = ((t_sys * self.__kappa / t_rms) ** 2 *
(1 + 1 / np.sqrt(self.__n_on)) / delta_nu)
exp_time_ = SpectralQty(exp_time_.wl, exp_time_.qty.decompose())
exp_time.append(exp_time_.qty[line_ind])
# Print details
self.__printDetails(t_sys.qty[line_ind], delta_nu[line_ind], t_rms.qty[line_ind], t_signal.qty[line_ind],
"SNR=%.2f: " % snr_.value)
self.__output(t_signal, t_background, t_rms, "snr_%.2f" % snr_.value, exp_time=exp_time_)
return u.Quantity(exp_time) if len(exp_time) > 1 else u.Quantity(exp_time[0])
# @u.quantity_input(exp_time="time", snr=u.dimensionless_unscaled,
# target_brightness=[u.mag, u.mag / u.sr])
[docs] def calcSensitivity(self, background: SpectralQty, signal: SpectralQty, obstruction: float, exp_time: u.Quantity,
snr: u.Quantity, target_brightness: u.Quantity) -> [u.mag, u.mag / u.sr]:
"""
Calculate the sensitivity of the telescope detector combination.
Parameters
----------
background : SpectralQty
The received background radiation
signal : SpectralQty
The received signal radiation
obstruction : float
The obstruction factor of the aperture as ratio A_ob / A_ap
exp_time : Quantity
The exposure time in seconds.
snr : Quantity
The SNR for which the sensitivity time shall be calculated.
target_brightness : Quantity
The target brightness in mag or mag / sr.
Returns
-------
sensitivity: Quantity
The sensitivity as limiting apparent star magnitude in mag.
"""
# Calculate the signal and background temperatures
t_signal, t_background = self.calcTemperatures(background, signal, obstruction)
line_ind = np.where(t_signal.wl == self.__lambda_line)[0][0]
t_sys = t_background + 2 * self.__receiver_temp + t_signal
# Calculate the noise bandwidth
delta_nu = t_signal.wl.to(u.Hz, equivalencies=u.spectral()) / (t_signal.wl / self.__common_conf.wl_delta() + 1)
sensitivity = []
for snr_, exp_time_ in zip(snr, exp_time) if snr.size > 1 else zip([snr], [exp_time]):
# Calculate the RMS background temperature
if self.__n_on is None:
t_rms = 2 * t_sys * self.__kappa / np.sqrt(exp_time_ * delta_nu)
else:
t_rms = t_sys * self.__kappa * np.sqrt(1 + 1 / np.sqrt(self.__n_on)) / np.sqrt(exp_time_ * delta_nu)
# Calculate the limiting signal temperature
t_signal_lim = t_rms * snr_
# Calculate the sensitivity
signal_ratio = t_signal_lim / t_signal
sensitivity_ = SpectralQty(signal_ratio.wl,
target_brightness - 2.5 * np.log10(signal_ratio.qty) * target_brightness.unit)
sensitivity.append(sensitivity_.qty[line_ind])
# Print details
self.__printDetails(t_sys.qty[line_ind], delta_nu[line_ind], t_rms.qty[line_ind],
t_signal_lim.qty[line_ind], "SNR=%.2f t_exp=%.2f s: " % (snr_.value, exp_time_.value))
self.__output(t_signal, t_background, t_rms, "snr_%.2f_texp_%.2f" % (snr_.value, exp_time_.value),
sensitivity=sensitivity_)
return u.Quantity(sensitivity) if len(sensitivity) > 1 else u.Quantity(sensitivity[0])
@u.quantity_input(t_sys=u.K, delta_nu=u.Hz, t_rms=u.K, t_signal=u.K)
def __printDetails(self, t_sys: u.Quantity, delta_nu: u.Quantity, t_rms: u.Quantity,
t_signal: u.Quantity, prefix: str = ""):
"""
Print details on the signal and noise composition.
Parameters
----------
t_sys : Quantity
The system temperature.
delta_nu : Quantity
The noise bandwidth.
t_rms : Quantity
The RMS antenna temperature.
t_signal : Quantity
The antenna temperature.
prefix : str
The prefix to be used for printing.
Returns
-------
"""
logger.info("--------------------------------------------------------------------------")
logger.info(prefix + "System temperature: %1.2e K" % t_sys.value)
logger.info(prefix + "Noise bandwidth: %1.2e Hz" % delta_nu.value)
logger.info(prefix + "RMS antenna temperature: %1.2e K" % t_rms.value)
logger.info(prefix + "Antenna temperature: %1.2e K" % t_signal.value)
logger.info("--------------------------------------------------------------------------")
@u.quantity_input(signal=u.electron, background=u.electron, read_noise=u.electron ** 0.5, dark=u.electron)
def __output(self, t_signal: SpectralQty, t_background: SpectralQty, t_rms: SpectralQty,
name: str, snr: SpectralQty = None, exp_time: SpectralQty = None, sensitivity: SpectralQty = None):
"""
Write the signal and the noise in electrons to files.
Parameters
----------
t_signal : SpectralQty
The signal temperature in Kelvins.
t_background : SpectralQty
The background temperature in Kelvins.
t_rms : SpectralQty
The RMS noise temperature in Kelvins.
name : str
The name of the configuration.
snr : SpectralQty
The calculated signal-to-noise ratio per wavelength.
exp_time : SpectralQty
The calculated exposure time per wavelength.
sensitivity : SpectralQty
The calculated sensitivity per wavelength.
Returns
-------
"""
# Concatenate the paths
path = os.path.join(self.__common_conf.output.path, name)
try:
os.makedirs(path, exist_ok=True)
except FileExistsError:
logger.warning("Output directory '" + path + "' already exists.")
res = QTable([t_signal.wl, t_signal.qty, t_background.qty, t_rms.qty],
names=('Wavelength [' + t_signal.wl.unit.to_string() + ']',
'Signal Temperature [' + t_signal.qty.unit.to_string() + ']',
'Background Temperature [' + t_background.qty.unit.to_string() + ']',
'RMS Noise Temperature [' + t_rms.qty.unit.to_string() + ']'),
meta={'name': 'first table'})
if snr is not None:
res['SNR [-]'] = snr.qty
if exp_time is not None:
res['Exposure Time [' + exp_time.qty.unit.to_string() + ']'] = exp_time.qty
if sensitivity is not None:
res['Sensitivity [' + sensitivity.qty.unit.to_string() + ']'] = sensitivity.qty
res.write(os.path.join(path, "result.csv"), format='ascii.csv', overwrite=True)
[docs] def calcTemperatures(self, background: SpectralQty, signal: SpectralQty, obstruction: float):
"""
Calculate the noise temperatures of the signal and the background radiation.
Parameters
----------
background : SpectralQty
The received background radiation
signal : SpectralQty
The received signal radiation
obstruction : float
The obstruction factor of the aperture as ratio A_ob / A_ap
Returns
-------
t_signal : SpectralQty
The spectral signal temperature in Kelvins.
t_background : SpectralQty
The spectral signal temperature in Kelvins.
"""
logger.info("Calculating the system temperature.")
# Add desired wavelength to wavelength bins
wl_bins = np.sort(np.append(self.__common_conf.wl_bins(), self.__lambda_line)).view(u.Quantity)
signal = signal.rebin(wl_bins)
background = background.rebin(wl_bins)
background = SpectralQty(background.wl, background.qty.to(u.W / (u.m ** 2 * u.Hz * u.sr),
equivalencies=u.spectral_density(
background.wl)))
t_background = background * (
self.__main_beam_efficiency * background.wl ** 2 / (2 * k_B) / self.__eta_fss * u.sr)
t_background = SpectralQty(t_background.wl, t_background.qty.decompose())
# Calculate the incoming photon current of the target
logger.info("Calculating the signal temperature.")
size = "extended" if signal.qty.unit.is_equivalent(u.W / (u.m ** 2 * u.nm * u.sr)) else "point"
if size == "point":
signal = SpectralQty(signal.wl, signal.qty.to(u.W / (u.m ** 2 * u.Hz),
equivalencies=u.spectral_density(signal.wl)))
t_signal = signal * (self.__aperture_efficiency * self.__common_conf.d_aperture() ** 2 *
np.pi / 4 / (2 * k_B) / self.__eta_fss)
t_signal = SpectralQty(t_signal.wl, t_signal.qty.decompose())
else:
signal = SpectralQty(signal.wl, signal.qty.to(u.W / (u.m ** 2 * u.Hz * u.sr),
equivalencies=u.spectral_density(signal.wl)))
t_signal = signal * (
self.__main_beam_efficiency * signal.wl ** 2 / (2 * k_B) / self.__eta_fss * u.sr)
t_signal = SpectralQty(t_signal.wl, t_signal.qty.decompose())
if self.__lambda_local_oscillator is None:
t_signal = t_signal * 2
t_background = t_background * 2
else:
t_signal = SpectralQty(t_signal.wl, t_signal.qty + t_signal.rebin(
2 * self.__lambda_local_oscillator - t_signal.wl).qty)
t_background = SpectralQty(t_background.wl, t_background.qty + t_background.rebin(
2 * self.__lambda_local_oscillator - t_background.wl).qty)
logger.debug("Spectral signal temperature")
logger.debug(t_signal)
logger.debug("Target size: " + size)
logger.debug("Obstruction: %.2f" % obstruction)
logger.debug("Spectral background temperature")
logger.debug(t_background)
return t_signal, t_background
[docs] @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, "aperture_efficiency"):
return "Missing container 'aperture_efficiency'."
mes = sensor.aperture_efficiency.check_float("val")
if mes is not None:
return "aperture_efficiency: " + mes
if not hasattr(sensor, "main_beam_efficiency"):
return "Missing container 'main_beam_efficiency'."
mes = sensor.main_beam_efficiency.check_float("val")
if mes is not None:
return "main_beam_efficiency: " + mes
if not hasattr(sensor, "receiver_temp"):
return "Missing container 'receiver_temp'."
mes = sensor.receiver_temp.check_quantity("val", u.K)
if mes is not None:
return "receiver_temp: " + mes
if not hasattr(sensor, "eta_fss"):
return "Missing container 'eta_fss'."
mes = sensor.eta_fss.check_float("val")
if mes is not None:
return "eta_fss: " + mes
if not hasattr(sensor, "lambda_line"):
return "Missing container 'lambda_line'."
mes = sensor.lambda_line.check_quantity("val", u.nm)
if mes is not None:
return "lambda_line: " + mes
if hasattr(sensor, "lambda_local_oscillator"):
mes = sensor.lambda_local_oscillator.check_quantity("val", u.nm)
if mes is not None:
return "lambda_local_oscillator: " + mes
if not hasattr(sensor, "kappa"):
return "Missing container 'kappa'."
mes = sensor.kappa.check_float("val")
if mes is not None:
return "kappa: " + mes
if hasattr(sensor, "n_on") and isinstance(sensor.n_on, Entry):
mes = sensor.n_on.check_float("val")
if mes is not None:
return "n_on: " + mes