Source code for optika.sensors.materials._materials

from typing import Literal
from typing_extensions import Self
import abc
import functools
import dataclasses
import numpy as np
import scipy.optimize
import astropy.units as u
import astropy.constants
import named_arrays as na
import optika
from ._stern_1994 import (
    _thickness_oxide,
    _thickness_implant,
    _thickness_substrate,
    _width_pixel,
    _cce_backsurface,
)
from ._ramanathan_2020 import (
    energy_bandgap,
    energy_pair,
    energy_pair_inf,
    quantum_yield_ideal,
    fano_factor,
    fano_factor_inf,
    electrons_measured,
)
from .depletion import AbstractDepletionModel

__all__ = [
    "energy_bandgap",
    "energy_pair",
    "energy_pair_inf",
    "quantum_yield_ideal",
    "fano_factor",
    "fano_factor_inf",
    "absorption_effective",
    "transmittance",
    "absorbance",
    "charge_collection_efficiency",
    "quantum_efficiency_effective",
    "probability_measurement",
    "electrons_measured",
    "electrons_measured_approx",
    "signal",
    "vmr_signal",
    "AbstractSensorMaterial",
    "IdealSensorMaterial",
    "AbstractSiliconSensorMaterial",
    "AbstractBackIlluminatedSiliconSensorMaterial",
    "BackIlluminatedSiliconSensorMaterial",
]


