Source code for exosim.tools.pixels_non_linearity

from math import factorial

import matplotlib.pyplot as plt
import numpy as np
from tqdm.auto import tqdm

from exosim.output import SetOutput
from exosim.utils import RunConfig

from .exosim_tool import ExoSimTool


[docs] class PixelsNonLinearity(ExoSimTool): r""" This tools helps the user to find the pixel non-linearity coefficients to as inputs for ExoSim, starting from the an estimate of pixel non-linearity correction. This class will retrieve the :math:`a_i` coefficients, starting from physical assumptions. The detector non linearity model, is written as polynomial such as .. math:: Q_{det} = Q \cdot (1 + \sum_i a_i \cdot Q^i) where :math:`Q_{det}` is the charge read by the detector, and :math:`Q` is the ideal count, as :math:`Q = \phi_t`, with :math:`\phi` being the number of electrons generated and :math:`t` being the elapsed time. Considering the detector as a capacitor, the charge :math:`Q_{det}` is given by .. math:: Q_{det} = \phi \tau \cdot \left(1 - e^{-Q/\phi \tau}\right) where :math:`\phi` is the charge generated in the detector pixel, and :math:`\tau` is the capacitor time constant. In fact the product :math:`\phi \tau` is constant :math:`Q` is the response of a linear detectror is given by :math:`Q = \phi t` The detector is considered saturated when the charge :math:`Q_{det}` at the well depth :math:`Q_{det, \, wd}` differs from the ideal well depth :math:`Q_{wd}` by 5%. .. math:: Q_{det} = (1-5\%)Q_{wd} Then .. math:: \phi \tau \cdot \left(1 - e^{-Q_{wd}/\phi \tau}\right) = (1-5\%)Q_{wd} This equation can be solved numerically and gives .. math:: \frac{Q_{wd}}{\phi \tau} \sim 0.103479 Therefore the detector collected charge is given by .. math:: Q_{det} = \frac{Q_{wd}}{0.103479} \cdot \left(1 - e^\frac{- 0.103479 \, Q}{Q_{wd}}\right) Which can be approximated by a polynomial of order 4 as .. math:: Q_{det} = Q\left[ 1- \frac{1}{2!}\frac{0.103479}{Q_{wd}} Q + \frac{1}{3!}\left(\frac{0.103479}{Q_{wd}}\right)^2 Q^2 - \frac{1}{4!}\left(\frac{0.103479}{Q_{wd}}\right)^3 Q^3 + \frac{1}{5!}\left(\frac{0.103479}{Q_{wd}}\right)^4 Q^4 \right] The results are the coefficients for a 4-th order polynomial: .. math:: Q_{det} = Q \cdot (a_1 + a_2 \cdot Q + a_3 \cdot Q^2 + a_4 \cdot Q^3 + a_5 \cdot Q^4) However, each pixel is different, and therefore, this class also produces a map of the coefficient for each pixel. Each coefficient is normally distributed around the mean value, with a standard deviation indicated in the configuration. If no standard deviation is indicated, the coefficients are assumed to be constant. The code output is a map of :math:`a_i` coefficients for each pixel, which can be injected into :class:`~exosim.tasks.detector.applyPixelsNonLinearity.ApplyPixelsNonLinearity`. Examples ---------- >>> import exosim.tools as tools >>> >>> results = tools.PixelsNonLinearity(options_file='tools_input_example.xml', >>> output='output_pnl_map.h5') """ def __init__( self, options_file: str | dict, output: str | None = None, show_results: bool = True, ) -> None: """ Parameters ---------- parameters: str or dict dictionary containing the parameters. This is usually parsed from :class:`~exosim.tasks.load.load_options.LoadOptions` output: str (optional) output file """ super().__init__(options_file) if output is not None: out = SetOutput(output, replace=True) for ch in self.ch_list: out_dict = {} self.info(f"computing pixel no linearity coefficients for {ch}") pnl_coeff, sat = self.compute_coefficients(self.ch_param[ch], show_results) out_dict["coeff"] = pnl_coeff out_dict["saturation"] = sat map = self.create_map(self.ch_param[ch], out_dict, show_results) out_dict["map"] = map self.results.update({ch: out_dict}) if output is not None: with out.use(append=True) as o: o.store_dictionary(out_dict, ch)
[docs] def compute_coefficients( self, parameters: dict, show_results: bool = True ) -> tuple[list[float], float]: """ It computes the non linearity coefficients. Parameters ---------- parameters: dict dictionary contained the sources parameters. This is usually parsed from :class:`~exosim.tasks.load.load_options.LoadOptions` show_results: bool it tells the code if showing the results in a plot. Default is `True`. """ Q_wd = parameters["detector"]["well_depth"].value phi_t = 0.103479 # stimated solution of (1-exp(Qdet/phiT)) = (1-0.05)Qdet/phiT constant = phi_t / Q_wd # compute the coefficients pnl_coeff = np.array( [(-1) ** i * 1 / factorial(i + 1) * constant**i for i in range(1, 5)] ) if show_results: self._print_results( pnl_coeff, Q_wd, ) self._plot(Q_wd, pnl_coeff) return np.insert(pnl_coeff, 0, 1), Q_wd
def _plot(self, saturation, pnl_coeff) -> None: Q = np.linspace(1, saturation * 1.2, 2**10) # detector pixel counts in adu fig, ax = plt.subplots(1, 1, figsize=(8, 6)) fig.suptitle("pixel linearity model") ax.axvline( saturation, c="k", label=f"real saturation (5% from linear): {int(np.ceil(saturation))}", ls="-", ) p = np.polynomial.Polynomial(np.insert(pnl_coeff, 0, 1)) plt.plot(Q, Q * p(Q), "g", label="Detector pixel count") ax.plot(Q, Q, "k", ls=":", label="Linear pixel count") ax.set_xlabel("$Q$ [adu]") ax.set_ylabel("$Q_{det}$ [adu]") ax.legend() ax.grid() plt.show() def _print_results(self, coeff: list[float], sat: float) -> None: """It prints the estimated coefficients and saturation level to screen. Parameters ---------- coeff : list list of fitted coefficients sat : float saturation value """ self.info(f"saturation (5% from linear): {int(sat)} (counts)") self.info("------------------------------------") self.info("pnl_coeff_a: 1") self.info(f"pnl_coeff_b: {coeff[0]}") self.info(f"pnl_coeff_c: {coeff[1]}") self.info(f"pnl_coeff_d: {coeff[2]}") self.info(f"pnl_coeff_e: {coeff[3]}") self.info("------------------------------------")
[docs] def create_map( self, parameters: dict, input_dict: dict, show_results: bool = True ) -> np.ndarray: """ Create a map of the pixel non-linearity correction coefficients. To create a non linearity map of the detector, we randomize the coefficients. If not specified, the standard deviation is set to 0 and the coefficients are assumed to be standard. of the mean value of the polynomial coefficients Parameters ---------- parameters: dict dictionary contained the sources parameters. This is usually parsed from :class:`~exosim.tasks.load.load_options.LoadOptions` input_dict: dict dictionary produced by `compute_coefficients`. It contains the coefficients (`coeff`), the saturation ('saturation') and the estimated well depth (`well_depth`) show_results: bool it tells the code if showing the results in a plot. Default is `True`. Returns ------- np.ndarray map of the coefficients. The shape is (4, nrow, ncol) The first axes refers to the coefficients (a, b, c, d, e) """ map = np.ones( ( input_dict["coeff"].size, parameters["detector"]["spatial_pix"], parameters["detector"]["spectral_pix"], ) ) std = parameters["detector"].get("pnl_coeff_std", 0.0) for i in range(input_dict["coeff"].size): map[i] *= RunConfig.random_generator.normal( input_dict["coeff"][i], std * np.abs(input_dict["coeff"][i]), size=map.shape[1:], ) if show_results: self._plot_map(input_dict, map) return map
def _plot_map(self, input_dict, map): Q = np.linspace( 1, input_dict["saturation"] * 1.2, 2**10 ) # detector pixel counts in adu fig, ax = plt.subplots(1, 1, figsize=(8, 6)) fig.suptitle("detector linearity model") ax.axvline( input_dict["saturation"], c="k", label="real saturation (5% from linear): {}".format( int(np.ceil(input_dict["saturation"])) ), ls="-", ) coeffs = map.T.reshape(map.shape[1] * map.shape[2], map.shape[0]) for cs in tqdm(coeffs, total=coeffs.shape[0], desc="preparing plot"): p = np.polynomial.Polynomial(cs) plt.plot(Q, Q * p(Q), c="g", lw=0.5, alpha=0.1) ax.plot(Q, Q, "k", ls=":", label="Linear pixel count") ax.set_xlabel("$Q$ [adu]") ax.set_ylabel("$Q_{det}$ [adu]") ax.legend() ax.grid() plt.show()