[docs] def absorption_effective( wavelength: u.Quantity | na.AbstractScalar, n_substrate: complex | na.AbstractScalar, direction_substrate: complex | na.AbstractScalar, ) -> na.AbstractScalar: r""" The absorption coefficient per unit perpendicular depth for a wave refracted into the (absorbing) light-sensitive region at oblique incidence. This is the rigorous generalization of :math:`4 \pi \, \text{Im}(n) / \lambda` to oblique incidence: it uses the complex refracted cosine ``direction_substrate`` so that the geometric :math:`\sec\theta` path-length factor is no longer needed downstream. Reduces to the normal-incidence coefficient when ``direction_substrate`` is 1. Parameters ---------- wavelength The wavelength of the incident light in vacuum. n_substrate The complex index of refraction of the light-sensitive region. direction_substrate The (generally complex) cosine of the refracted angle inside the light-sensitive region, e.g. from :func:`optika.materials.snells_law_scalar`. Notes ----- For a wave refracted from a transparent ambient medium into an absorbing medium, the planes of constant amplitude remain parallel to the interface, so the intensity attenuates along the surface normal with coefficient .. math:: \alpha_\perp = \frac{4 \pi}{\lambda} \, \text{Im}(n_2 \cos\theta_2), where :math:`n_2` is the complex index of the light-sensitive region and :math:`\cos\theta_2` is its (complex) refracted cosine. """ return 4 * np.pi * np.imag(n_substrate * direction_substrate) / wavelength
[docs] def transmittance( wavelength: u.Quantity | na.AbstractScalar, direction: float | na.AbstractScalar = 1, n: complex | na.AbstractScalar = 1, thickness_oxide: u.Quantity | na.AbstractScalar = _thickness_oxide, thickness_substrate: u.Quantity | na.AbstractScalar = _thickness_substrate, chemical_oxide: str | optika.chemicals.AbstractChemical = "SiO2", chemical_substrate: str | optika.chemicals.AbstractChemical = "Si", roughness_oxide: u.Quantity | na.AbstractScalar = 0 * u.nm, roughness_substrate: u.Quantity | na.AbstractScalar = 0 * u.nm, ) -> optika.vectors.PolarizationVectorArray: """ The fraction of incident energy transmitted through the oxide layer into the light-sensitive material. Parameters ---------- wavelength The wavelength of the incident light in vacuum. direction The cosine of the incidence angle. Default is normal incidence. n The complex index of refraction in the ambient medium. thickness_oxide The thickness of the oxide layer on the illuminated surface of the sensor. Default is the value given in :cite:t:`Stern1994`. thickness_substrate The thickness of the light-sensitive substrate layer. Default is the value given in :cite:t:`Stern1994`. chemical_oxide The chemical formula of the oxide layer on the illuminated surface of the sensor. Default is silicon dioxide. chemical_substrate The chemical formula of the light-sensitive portion of the sensor. Default is silicon. roughness_oxide The RMS roughness the oxide layer surface. roughness_substrate The RMS roughness of the substrate surface. Examples -------- Plot the transmittance as a function of wavelength. .. jupyter-execute:: import matplotlib.pyplot as plt import astropy.units as u import named_arrays as na import optika # Define a grid of wavelengths wavelength = na.geomspace(10, 10000, axis="wavelength", num=1001) * u.AA # Compute the transmittance vs wavelength transmittance = optika.sensors.transmittance( wavelength=wavelength, ) # Plot the average transmittance vs. wavelength fig, ax = plt.subplots(constrained_layout=True) na.plt.plot( wavelength, transmittance.average, ax=ax, ); ax.set_xscale("log"); ax.set_xlabel(f"wavelength ({wavelength.unit:latex_inline})"); ax.set_ylabel("incident energy fraction"); """ if not isinstance(chemical_oxide, optika.chemicals.AbstractChemical): chemical_oxide = optika.chemicals.Chemical(chemical_oxide) if not isinstance(chemical_substrate, optika.chemicals.AbstractChemical): chemical_substrate = optika.chemicals.Chemical(chemical_substrate) reflection, transmission = optika.materials.multilayer_efficiency( wavelength=wavelength, direction=direction, n=n, layers=[ optika.materials.Layer( chemical=chemical_oxide, thickness=thickness_oxide, interface=optika.materials.profiles.ErfInterfaceProfile( width=roughness_oxide, ), ), ], substrate=optika.materials.Layer( chemical=chemical_substrate, thickness=thickness_substrate, interface=optika.materials.profiles.ErfInterfaceProfile( width=roughness_substrate, ), ), ) return transmission
[docs] def absorbance( wavelength: u.Quantity | na.AbstractScalar, direction: float | na.AbstractScalar = 1, n: complex | na.AbstractScalar = 1, thickness_oxide: u.Quantity | na.AbstractScalar = _thickness_oxide, thickness_substrate: u.Quantity | na.AbstractScalar = _thickness_substrate, chemical_oxide: str | optika.chemicals.AbstractChemical = "SiO2", chemical_substrate: str | optika.chemicals.AbstractChemical = "Si", roughness_oxide: u.Quantity | na.AbstractScalar = 0 * u.nm, roughness_substrate: u.Quantity | na.AbstractScalar = 0 * u.nm, method: Literal["exact", "Beer-Lambert"] = "Beer-Lambert", ) -> optika.vectors.PolarizationVectorArray: """ The fraction of incident energy absorbed by the light-sensitive region of the sensor Parameters ---------- wavelength The wavelength of the incident light in vacuum. direction The cosine of the incidence angle. Default is normal incidence. n The complex index of refraction in the ambient medium. thickness_oxide The thickness of the oxide layer on the illuminated surface of the sensor. Default is the value given in :cite:t:`Stern1994`. thickness_substrate The thickness of the light-sensitive substrate layer. Default is the value given in :cite:t:`Stern1994`. chemical_oxide The chemical formula of the oxide layer on the illuminated surface of the sensor. Default is silicon dioxide. chemical_substrate The chemical formula of the light-sensitive portion of the sensor. Default is silicon. roughness_oxide The RMS roughness the oxide layer surface. roughness_substrate The RMS roughness of the substrate surface. method The method to use to compute the absorbance. If ``exact``, this method allows thin-film interference effects inside the light-sensitive region. If ``Beer-Lambert``, this method assumes no interference effects. These methods only differ in the infrared, where the wavelength is commensurate with the thickness of the light-sensitive region. Examples -------- Plot the absorbance as a function of wavelength and compare it to the transmittance. .. jupyter-execute:: import matplotlib.pyplot as plt import astropy.units as u import named_arrays as na import optika # Define a grid of wavelengths wavelength = na.geomspace(10, 10000, axis="wavelength", num=1001) * u.AA # Compute the transmittance vs wavelength transmittance = optika.sensors.transmittance( wavelength=wavelength, ) # Compute the absorbance vs wavelength absorbance_exact = optika.sensors.absorbance( wavelength=wavelength, method="exact", ) absorbance_beer = optika.sensors.absorbance( wavelength=wavelength, method="Beer-Lambert", ) # Plot the average absorbance vs. wavelength fig, ax = plt.subplots(constrained_layout=True) na.plt.plot( wavelength, transmittance.average, ax=ax, label="transmittance", ); na.plt.plot( wavelength, absorbance_exact.average, ax=ax, label="exact absorbance", ); na.plt.plot( wavelength, absorbance_beer.average, ax=ax, label="Beer-Lambert absorbance", ); ax.set_xscale("log"); ax.set_xlabel(f"wavelength ({wavelength.unit:latex_inline})"); ax.set_ylabel("incident energy fraction"); ax.legend(); """ if not isinstance(chemical_oxide, optika.chemicals.AbstractChemical): chemical_oxide = optika.chemicals.Chemical(chemical_oxide) if not isinstance(chemical_substrate, optika.chemicals.AbstractChemical): chemical_substrate = optika.chemicals.Chemical(chemical_substrate) if method == "exact": result = optika.materials.layer_absorbance( index=1, wavelength=wavelength, direction=direction, n=n, layers=[ optika.materials.Layer( chemical=chemical_oxide, thickness=thickness_oxide, interface=optika.materials.profiles.ErfInterfaceProfile( width=roughness_oxide, ), ), optika.materials.Layer( chemical=chemical_substrate, thickness=thickness_substrate, interface=optika.materials.profiles.ErfInterfaceProfile( width=roughness_substrate, ), ), ], ) elif method == "Beer-Lambert": _transmittance = transmittance( wavelength=wavelength, direction=direction, n=n, thickness_oxide=thickness_oxide, thickness_substrate=thickness_substrate, chemical_oxide=chemical_oxide, chemical_substrate=chemical_substrate, roughness_oxide=roughness_oxide, roughness_substrate=roughness_substrate, ) n_substrate = chemical_substrate.n(wavelength) direction_substrate = optika.materials.snells_law_scalar( cos_incidence=direction, index_refraction=n, index_refraction_new=n_substrate, ) absorption = absorption_effective( wavelength=wavelength, n_substrate=n_substrate, direction_substrate=direction_substrate, ) _transmittance_total = _transmittance * np.exp( -absorption * thickness_substrate ) result = _transmittance - _transmittance_total else: # pragma: nocover raise ValueError(f"Method {method} not implemented") return np.real(result)
[docs] def charge_collection_efficiency( absorption: u.Quantity | na.AbstractScalar, thickness_implant: u.Quantity | na.AbstractScalar = _thickness_implant, cce_backsurface: u.Quantity | na.AbstractScalar = _cce_backsurface, ) -> na.AbstractScalar: r""" Compute the average charge collection efficiency using the differential charge collection efficiency profile described in :cite:t:`Stern1994`. Parameters ---------- absorption The absorption coefficient of the light-sensitive material per unit perpendicular depth. For oblique incidence, supply the effective coefficient from :func:`absorption_effective`, which folds in the refracted angle, so no separate angle argument is needed. thickness_implant The thickness of the implant layer, the layer where recombination can occur. Default is the value given in :cite:t:`Stern1994`. cce_backsurface The differential charge collection efficiency on the back surface of the sensor. Default is the value given in :cite:t:`Stern1994`. Examples -------- Plot the charge collection efficiency as a function of wavelength. .. jupyter-execute:: import matplotlib.pyplot as plt import astropy.units as u import named_arrays as na import optika # Define a grid of wavelengths wavelength = na.geomspace(10, 10000, axis="wavelength", num=1001) * u.AA # Compute the absorption coefficient for silicon absorption = optika.chemicals.Chemical("Si").absorption(wavelength) # Compute the CCE vs wavelength cce = optika.sensors.charge_collection_efficiency( absorption=absorption, ) # Plot the effective and maximum quantum efficiency fig, ax = plt.subplots(constrained_layout=True) na.plt.plot( wavelength, cce, ax=ax, ); ax.set_xscale("log"); ax.set_xlabel(f"wavelength ({wavelength.unit:latex_inline})"); ax.set_ylabel("charge collection efficiency"); Notes ----- The charge collection efficiency is the fraction of photoelectrons that are measured by the sensor :cite:p:`Janesick2001`, and is an important component of the quantum efficiency of the sensor In :cite:t:`Stern1994`, the authors define a differential charge collection efficiency, :math:`\eta(z)`, which is the probability that a photoelectron resulting from a photon absorbed at a depth :math:`z` will be measured by the sensor. In principle, :math:`\eta(z)` depends on the exact implant profile on the backsurface of the sensor, however :cite:t:`Stern1994` and :cite:t:`Boerner2012` have shown that a piecewise-linear approximation of :math:`\eta(z)`, .. math:: :label: differential-cce \eta(z) = \begin{cases} \eta_0 + (1 - \eta_0) z / W, & 0 < z < W \\ 1, & W < z < D, \end{cases} is sufficient, given the uncertainties in the optical constants involved. The total charge collection efficiency is then the average value of :math:`\eta(z)` weighted by the probability of absorbing a photon at a depth :math:`z`, .. math:: :label: cce-definition \text{CCE}(\lambda) = \frac{\int_0^\infty \eta(z) e^{-\alpha z} \, dz} {\int_0^\infty e^{-\alpha z} \, dz}. Plugging Equation :eq:`differential-cce` into Equation :eq:`cce-definition` and integrating yields .. math:: :label: cce \text{CCE}(\lambda) = \eta_0 + \left( \frac{1 - \eta_0}{\alpha W} \right)(1 - e^{-\alpha W}). Equation :eq:`cce` is equivalent to the term in curly braces of Equation 11 in :cite:t:`Stern1994`, with the addition of an :math:`e^{-\alpha W}` term which represents photons absorbed inside the epitaxial layer but outside the implant layer. In Equation :eq:`cce`, :math:`\alpha` and :math:`z` are measured along the surface normal. Oblique incidence is therefore handled entirely by the absorption coefficient: passing the perpendicular-depth coefficient :math:`\alpha_\perp` from :func:`absorption_effective` (which folds in the refracted angle, and reduces to :math:`\alpha` at normal incidence) makes Equation :eq:`cce` valid at any incidence angle without further modification. """ z0 = absorption * thickness_implant exp_z0 = np.exp(-z0) term_1 = cce_backsurface term_2 = ((1 - cce_backsurface) / z0) * (1 - exp_z0) return term_1 + term_2
[docs] def quantum_efficiency_effective( wavelength: u.Quantity | na.AbstractScalar, direction: float | na.AbstractScalar = 1, n: complex | na.AbstractScalar = 1, thickness_oxide: u.Quantity | na.AbstractScalar = _thickness_oxide, thickness_implant: u.Quantity | na.AbstractScalar = _thickness_implant, thickness_substrate: u.Quantity | na.AbstractScalar = _thickness_substrate, cce_backsurface: u.Quantity | na.AbstractScalar = _cce_backsurface, chemical_oxide: str | optika.chemicals.AbstractChemical = "SiO2", chemical_substrate: str | optika.chemicals.AbstractChemical = "Si", roughness_oxide: u.Quantity | na.AbstractScalar = 0 * u.nm, roughness_substrate: u.Quantity | na.AbstractScalar = 0 * u.nm, ) -> na.AbstractScalar: r""" Calculate the effective quantum efficiency of a backilluminated detector. Parameters ---------- wavelength The wavelength of the incident light in vacuum. direction The cosine of the incidence angle. Default is normal incidence. n The complex index of refraction of the ambient medium. thickness_oxide The thickness of the oxide layer on the back surface of the sensor. Default is the value given in :cite:t:`Stern1994`. thickness_implant The thickness of the implant layer. Default is the value given in :cite:t:`Stern1994`. thickness_substrate The thickness of the silicon substrate. Default is the value given in :cite:t:`Stern1994`. cce_backsurface The differential charge collection efficiency on the back surface of the sensor. Default is the value given in :cite:t:`Stern1994`. chemical_oxide The chemical composition of the oxide layer. The default is to assume the oxide layer is silicon dioxide. chemical_substrate Optional complex refractive index of the implant region and substrate. The default is to assume the substrate is made from silicon. roughness_oxide The RMS roughness the oxide layer surface. roughness_substrate The RMS roughness of the substrate surface. Examples -------- Reproduce Figure 12 from :cite:t:`Stern1994`, the modeled quantum efficiency of a Tektronix TK512CB :math:`512 \times 512` pixel backilluminated CCD. .. jupyter-execute:: import matplotlib.pyplot as plt import numpy as np import astropy.units as u import named_arrays as na import optika # Define an array of wavelengths with which to sample the EQE wavelength = na.geomspace(10, 10000, axis="wavelength", num=1001) * u.AA # Compute the effective quantum efficiency eqe = optika.sensors.quantum_efficiency_effective( wavelength=wavelength, ) # Compute the maximum theoretical quantum efficiency eqe_max = optika.sensors.quantum_efficiency_effective( wavelength=wavelength, cce_backsurface=1, ) # Plot the effective and maximum quantum efficiency fig, ax = plt.subplots(constrained_layout=True) na.plt.plot( wavelength, eqe, ax=ax, label="effective quantum efficiency", ); na.plt.plot( wavelength, eqe_max, ax=ax, label="maximum quantum efficiency", ); ax.set_xscale("log"); ax.set_xlabel(f"wavelength ({wavelength.unit:latex_inline})"); ax.set_ylabel("efficiency"); ax.legend(); Plot the EQE as a function of wavelength for normal and oblique incidence .. jupyter-execute:: import matplotlib.pyplot as plt import numpy as np import astropy.units as u import named_arrays as na import optika # Define an array of wavelengths with which to sample the EQE wavelength = na.geomspace(10, 10000, axis="wavelength", num=1001) * u.AA # Define the cosines of the incidence angles angle = na.linspace(0, 30, axis="angle", num=2) * u.deg direction = np.cos(angle) eqe = optika.sensors.quantum_efficiency_effective( wavelength=wavelength, direction=direction, ) # Plot the results fig, ax = plt.subplots(constrained_layout=True) angle_str = angle.value.astype(str).astype(object) na.plt.plot( wavelength, eqe, ax=ax, axis="wavelength", label=r"$\theta$ = " + angle_str + f"{angle.unit:latex_inline}", ); ax.set_xscale("log"); ax.set_xlabel(f"wavelength ({wavelength.unit:latex_inline})"); ax.set_ylabel("efficiency"); ax.legend(); Notes ----- :cite:t:`Stern1994` defines the effective quantum efficiency as .. math:: :label: eqe \text{EQE}(\lambda) = A(\lambda) \times \text{CCE}(\lambda), where :math:`A(\lambda)` is the absorbtivity of the epitaxial silicon layer for a given wavelength :math:`\lambda`, and :math:`\text{CCE}(\lambda)` is the charge collection efficiency (computed by :func:`charge_collection_efficiency`). """ if not isinstance(chemical_oxide, optika.chemicals.AbstractChemical): chemical_oxide = optika.chemicals.Chemical(chemical_oxide) if not isinstance(chemical_substrate, optika.chemicals.AbstractChemical): chemical_substrate = optika.chemicals.Chemical(chemical_substrate) absorbance_substrate = absorbance( wavelength=wavelength, direction=direction, n=n, thickness_oxide=thickness_oxide, thickness_substrate=thickness_substrate, chemical_oxide=chemical_oxide, chemical_substrate=chemical_substrate, roughness_oxide=roughness_oxide, roughness_substrate=roughness_substrate, ) n_substrate = chemical_substrate.n(wavelength) direction_substrate = optika.materials.snells_law_scalar( cos_incidence=direction, index_refraction=n, index_refraction_new=n_substrate, ) absorption_substrate = absorption_effective( wavelength=wavelength, n_substrate=n_substrate, direction_substrate=direction_substrate, ) cce = charge_collection_efficiency( absorption=absorption_substrate, thickness_implant=thickness_implant, cce_backsurface=cce_backsurface, ) result = absorbance_substrate.average * cce return result
[docs] def probability_measurement( iqy: u.Quantity | na.AbstractScalar = 1 * u.electron / u.photon, cce: float | na.AbstractScalar = 1, ) -> na.AbstractScalar: r""" The probability that a photon absorbed in the epitaxial silicon layer results in at least one photoelectron measured by the sensor. For most of the electromagnetic spectrum, this quantity is nearly unity, but in the ultraviolet, there is a significant chance that all the photoelectrons associated with a photon recombine before being measured. Parameters ---------- iqy The ideal quantum yield of the sensor in electrons per photon, calculated by :func:`quantum_yield_ideal`. cce The charge collection efficiency of the detector computed using :func:`charge_collection_efficiency`. Examples -------- Plot the probability of measuring an absorbed photon vs the charge collection efficiency .. jupyter-execute:: import matplotlib.pyplot as plt import astropy.units as u import astropy.visualization import named_arrays as na import optika # Define a grid of wavelengths wavelength = na.geomspace(10, 10000, axis="wavelength", num=1001) * u.AA # Compute the ideal quantum yield of silicon for these wavelengths iqy = optika.sensors.quantum_yield_ideal(wavelength) # Compute the charge collection efficiency for each wavelength cce = optika.sensors.charge_collection_efficiency( absorption=optika.chemicals.Chemical("Si").absorption(wavelength), ) # Compute the probability of measuring an absorbed photon # vs the charge collection efficiency p_m = optika.sensors.probability_measurement(iqy, cce) # Plot the results with astropy.visualization.quantity_support(): fig, ax = plt.subplots(constrained_layout=True) na.plt.plot( wavelength, cce, ax=ax, label="charge collection efficiency", ) na.plt.plot( wavelength, p_m, ax=ax, label="probability of measurement", ) ax.set_xscale("log"); ax.set_xlabel(f"wavelength ({ax.get_xlabel()})"); ax.set_ylabel("probability"); ax.legend(); Notes ----- The probability that `all` the electrons recombine before being measured is .. math:: P_r = (1 - \text{CCE})^\text{IQY} Where :math:`\text{CCE}` is the charge collection efficiency, and :math:`\text{IQY}` is the ideal quantum yield of the sensor. So then the probability of a photon being measured is just the compliment of :math:`P_r`, .. math:: P_m = 1 - P_r. """ iqy = iqy.to(u.electron / u.photon).value p_r = (1 - cce) ** iqy p_m = 1 - p_r return p_m
def _discrete_gamma( mean: float | na.ScalarArray, vmr: float | na.ScalarArray, shape_random: None | dict[str, int] = None, ) -> na.ScalarArray: x = na.random.gamma( shape=mean / vmr, scale=vmr, shape_random=shape_random, ) x = np.where( vmr != 0, x, mean, ) unit_x = x.unit if unit_x is not None: x = x.value x_frac, x_int = np.modf(x) x_frac = na.random.binomial( n=1, p=x_frac, shape_random=shape_random, ) x = x_int + x_frac if unit_x is not None: x = x << unit_x return x _fano_factor = fano_factor
[docs] def electrons_measured_approx( photons_absorbed: u.Quantity | na.AbstractScalar, wavelength: u.Quantity | na.ScalarArray, absorption: None | u.Quantity | na.AbstractScalar = None, thickness_implant: u.Quantity | na.AbstractScalar = _thickness_implant, thickness_substrate: u.Quantity | na.AbstractScalar = _thickness_substrate, cce_backsurface: u.Quantity | na.AbstractScalar = _cce_backsurface, temperature: u.Quantity | na.ScalarArray = 300 * u.K, iqy: None | u.Quantity | na.AbstractScalar = None, fano_factor: None | u.Quantity | na.AbstractScalar = None, shape_random: None | dict[str, int] = None, ) -> na.AbstractScalar: r""" A random sample from an approximate distribution of measured electrons given the number of photons absorbed by the light-sensitive layer of the sensor. This function accounts for both Fano noise and recombination noise due to partial-charge collection. Parameters ---------- photons_absorbed The number of photons absorbed by the light-sensitive layer of the sensor. wavelength The vacuum wavelength of the absorbed photons. absorption The absorption coefficient of silicon per unit perpendicular depth. For oblique incidence, supply the effective coefficient from :func:`optika.sensors.absorption_effective`, which folds in the refracted angle, so no separate angle argument is needed. thickness_implant The thickness of the implant layer, where partial-charge collection occurs. thickness_substrate The thickness of the entire light-sensitive region of the device. The absorbed photons are distributed within this region, which sets the fraction that land in the implant layer. cce_backsurface The differential charge collection efficiency on the back surface of the sensor. temperature The temperature of the silicon detector. Default is room temperature. iqy The ideal quantum yield of the sensor in electrons per photon. If :obj:`None` (the default), the result of :func:`quantum_yield_ideal` is used. fano_factor The `Fano factor <https://en.wikipedia.org/wiki/Fano_factor>`_ (ratio of the variance to the mean) of the Fano noise for this sensor material in units of electrons per photon. If :obj:`None` (the default), the result of :func:`fano_factor` is used. shape_random Additional shape used to specify the number of samples to draw. Examples -------- Plot the energy spectrum of twenty 6 keV photons emitted from an Fe-55 radioactive source and compare it to the exact spectrum .. jupyter-execute:: import matplotlib.pyplot as plt import astropy.units as u import astropy.visualization import named_arrays as na import optika # Define the number of experiments to perform num_experiments = 100000 # Define the expected number of photons # for each experiment photons_absorbed = (20 * u.photon).astype(int) # Define the wavelength at which to sample the distribution wavelength = 5.9 * u.keV wavelength = wavelength.to(u.AA, equivalencies=u.spectral()) # Compute the actual number of electrons measured for each experiment electrons_exact = optika.sensors.electrons_measured( photons_absorbed=photons_absorbed, wavelength=wavelength, shape_random=dict(experiment=num_experiments), ) # Compute the approximate number of electrons measured for each experiment electrons_approx = optika.sensors.electrons_measured_approx( photons_absorbed=photons_absorbed, wavelength=wavelength, shape_random=dict(experiment=num_experiments), ) # Define the histogram bins step = 10 bins = na.arange( electrons_exact.value.min()-step/2, electrons_exact.value.max()+step/2, step=step, axis="bin", ) * u.electron # Compute a histogram of exact energy spectrum hist_exact = na.histogram( electrons_exact, bins=bins, axis="experiment", ) # Compute a histogram of approximate energy spectrum hist_approx = na.histogram( electrons_approx, bins=bins, axis="experiment", ) # Plot the histogram with astropy.visualization.quantity_support(): fig, ax = plt.subplots() na.plt.stairs( hist_exact.inputs, hist_exact.outputs, ax=ax, label="exact", ); na.plt.stairs( hist_approx.inputs, hist_approx.outputs, ax=ax, label="approx", ); ax.legend(); """ if absorption is None: absorption = optika.chemicals.Chemical("Si").absorption(wavelength) if iqy is None: iqy = quantum_yield_ideal(wavelength, temperature) if fano_factor is None: fano_factor = _fano_factor(wavelength, temperature) if shape_random is None: shape_random = dict() shape = na.shape_broadcasted( photons_absorbed, absorption, iqy, thickness_implant, thickness_substrate, cce_backsurface, fano_factor, ) shape = na.broadcast_shapes(shape, shape_random) f = fano_factor a = absorption W = thickness_implant D = thickness_substrate n0 = cce_backsurface aW = (a * W).to(u.dimensionless_unscaled).value aD = (a * D).to(u.dimensionless_unscaled).value # The absorbed photons are distributed within the substrate `[0, D)`, so the # depth follows an exponential truncated to that region (matching the exact # `electrons_measured` kernel). Conditioned on absorption within the # substrate, a photon lands in the implant region (partial charge collection) # with probability (1 - exp(-alpha * W)) / (1 - exp(-alpha * D)); otherwise # it is absorbed deeper in the substrate (complete charge collection). fraction_partial = (1 - np.exp(-aW)) / (1 - np.exp(-aD)) photons_absorbed_partial = na.random.binomial( n=photons_absorbed, p=fraction_partial, shape_random=shape, ) photons_absorbed_complete = photons_absorbed - photons_absorbed_partial mean_p = n0 + (1 - n0) / (aW) + (1 - n0) / (1 - np.exp(aW)) var_p = np.square(n0 - 1) * (4 / np.square(aW) - 1 / np.square(np.sinh(aW / 2))) / 4 mean_n = iqy var_n = f * mean_n mean_p2 = np.square(mean_p) mean_n2 = np.square(mean_n) mean_i = mean_n * mean_p var_exp = (var_n * var_p) + (var_n * mean_p2) + (var_p * mean_n2) exp_var = mean_n * (mean_p - (var_p + mean_p2)) * u.electron / u.photon var_i = var_exp + exp_var mean_partial = photons_absorbed_partial * mean_i vmr_partial = var_i / mean_i * u.photon electrons_partial = _discrete_gamma( mean=mean_partial, vmr=np.maximum(vmr_partial - 1 / (6 * mean_partial) * u.electron**2, f * u.ph), shape_random=shape, ) electrons_complete = _discrete_gamma( mean=photons_absorbed_complete * iqy, vmr=f * u.photon, shape_random=shape, ) result = electrons_complete + electrons_partial return result
_transmittance = transmittance _absorbance = absorbance
[docs] def signal( photons_expected: u.Quantity | na.AbstractScalar, wavelength: u.Quantity | na.ScalarArray, direction: float | na.AbstractScalar = 1, n: complex | na.AbstractScalar = 1, n_substrate: None | complex | na.AbstractScalar = None, absorbance: None | float | na.AbstractScalar = None, thickness_implant: u.Quantity | na.AbstractScalar = _thickness_implant, thickness_depletion: u.Quantity | na.AbstractScalar = _thickness_substrate, thickness_substrate: None | na.AbstractScalar = _thickness_substrate, width_pixel: ( u.Quantity | na.AbstractScalar | na.AbstractCartesian2dVectorArray ) = _width_pixel, cce_backsurface: u.Quantity | na.AbstractScalar = _cce_backsurface, temperature: u.Quantity | na.ScalarArray = 300 * u.K, method: Literal["monte-carlo", "expected"] = "monte-carlo", axis_xy: None | tuple[str, str] = None, wrap: bool = False, shape_random: None | dict[str, int] = None, ) -> na.AbstractScalar: r""" A random sample from the distribution of measured electrons given the expected number of photons incident on the front surface of the sensor. This function adds shot noise to the expected number of photons, and then adds Fano noise and recombination noise using :func:`electrons_measured`. Parameters ---------- photons_expected The `expected` number of photons incident on the detector surface. wavelength The vacuum wavelength of the absorbed photons. direction The cosine of the incidence angle. n The complex index of refraction of the ambient medium. n_substrate The complex index of refraction of the light-sensitive material If :obj:`None` (the default), the result of :meth:`optika.chemicals.Chemical.n` for silicon will be used. absorbance The fraction of incident energy absorbed by the light-sensitive region of the detector. If :obj:`None` (the default), the result of :func:`absorbance` called with default values will be used. thickness_implant The thickness of the implant layer. Default is the value given in :cite:t:`Stern1994`. thickness_depletion The thickness of the depletion region, the region with significant electric field. If :obj:`None` (the default), this is set to the same value as `thickness_substrate`. thickness_substrate The thickness of the entire light-sensitive region of the device. The default width_pixel The size of a single pixel on the sensor. cce_backsurface The differential charge collection efficiency on the back surface of the sensor. Default is the value given in :cite:t:`Stern1994`. temperature The temperature of the light-sensitive silicon layer. method The method used to generate samples of measured electrons. The `monte-carlo` method draws a random sample by simulating every photon using :func:`electrons_measured`, including shot, Fano, and recombination noise as well as charge diffusion. The `expected` method adds no noise and just returns the expected number of electrons in each pixel; since it is a per-pixel expectation, it does not apply charge diffusion. axis_xy The two logical axes corresponding to the pixel grid of the sensor along which electrons will diffuse. If :obj:`None` (the default), there is no charge diffusion. wrap Controls how diffused charge is treated at the edges of the pixel grid. If :obj:`False` (the default), charge that diffuses past the edge of the grid is lost, as it would be at the physical edge of a sensor. If :obj:`True`, the grid is treated as periodic and the charge re-enters the opposite edge (a toroidal boundary). shape_random Additional shape used to specify the number of samples to draw. Examples -------- Plot the variance-to-mean ratio of the number of electrons measured by the sensor as a function of wavelength. .. jupyter-execute:: import matplotlib.pyplot as plt import astropy.units as u import named_arrays as na import optika # Define the number of experiments to perform num_experiments = 1000 # Define the expected number of photons # for each experiment photons_expected = 100 * u.photon # Define a grid of wavelengths wavelength = na.geomspace(10, 10000, axis="wavelength", num=1001) * u.AA # Compute the actual number of electrons measured for each experiment electrons = optika.sensors.signal( photons_expected=photons_expected, wavelength=wavelength, shape_random=dict(experiment=num_experiments), ) # Plot the variance-to-mean ratio of the result # as a function of wavelength. fig, ax = plt.subplots(constrained_layout=True) na.plt.plot( wavelength, electrons.vmr("experiment"), ax=ax, ); ax.set_xscale("log"); ax.set_yscale("log"); ax.set_xlabel(f"wavelength ({wavelength.unit:latex_inline})"); ax.set_ylabel(f"variance-to-mean ratio ({electrons.unit:latex_inline})"); """ if absorbance is None: absorbance = _absorbance( wavelength=wavelength, direction=direction, n=n, thickness_substrate=thickness_substrate, ).average if n_substrate is None: n_substrate = optika.chemicals.Chemical("Si").n(wavelength) direction_substrate = optika.materials.snells_law_scalar( cos_incidence=direction, index_refraction=n, index_refraction_new=n_substrate, ) absorption = absorption_effective( wavelength=wavelength, n_substrate=n_substrate, direction_substrate=direction_substrate, ) if method == "expected": iqy = quantum_yield_ideal( wavelength=wavelength, temperature=temperature, ) cce = charge_collection_efficiency( absorption=absorption, thickness_implant=thickness_implant, cce_backsurface=cce_backsurface, ) return iqy * absorbance * cce * photons_expected.to(u.ph) elif method == "monte-carlo": photons = na.random.poisson( lam=absorbance * photons_expected.to(u.ph), shape_random=shape_random, ).astype(int) return electrons_measured( photons_absorbed=photons, wavelength=wavelength, absorption=absorption, thickness_implant=thickness_implant, thickness_depletion=thickness_depletion, thickness_substrate=thickness_substrate, width_pixel=width_pixel, cce_backsurface=cce_backsurface, temperature=temperature, axis_xy=axis_xy, wrap=wrap, shape_random=shape_random, ) else: # pragma: nocover raise ValueError(f"Unrecognized method: {method}")
[docs] def vmr_signal( wavelength: u.Quantity | na.ScalarArray, direction: float | na.AbstractScalar = 1, n: complex | na.AbstractScalar = 1, n_substrate: None | complex | na.AbstractScalar = None, thickness_implant: u.Quantity | na.AbstractScalar = _thickness_implant, cce_backsurface: u.Quantity | na.AbstractScalar = _cce_backsurface, temperature: u.Quantity | na.ScalarArray = 300 * u.K, shot: bool = True, fano: bool = True, pcc: bool = True, ) -> na.ScalarArray: r""" Compute the variance-to-mean ratio (VMR) of the number of electrons measured by the sensor using an analytic expression. Parameters ---------- wavelength The vacuum wavelength of the absorbed photons. direction The cosine of the incidence angle. n The complex index of refraction of the ambient medium. n_substrate The complex index of refraction of the light-sensitive material. If :obj:`None` (the default), the result of :meth:`optika.chemicals.Chemical.n` for silicon will be used. thickness_implant The thickness of the implant layer. Default is the value given in :cite:t:`Stern1994`. cce_backsurface The differential charge collection efficiency on the back surface of the sensor. Default is the value given in :cite:t:`Stern1994`. temperature The temperature of the light-sensitive silicon layer. shot Whether to include shot noise in the result. fano Whether to include the Fano noise in the result. pcc Whether to include noise due to partial charge collection in the result. Examples -------- Compute the VMR of the signal analytically and compare to a Monte Carlo approximation .. jupyter-execute:: import matplotlib.pyplot as plt import astropy.units as u import named_arrays as na import optika # Define the number of experiments to perform num_experiments = 1000 # Define the expected number of photons # for each experiment photons_expected = 100 * u.photon # Define a grid of wavelengths wavelength = na.geomspace(10, 10000, axis="wavelength", num=1001) * u.AA # Compute the variance-to-mean ratio of the signal analytically vmr_signal = optika.sensors.vmr_signal(wavelength) # Compute the actual number of electrons measured for each experiment signal = optika.sensors.signal( photons_expected=photons_expected, wavelength=wavelength, shape_random=dict(experiment=num_experiments), ) # Plot the variance-to-mean ratio of the result # as a function of wavelength. fig, ax = plt.subplots(constrained_layout=True) na.plt.plot( wavelength, signal.vmr("experiment"), ax=ax, label="Monte Carlo", ); na.plt.plot( wavelength, vmr_signal, ax=ax, label="analytic" ); ax.set_xscale("log"); ax.set_yscale("log"); ax.set_xlabel(f"wavelength ({wavelength.unit:latex_inline})"); ax.set_ylabel(f"variance-to-mean ratio ({signal.unit:latex_inline})"); ax.legend(); Notes ----- The VMR of the measured electrons is given by .. math:: F(N_e'') = 1 - \overline{\eta} - F(\eta) + \overline{n} \, \overline{\eta} + \overline{\eta} \mathcal{F} + \overline{n} F(\eta) + \mathcal{F} F(\eta) where :math:`N_e''` is the number of measured electrons, :math:`\overline{\eta}` is the average charge-collection efficiency, :math:`\overline{n}` is the average quantum yield, and :math:`\mathcal{F}` is the Fano factor. The VMR of the charge-collection efficiency (CCE) is .. math:: F(\eta) = \frac{2 e^{-\alpha W}}{\overline{\eta}} \left( \frac{1 - \eta_0}{\alpha W} \right)^2 \bigl( \sinh(\alpha W) - \alpha W \bigr) where :math:`\alpha` is the absorption coefficient, :math:`W` is the thickness of the implant region, and :math:`\eta_0` is the CCE at the back surface. """ if n_substrate is None: n_substrate = optika.chemicals.Chemical("Si").n(wavelength) direction_substrate = optika.materials.snells_law_scalar( cos_incidence=direction, index_refraction=n, index_refraction_new=n_substrate, ) absorption = absorption_effective( wavelength=wavelength, n_substrate=n_substrate, direction_substrate=direction_substrate, ) iqy = quantum_yield_ideal( wavelength=wavelength, temperature=temperature, ) cce = charge_collection_efficiency( absorption=absorption, thickness_implant=thickness_implant, cce_backsurface=cce_backsurface, ) F = fano_factor(wavelength) result = 0 if shot: F_shot = iqy * cce result = result + F_shot if fano: F_fano = cce * F result = result + F_fano if pcc: n0 = cce_backsurface aW = absorption * thickness_implant aW = aW.to(u.dimensionless_unscaled).value F_cce = 2 * np.exp(-aW) * np.square((n0 - 1) / aW) * (np.sinh(aW) - aW) / cce unit = u.electron / u.photon F_pcc = 1 * unit - cce * unit - F_cce * unit + iqy * F_cce + F * F_cce result = result + F_pcc return result * u.photon
[docs] @dataclasses.dataclass(eq=False, repr=False) class AbstractSensorMaterial( optika.materials.AbstractMaterial, ): """ An interface representing the light-sensitive material of an imaging sensor. """
[docs] @abc.abstractmethod def signal( self, photons: u.Quantity | na.AbstractScalar, wavelength: u.Quantity | na.AbstractScalar, direction: float | na.AbstractScalar = 1, width_pixel: ( u.Quantity | na.AbstractScalar | na.AbstractCartesian2dVectorArray ) = 0 * u.um, axis_xy: None | tuple[str, str] = None, noise: bool = True, wrap: bool = False, ) -> na.AbstractScalar: """ Given the photons incident on each pixel, compute the number of electrons measured by the sensor using :func:`signal`. Parameters ---------- photons The number of photons incident on each pixel. wavelength An assumed grid of wavelengths for the incident photons. direction The cosine of the refracted angle inside the light-sensitive region, as produced by :meth:`direction_refracted`. width_pixel The physical size of each pixel, used by the charge-diffusion model. axis_xy The two logical axes corresponding to the pixel grid of the sensor. If provided, charge diffusion will occur along these two axes. If :obj:`None` (the default), no diffusion is performed. noise Whether to add noise to the result. wrap Controls how diffused charge is treated at the edges of the pixel grid. If :obj:`False` (the default), charge that diffuses past the edge of the grid is lost, as it would be at the physical edge of a sensor. If :obj:`True`, the grid is treated as periodic and the charge re-enters the opposite edge (a toroidal boundary). """
[docs] @abc.abstractmethod def direction_refracted( self, wavelength: u.Quantity | na.AbstractScalar, direction: None | na.AbstractCartesian3dVectorArray = None, n: complex | na.AbstractScalar = 1, normal: None | na.AbstractCartesian3dVectorArray = None, ) -> complex | na.AbstractScalar: """ The cosine of the refracted propagation angle inside the light-sensitive region of the sensor. This is the quantity :meth:`signal` expects as its ``direction`` argument. Performing the refraction here lets the sensor's ``collect`` method fold the ambient index of refraction into the per-pixel cosine, so :meth:`signal` does not need a separate ambient-index argument. Parameters ---------- wavelength The wavelength of the incident light in vacuum. direction The propagation direction of the incident light in the ambient medium. If :obj:`None` (default), normal incidence (:math:`\\hat{z}`) is assumed. n The complex index of refraction of the ambient medium. normal The vector perpendicular to the surface of the sensor. """
[docs] @abc.abstractmethod def photons_incident( self, electrons: u.Quantity | na.AbstractScalar, wavelength: u.Quantity | na.AbstractScalar, direction: na.AbstractCartesian3dVectorArray, normal: na.AbstractCartesian3dVectorArray, ) -> na.AbstractScalar: """ Given the number of electrons measured by the sensor, and a grid of wavelengths, compute the expected number of photons incident on the sensor. Parameters ---------- electrons The number of electrons measured by each pixel. wavelength An assumed grid of wavelengths for the incident photons. direction An assumed propagation direction for the incident photons. normal The vector perpendicular to the surface of the sensor. """
[docs] @dataclasses.dataclass(eq=False, repr=False) class IdealSensorMaterial( optika.materials.Vacuum, AbstractSensorMaterial, ): """ An idealized sensor material with a quantum efficiency of unity, no charge diffusion, and a noise model which consists of only shot noise. """
[docs] def signal( self, photons: u.Quantity | na.AbstractScalar, wavelength: u.Quantity | na.AbstractScalar, direction: float | na.AbstractScalar = 1, width_pixel: ( u.Quantity | na.AbstractScalar | na.AbstractCartesian2dVectorArray ) = 0 * u.um, axis_xy: None | tuple[str, str] = None, noise: bool = True, wrap: bool = False, ) -> na.AbstractScalar: if not photons.unit.is_equivalent(u.photon): h = astropy.constants.h c = astropy.constants.c photons = photons / (h * c / wavelength) * u.photon if noise: photons = na.random.poisson(photons.to(u.ph)).astype(int) electrons = photons * u.electron / u.photon electrons = electrons.to(u.electron) return electrons
[docs] def direction_refracted( self, wavelength: u.Quantity | na.AbstractScalar, direction: None | na.AbstractCartesian3dVectorArray = None, n: complex | na.AbstractScalar = 1, normal: None | na.AbstractCartesian3dVectorArray = None, ) -> complex | na.AbstractScalar: # An ideal material does not refract, so the refracted cosine is just # the cosine of the incidence angle. if direction is None: direction = na.Cartesian3dVectorArray(0, 0, 1) if normal is None: normal = na.Cartesian3dVectorArray(0, 0, -1) return -direction @ normal
[docs] def photons_incident( self, electrons: u.Quantity | na.AbstractScalar, wavelength: u.Quantity | na.AbstractScalar, direction: na.AbstractCartesian3dVectorArray, normal: na.AbstractCartesian3dVectorArray, ) -> na.AbstractScalar: return electrons * u.photon / u.electron
[docs] @dataclasses.dataclass(eq=False, repr=False) class AbstractSiliconSensorMaterial( AbstractSensorMaterial, ): """ An interface representing the light-sensitive material of a silicon sensor. """ temperature: u.Quantity | na.AbstractScalar = 300 * u.K """The temperature of this sensor.""" @property def transformation(self) -> None: return None @functools.cached_property def _chemical(self) -> optika.chemicals.Chemical: return optika.chemicals.Chemical("Si") @functools.cached_property def _chemical_oxide(self) -> optika.chemicals.Chemical: return optika.chemicals.Chemical("SiO2_llnl_cxro_rodriguez")
[docs] def quantum_yield_ideal( self, wavelength: u.Quantity | na.AbstractScalar, ) -> u.Quantity | na.AbstractScalar: """ Compute the ideal quantum yield of this CCD sensor material using :func:`optika.sensors.quantum_yield_ideal`. Parameters ---------- wavelength The wavelength of the incident light """ return quantum_yield_ideal( wavelength=wavelength, temperature=self.temperature, )
[docs] def fano_factor( self, wavelength: u.Quantity | na.AbstractScalar, ) -> na.ScalarArray: """ The `Fano factor <https://en.wikipedia.org/wiki/Fano_factor>`_ (ratio of the variance to the mean) of the Fano noise for this sensor material. The method uses the equivalent function, :func:`optika.sensors.fano_factor, along with the :attr:`temperature` attribute to compute the Fano factor for this material """ return fano_factor( wavelength=wavelength, temperature=self.temperature, )
[docs] def index_refraction( self, rays: optika.rays.AbstractRayVectorArray, ) -> na.ScalarLike: return rays.index_refraction
[docs] def attenuation( self, rays: optika.rays.AbstractRayVectorArray, ) -> na.ScalarLike: return rays.attenuation
@property def is_mirror(self) -> bool: return False
[docs] @dataclasses.dataclass(eq=False, repr=False) class AbstractBackIlluminatedSiliconSensorMaterial( AbstractSiliconSensorMaterial, ): """ An interface representing the light-sensitive material of a backilluminated silicon sensor. """ @property @abc.abstractmethod def thickness_oxide(self) -> u.Quantity | na.AbstractScalar: """ The thickness of the oxide layer on the illuminated side of the sensor. """ @property @abc.abstractmethod def thickness_implant(self) -> u.Quantity | na.AbstractScalar: """The thickness of the ion implant layer.""" @property @abc.abstractmethod def thickness_substrate(self) -> u.Quantity | na.AbstractScalar: """The thickness of the light-sensitive silicon substrate.""" @property @abc.abstractmethod def roughness_oxide(self): """The RMS roughness of the oxide layer surface.""" @property @abc.abstractmethod def roughness_substrate(self): """The RMS roughness of the silicon substrate surface.""" @property @abc.abstractmethod def cce_backsurface(self) -> float | na.AbstractScalar: """ The charge collection efficiency on the illuminated surface of the sensor. """ @property @abc.abstractmethod def depletion(self) -> AbstractDepletionModel: """ A model of this sensor's depletion region. """
[docs] def width_charge_diffusion( self, wavelength: u.Quantity | na.AbstractScalar, ) -> na.AbstractScalar: """ The standard deviation of the charge diffusion kernel for this sensor. Calculated using :func:`optika.sensors.charge_diffusion`. Parameters ---------- wavelength The wavelength of the incident light in vacuum. """ return optika.sensors.charge_diffusion( self._chemical.absorption(wavelength), thickness_substrate=self.thickness_substrate, thickness_depletion=self.depletion.thickness, )
[docs] def transmittance( self, wavelength: u.Quantity | na.AbstractScalar, direction: None | na.AbstractCartesian3dVectorArray = None, n: complex | na.AbstractScalar = 1, normal: None | na.AbstractCartesian3dVectorArray = None, ) -> optika.vectors.PolarizationVectorArray: r""" Compute the fraction of energy transmitted to the light-sensitive region of the sensor. Parameters ---------- wavelength The wavelength of the incident light in vacuum. direction The propagation direction of the incident light in the ambient medium. If :obj:`None` (default), normal incidence (:math:`\hat{z}`) is assumed. n The complex index of refraction of the ambient medium. normal The vector perpendicular to the surface of the CCD sensor. """ if direction is None: direction = na.Cartesian3dVectorArray(0, 0, 1) if normal is None: normal = na.Cartesian3dVectorArray(0, 0, -1) return transmittance( wavelength=wavelength, direction=-direction @ normal, n=n, thickness_oxide=self.thickness_oxide, thickness_substrate=self.thickness_substrate, chemical_oxide=self._chemical_oxide, chemical_substrate=self._chemical, roughness_oxide=self.roughness_oxide, roughness_substrate=self.roughness_substrate, )
[docs] def absorbance( self, wavelength: u.Quantity | na.AbstractScalar, direction: None | na.AbstractCartesian3dVectorArray = None, n: complex | na.AbstractScalar = 1, normal: None | na.AbstractCartesian3dVectorArray = None, ) -> optika.vectors.PolarizationVectorArray: r""" Compute the fraction of energy absorbed by the light-sensitive region of the sensor. Parameters ---------- wavelength The wavelength of the incident light in vacuum. direction The propagation direction of the incident light in the ambient medium. If :obj:`None` (default), normal incidence (:math:`\hat{z}`) is assumed. n The complex index of refraction of the ambient medium. normal The vector perpendicular to the surface of the CCD sensor. """ if direction is None: direction = na.Cartesian3dVectorArray(0, 0, 1) if normal is None: normal = na.Cartesian3dVectorArray(0, 0, -1) return absorbance( wavelength=wavelength, direction=-direction @ normal, n=n, thickness_oxide=self.thickness_oxide, thickness_substrate=self.thickness_substrate, chemical_oxide=self._chemical_oxide, chemical_substrate=self._chemical, roughness_oxide=self.roughness_oxide, roughness_substrate=self.roughness_substrate, )
[docs] def charge_collection_efficiency( self, wavelength: u.Quantity | na.AbstractScalar, direction: None | na.AbstractCartesian3dVectorArray = None, n: complex | na.AbstractScalar = 1, normal: None | na.AbstractCartesian3dVectorArray = None, ) -> na.AbstractScalar: r""" Compute the charge collection efficiency of this CCD sensor material using :func:`charge_collection_efficiency`. Parameters ---------- wavelength The wavelength of the incident light in vacuum. direction The propagation direction of the incident light in the ambient medium. If :obj:`None` (default), normal incidence (:math:`\hat{z}`) is assumed. n The complex index of refraction of the ambient medium. normal The vector perpendicular to the surface of the CCD sensor. """ if direction is None: direction = na.Cartesian3dVectorArray(0, 0, 1) if normal is None: normal = na.Cartesian3dVectorArray(0, 0, -1) direction = -direction @ normal n_substrate = self._chemical.n(wavelength) direction_substrate = optika.materials.snells_law_scalar( cos_incidence=direction, index_refraction=n, index_refraction_new=n_substrate, ) return charge_collection_efficiency( absorption=absorption_effective( wavelength=wavelength, n_substrate=n_substrate, direction_substrate=direction_substrate, ), thickness_implant=self.thickness_implant, cce_backsurface=self.cce_backsurface, )
[docs] def quantum_efficiency_effective( self, wavelength: u.Quantity | na.AbstractScalar, direction: None | na.AbstractCartesian3dVectorArray = None, n: complex | na.AbstractScalar = 1, normal: None | na.AbstractCartesian3dVectorArray = None, ) -> na.AbstractScalar: r""" Compute the effective quantum efficiency of this CCD material using :func:`quantum_efficiency_effective`. Parameters ---------- wavelength The wavelength of the incident light in vacuum. direction The propagation direction of the incident light in the ambient medium. If :obj:`None` (default), normal incidence (:math:`\hat{z}`) is assumed. n The complex index of refraction of the ambient medium. normal The vector perpendicular to the surface of the CCD. """ if direction is None: direction = na.Cartesian3dVectorArray(0, 0, 1) if normal is None: normal = na.Cartesian3dVectorArray(0, 0, -1) direction = -direction @ normal return quantum_efficiency_effective( wavelength=wavelength, direction=direction, n=n, thickness_oxide=self.thickness_oxide, thickness_implant=self.thickness_implant, thickness_substrate=self.thickness_substrate, cce_backsurface=self.cce_backsurface, chemical_oxide=self._chemical_oxide, chemical_substrate=self._chemical, roughness_oxide=self.roughness_oxide, roughness_substrate=self.roughness_substrate, )
[docs] def quantum_efficiency( self, wavelength: u.Quantity | na.AbstractScalar, direction: None | na.AbstractCartesian3dVectorArray = None, n: complex | na.AbstractScalar = 1, normal: None | na.AbstractCartesian3dVectorArray = None, ) -> na.AbstractScalar: r""" Compute the quantum efficiency of this CCD material using :meth:`quantum_efficiency_effective` and :meth:`quantum_yield_ideal`. Parameters ---------- wavelength The wavelength of the incident light in vacuum. direction The propagation direction of the incident light in the ambient medium. If :obj:`None` (default), normal incidence (:math:`\hat{z}`) is assumed. n The complex index of refraction of the ambient medium. normal The vector perpendicular to the surface of the CCD. """ iqy = self.quantum_yield_ideal(wavelength) eqe = self.quantum_efficiency_effective( wavelength=wavelength, direction=direction, n=n, normal=normal, ) return iqy * eqe
[docs] def probability_measurement( self, wavelength: u.Quantity | na.AbstractScalar, direction: None | na.AbstractCartesian3dVectorArray = None, normal: None | na.AbstractCartesian3dVectorArray = None, ) -> na.AbstractScalar: r""" Compute the probability of measuring an absorbed photon for this sensor using :func:`probability_measurement`. Parameters ---------- wavelength The wavelength of the incident light in vacuum. direction The propagation direction of the incident light in the ambient medium. If :obj:`None` (default), normal incidence (:math:`\hat{z}`) is assumed. normal The vector perpendicular to the surface of the CCD. """ return probability_measurement( iqy=self.quantum_yield_ideal(wavelength), cce=self.charge_collection_efficiency( wavelength=wavelength, direction=direction, normal=normal, ), )
[docs] def direction_refracted( self, wavelength: u.Quantity | na.AbstractScalar, direction: None | na.AbstractCartesian3dVectorArray = None, n: complex | na.AbstractScalar = 1, normal: None | na.AbstractCartesian3dVectorArray = None, ) -> complex | na.AbstractScalar: if direction is None: direction = na.Cartesian3dVectorArray(0, 0, 1) if normal is None: normal = na.Cartesian3dVectorArray(0, 0, -1) return optika.materials.snells_law_scalar( cos_incidence=-direction @ normal, index_refraction=n, index_refraction_new=self._chemical.n(wavelength), )
[docs] def electrons_measured( self, photons_absorbed: na.AbstractScalar, wavelength: u.Quantity | na.AbstractScalar, direction: None | na.AbstractCartesian3dVectorArray = None, n: complex | na.AbstractScalar = 1, normal: None | na.AbstractCartesian3dVectorArray = None, width_pixel: ( u.Quantity | na.AbstractScalar | na.AbstractCartesian2dVectorArray ) = 0 * u.um, axis_xy: None | tuple[str, str] = None, wrap: bool = False, ) -> na.AbstractScalar: """ Randomly sample the number of measured electrons given the number of absorbed photons using :func:`electrons_measured`. """ direction_substrate = self.direction_refracted( wavelength=wavelength, direction=direction, n=n, normal=normal, ) return electrons_measured( photons_absorbed=photons_absorbed, wavelength=wavelength, absorption=absorption_effective( wavelength=wavelength, n_substrate=self._chemical.n(wavelength), direction_substrate=direction_substrate, ), thickness_implant=self.thickness_implant, thickness_depletion=self.depletion.thickness, thickness_substrate=self.thickness_substrate, width_pixel=width_pixel, cce_backsurface=self.cce_backsurface, axis_xy=axis_xy, wrap=wrap, temperature=self.temperature, )
[docs] def signal( self, photons: u.Quantity | na.AbstractScalar, wavelength: u.Quantity | na.AbstractScalar, direction: float | na.AbstractScalar = 1, width_pixel: ( u.Quantity | na.AbstractScalar | na.AbstractCartesian2dVectorArray ) = 0 * u.um, axis_xy: None | tuple[str, str] = None, noise: bool = True, wrap: bool = False, ) -> na.AbstractScalar: if not photons.unit.is_equivalent(u.photon): h = astropy.constants.h c = astropy.constants.c photons = photons / (h * c / wavelength) * u.photon if noise: method = "monte-carlo" else: method = "expected" # `direction` is the cosine of the refracted angle *inside* the substrate # (computed by the sensor's `collect` method), so use equal ambient and # substrate indices to make the Snell refraction inside `signal` a no-op # while still computing the effective absorption with the correct index. n_substrate = self._chemical.n(wavelength) return signal( photons_expected=photons, wavelength=wavelength, direction=direction, n=n_substrate, n_substrate=n_substrate, absorbance=1, thickness_implant=self.thickness_implant, thickness_depletion=self.depletion.thickness, thickness_substrate=self.thickness_substrate, width_pixel=width_pixel, cce_backsurface=self.cce_backsurface, temperature=self.temperature, method=method, axis_xy=axis_xy, wrap=wrap, )
[docs] def photons_incident( self, electrons: u.Quantity | na.AbstractScalar, wavelength: u.Quantity | na.AbstractScalar, direction: None | na.AbstractCartesian3dVectorArray = None, n: complex | na.AbstractScalar = 1, normal: None | na.AbstractCartesian3dVectorArray = None, ) -> na.AbstractScalar: """ Compute the expected number of incident photons for a given number of electrons. Parameters ---------- electrons The energy collected by the sensor in units of electrons. wavelength The assumed wavelength of the incident photons. direction The assumed direction of the incident photons. n The complex index of refraction of the ambient medium. normal The vector perpendicular to the surface of the sensor. """ qe = self.quantum_efficiency( wavelength=wavelength, direction=direction, n=n, normal=normal, ) return electrons / qe
[docs] def efficiency( self, rays: optika.rays.AbstractRayVectorArray, normal: na.AbstractCartesian3dVectorArray, ) -> na.ScalarLike: result = self.absorbance( wavelength=rays.wavelength, direction=rays.direction, n=rays.n, normal=normal, ) return result.average
[docs] @dataclasses.dataclass(eq=False, repr=False) class BackIlluminatedSiliconSensorMaterial( AbstractBackIlluminatedSiliconSensorMaterial, ): """ A back-illuminated silicon sensor material which uses the method described in :cite:t:`Stern1994` to compute the quantum efficiency. """ thickness_oxide: u.Quantity = 0 * u.nm """The thickness of the oxide layer on the illuminated side of the sensor.""" thickness_implant: u.Quantity = 0 * u.nm """The thickness of the ion implant layer.""" thickness_substrate: u.Quantity | na.AbstractScalar = 0 * u.um """The thickness of the light-sensitive silicon substrate.""" roughness_oxide: u.Quantity = 0 * u.nm """The RMS roughness of the oxide layer on the illuminated side of the sensor.""" roughness_substrate: u.Quantity = 0 * u.um """The RMS roughness of the silicon substrate.""" cce_backsurface: u.Quantity = 0 """The charge-collection efficiency of the back surface of the sensor.""" depletion: None | AbstractDepletionModel = None """A model of this sensor's depletion region.""" eqe_measured: None | na.FunctionArray = None """An optional measurement of the effective quantum efficiency.""" @property def shape(self) -> dict[str, int]: return dict()
[docs] @classmethod def fit_eqe( cls, thickness_substrate: u.Quantity | na.AbstractScalar, depletion: AbstractDepletionModel, eqe_measured: na.FunctionArray, temperature: u.Quantity | na.AbstractScalar = 300 * u.K, ) -> Self: """ Fit the parameters of this sensor to a given effective quantum efficiency. Parameters --------- temperature The temperature of the light-sensitive silicon substrate. thickness_substrate The thickness of the light-sensitive silicon substrate. depletion A model of this sensor's depletion region. eqe_measured The measured quantum efficiency that will be fit by the function :func:`optika.sensors.quantum_efficiency_effective`. """ unit_thickness_oxide = u.AA unit_thickness_implant = u.AA unit_roughness = u.nm unit_cce_backsurface = u.dimensionless_unscaled roughness_oxide = 0 * unit_roughness def eqe_rms_difference(x: tuple[float, float, float, float]): ( thickness_oxide, thickness_implant, roughness_substrate, cce_backsurface, ) = x qe_fit = quantum_efficiency_effective( wavelength=eqe_measured.inputs, direction=1, thickness_oxide=thickness_oxide << unit_thickness_oxide, thickness_implant=thickness_implant << unit_thickness_implant, thickness_substrate=thickness_substrate, roughness_oxide=0 * unit_roughness, roughness_substrate=roughness_substrate << unit_roughness, cce_backsurface=cce_backsurface << unit_cce_backsurface, ) return np.sqrt(np.mean(np.square(eqe_measured.outputs - qe_fit))).ndarray thickness_oxide_guess = 50 * u.AA thickness_implant_guess = 2317 * u.AA roughness_substrate_guess = 5 * u.nm cce_backsurface_guess = 0.21 * u.dimensionless_unscaled fit = scipy.optimize.minimize( fun=eqe_rms_difference, x0=[ thickness_oxide_guess.to_value(unit_thickness_oxide), thickness_implant_guess.to_value(unit_thickness_implant), roughness_substrate_guess.to_value(unit_roughness), cce_backsurface_guess.to_value(unit_cce_backsurface), ], bounds=[ (0, None), (0, None), (0, None), (0, 1), ], method="nelder-mead", ) ( thickness_oxide, thickness_implant, roughness_substrate, cce_backsurface, ) = fit.x thickness_oxide = thickness_oxide << unit_thickness_oxide thickness_implant = thickness_implant << unit_thickness_implant roughness_substrate = roughness_substrate << unit_roughness cce_backsurface = cce_backsurface << unit_cce_backsurface return cls( temperature=temperature, thickness_oxide=thickness_oxide, thickness_implant=thickness_implant, thickness_substrate=thickness_substrate, roughness_oxide=roughness_oxide, roughness_substrate=roughness_substrate, cce_backsurface=cce_backsurface, depletion=depletion, eqe_measured=eqe_measured, )