diff --git a/docs/refs.bib b/docs/refs.bib index 4e6e425..70ffb08 100644 --- a/docs/refs.bib +++ b/docs/refs.bib @@ -90,3 +90,11 @@ @article{Stern1994 doi = {10.1364/AO.33.002521}, abstract = {We report quantum efficiency measurements of backilluminated, ion-implanted, laser-annealed CCD's in the wavelength range 13--10,000 {\AA}. The equivalent quantum efficiency (the equivalent photons detected per incident photon) ranges from a minimum of 5\% at 1216 {\AA} to a maximum of 87\% at 135 {\AA}. Using a simple relationship for the charge-collection efficiency of the CCD pixels as a function of depth, we present a semiempirical model with few parameters that reproduces our measurements with a fair degree of accuracy. The advantage of this model is that it can be used to predict CCD quantum efficiency performance for shallow backside implanted devices without a detailed solution of a system of differential equations, as in conventional approaches, and it yields a simple analytic form for the charge-collection efficiency that is adequate for detector calibration purposes. Making detailed assumptions about the dopant profile, we also solve the current density and continuity equations in order to relate our semiempirical model parameters to surface and bulk device properties. The latter procedure helps to better establish device processing parameters for a given level of CCD quantum efficiency performance.}, } +@techreport{Moody2017, + author = {Ian Moody and Marc Watkins and Ray Bell and Matthew Soman and Jonathan Keelan and Andrew Holland}, + title = {CCD QE in the Soft X-ray Range}, + institution = {e2v}, + month={March}, + year={2017}, + url={https://oro.open.ac.uk/49003/1/Moody%202017%20-%20SXRQE_WhitePaper.pdf}, +} diff --git a/optika/sensors/_materials/__init__.py b/optika/sensors/_materials/__init__.py index 450726c..c23dfa3 100644 --- a/optika/sensors/_materials/__init__.py +++ b/optika/sensors/_materials/__init__.py @@ -1 +1,2 @@ from ._materials import * +from ._e2v_ccd97 import * diff --git a/optika/sensors/_materials/_e2v_ccd97/__init__.py b/optika/sensors/_materials/_e2v_ccd97/__init__.py new file mode 100644 index 0000000..74fcfdc --- /dev/null +++ b/optika/sensors/_materials/_e2v_ccd97/__init__.py @@ -0,0 +1 @@ +from ._e2v_ccd97 import * diff --git a/optika/sensors/_materials/_e2v_ccd97/_e2v_ccd97.py b/optika/sensors/_materials/_e2v_ccd97/_e2v_ccd97.py new file mode 100644 index 0000000..c345ca1 --- /dev/null +++ b/optika/sensors/_materials/_e2v_ccd97/_e2v_ccd97.py @@ -0,0 +1,184 @@ +import functools +import pathlib +import numpy as np +import scipy.optimize +import astropy.units as u +import named_arrays as na +from .._materials import quantum_efficiency_effective +from .._materials import AbstractBackilluminatedCCDMaterial + +__all__ = [ + "E2VCCD97Material", +] + + +class E2VCCD97Material( + AbstractBackilluminatedCCDMaterial, +): + """ + A model of the light-sensitive material of an e2v CCD90 sensor based on + measurements by :cite:t:`Moody2017`. + + Examples + -------- + + Plot the measured E2VCCD97 quantum efficiency vs the fitted + quantum efficiency calculated using the method of :cite:t:`Stern1994`. + + .. jupyter-execute:: + + import matplotlib.pyplot as plt + import astropy.units as u + import astropy.visualization + import named_arrays as na + import optika + + # Create a new instance of the e2v CCD97 light-sensitive material + material_ccd97 = optika.sensors.E2VCCD97Material() + + # Store the wavelengths at which the QE was measured + wavelength_measured = material_ccd97.quantum_efficiency_measured.inputs + + # Store the QE measurements + qe_measured = material_ccd97.quantum_efficiency_measured.outputs + + # Define a grid of wavelengths with which to evaluate the fitted QE + wavelength_fit = na.geomspace(5, 10000, axis="wavelength", num=1001) * u.AA + + # Evaluate the fitted QE using the given wavelengths + qe_fit = material_ccd97.quantum_efficiency_effective( + rays=optika.rays.RayVectorArray( + wavelength=wavelength_fit, + direction=na.Cartesian3dVectorArray(0, 0, 1), + ), + normal=na.Cartesian3dVectorArray(0, 0, -1), + ) + + # Plot the measured QE vs the fitted QE + with astropy.visualization.quantity_support(): + fig, ax = plt.subplots(constrained_layout=True) + na.plt.scatter( + wavelength_measured, + qe_measured, + label="measured", + ) + na.plt.plot( + wavelength_fit, + qe_fit, + label="fit", + ) + ax.set_xscale("log") + ax.set_xlabel(f"wavelength ({wavelength_fit.unit:latex_inline})") + ax.set_ylabel("quantum efficiency") + ax.legend() + + The thickness of the oxide layer found by the fit is + + .. jupyter-execute:: + + material_ccd97.thickness_oxide + + The thickness of the implant layer found by the fit is + + .. jupyter-execute:: + + material_ccd97.thickness_implant + + The thickness of the substrate found by the fit is + + .. jupyter-execute:: + + material_ccd97.thickness_substrate + + And the differential charge collection efficiency at the backsurface + found by the fit is + + .. jupyter-execute:: + + material_ccd97.cce_backsurface + """ + + @property + def quantum_efficiency_measured(self) -> na.FunctionArray: + directory = pathlib.Path(__file__).parent + energy, qe = np.genfromtxt( + fname=directory / "e2v_ccd97_qe_moody2017.csv", + delimiter=", ", + unpack=True, + ) + energy = energy << u.eV + wavelength = energy.to(u.AA, equivalencies=u.spectral()) + return na.FunctionArray( + inputs=na.ScalarArray(wavelength, axes="wavelength"), + outputs=na.ScalarArray(qe, axes="wavelength"), + ) + + @functools.cached_property + def _quantum_efficiency_fit(self) -> dict[str, float | u.Quantity]: + qe_measured = self.quantum_efficiency_measured + + unit_thickness_oxide = u.AA + unit_thickness_implant = u.AA + unit_thickness_substrate = u.um + + def eqe_rms_difference(x: tuple[float, float, float, float]): + ( + thickness_oxide, + thickness_implant, + thickness_substrate, + cce_backsurface, + ) = x + qe_fit = quantum_efficiency_effective( + wavelength=qe_measured.inputs, + direction=na.Cartesian3dVectorArray(0, 0, 1), + thickness_oxide=thickness_oxide << unit_thickness_oxide, + thickness_implant=thickness_implant << unit_thickness_implant, + thickness_substrate=thickness_substrate << unit_thickness_substrate, + cce_backsurface=cce_backsurface, + ) + + return np.sqrt(np.mean(np.square(qe_measured.outputs - qe_fit))).ndarray + + thickness_oxide_guess = 50 * u.AA + thickness_implant_guess = 2317 * u.AA + thickness_substrate_guess = 7 * u.um + cce_backsurface_guess = 0.21 + + 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), + thickness_substrate_guess.to_value(unit_thickness_substrate), + cce_backsurface_guess, + ], + method="nelder-mead", + ) + + thickness_oxide, thickness_implant, thickness_substrate, cce_backsurface = fit.x + thickness_oxide = thickness_oxide << unit_thickness_oxide + thickness_implant = thickness_implant << unit_thickness_implant + thickness_substrate = thickness_substrate << unit_thickness_substrate + + return dict( + thickness_oxide=thickness_oxide, + thickness_implant=thickness_implant, + thickness_substrate=thickness_substrate, + cce_backsurface=cce_backsurface, + ) + + @property + def thickness_oxide(self) -> u.Quantity: + return self._quantum_efficiency_fit["thickness_oxide"] + + @property + def thickness_implant(self) -> u.Quantity: + return self._quantum_efficiency_fit["thickness_implant"] + + @property + def thickness_substrate(self) -> u.Quantity: + return self._quantum_efficiency_fit["thickness_substrate"] + + @property + def cce_backsurface(self) -> float: + return self._quantum_efficiency_fit["cce_backsurface"] diff --git a/optika/sensors/_materials/_e2v_ccd97/_e2v_ccd97_test.py b/optika/sensors/_materials/_e2v_ccd97/_e2v_ccd97_test.py new file mode 100644 index 0000000..64e7d37 --- /dev/null +++ b/optika/sensors/_materials/_e2v_ccd97/_e2v_ccd97_test.py @@ -0,0 +1,15 @@ +import pytest +import optika +from .._materials_test import AbstractTestAbstractBackilluminatedCCDMaterial + + +@pytest.mark.parametrize( + argnames="a", + argvalues=[ + optika.sensors.E2VCCD97Material(), + ], +) +class TestE2VCCD97Material( + AbstractTestAbstractBackilluminatedCCDMaterial, +): + pass diff --git a/optika/sensors/_materials/_e2v_ccd97/e2v_ccd97_qe_moody2017.csv b/optika/sensors/_materials/_e2v_ccd97/e2v_ccd97_qe_moody2017.csv new file mode 100644 index 0000000..ef07009 --- /dev/null +++ b/optika/sensors/_materials/_e2v_ccd97/e2v_ccd97_qe_moody2017.csv @@ -0,0 +1,33 @@ +39.91891990062515, 0.6788079470198677 +50.05075223132972, 0.7324503311258279 +59.97856496789139, 0.8178807947019869 +64.77313122091705, 0.8258278145695366 +69.95096548922383, 0.8695364238410598 +74.86231645365851, 0.8059602649006624 +79.75688300572139, 0.8317880794701988 +89.71162491843556, 0.851655629139073 +97.76351712475721, 0.8576158940397353 +109.96573146388018, 0.5317880794701989 +130.00166645736314, 0.45033112582781476 +149.57280983041403, 0.4860927152317882 +199.79719692884856, 0.5635761589403975 +249.37710648876907, 0.6529801324503313 +309.8554473373301, 0.7225165562913909 +399.1891990062509, 0.8357615894039737 +500.50752231329665, 0.9052980132450333 +547.9025729451029, 0.9211920529801326 +568.0943005681785, 0.9271523178807949 +599.7856496789133, 0.9291390728476823 +647.7313122091703, 0.960927152317881 +748.6231645365851, 0.9807947019867551 +901.183765633706, 0.9986754966887419 +1198.354915639953, 0.9907284768211922 +1397.6006827213541, 0.8894039735099339 +1593.5201660325251, 0.7741721854304637 +1697.7059683717916, 0.6907284768211922 +1800.5399029139885, 0.6569536423841061 +1825.142004033248, 0.7463576158940399 +1833.4171803334832, 0.8735099337748347 +1833.4171803334832, 0.9172185430463577 +1900.9836823956464, 0.947019867549669 +1850.0802617566994, 0.9748344370860929 diff --git a/optika/sensors/_materials/_materials.py b/optika/sensors/_materials/_materials.py index b15c11d..d8db082 100644 --- a/optika/sensors/_materials/_materials.py +++ b/optika/sensors/_materials/_materials.py @@ -1,3 +1,6 @@ +import abc +import functools +import dataclasses import numpy as np import astropy.units as u import named_arrays as na @@ -8,6 +11,9 @@ "energy_electron_hole", "quantum_yield_ideal", "quantum_efficiency_effective", + "AbstractImagingSensorMaterial", + "AbstractCCDMaterial", + "AbstractBackilluminatedCCDMaterial", ] energy_bandgap = 1.12 * u.eV @@ -381,3 +387,150 @@ def quantum_efficiency_effective( result = transmissivity * (term_1 + term_2 + term_3) return result + + +@dataclasses.dataclass(eq=False, repr=False) +class AbstractImagingSensorMaterial( + optika.materials.AbstractMaterial, +): + """ + An interface representing the light-sensitive material of an imaging sensor. + """ + + +@dataclasses.dataclass(eq=False, repr=False) +class AbstractCCDMaterial( + AbstractImagingSensorMaterial, +): + """ + An interface representing the light-sensitive material of a CCD sensor. + """ + + @property + def transformation(self) -> None: + return None + + @functools.cached_property + def _chemical(self) -> optika.chemicals.Chemical: + return optika.chemicals.Chemical("Si") + + def index_refraction( + self, + rays: optika.rays.AbstractRayVectorArray, + ) -> na.ScalarLike: + index_refraction = self._chemical.index_refraction + return na.interp( + x=rays.wavelength, + xp=index_refraction.inputs, + fp=index_refraction.outputs, + ) + + def attenuation( + self, + rays: optika.rays.AbstractRayVectorArray, + ) -> na.ScalarLike: + wavenumber = self._chemical.wavenumber + return na.interp( + x=rays.wavelength, + xp=wavenumber.inputs, + fp=4 * np.pi * wavenumber.outputs / wavenumber.inputs, + ) + + @property + def is_mirror(self) -> bool: + return False + + +@dataclasses.dataclass(eq=False, repr=False) +class AbstractBackilluminatedCCDMaterial( + AbstractCCDMaterial, +): + """ + An interface representing the light-sensitive material of a backilluminated + CCD sensor. + """ + + @property + @abc.abstractmethod + def thickness_oxide(self) -> u.Quantity | na.AbstractScalar: + """ + The thickness of the oxide layer on the back surface of the CCD sensor. + """ + + @property + @abc.abstractmethod + def thickness_implant(self) -> u.Quantity | na.AbstractScalar: + """ + The thickness of the implant layer of the CCD sensor. + """ + + @property + @abc.abstractmethod + def thickness_substrate(self) -> u.Quantity | na.AbstractScalar: + """the thickness of the entire CCD silicon substrate""" + + @property + @abc.abstractmethod + def cce_backsurface(self) -> float | na.AbstractScalar: + """ + The charge collection efficiency on the backsurface of the CCD sensor. + """ + + 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) + + def quantum_efficiency_effective( + self, + rays: optika.rays.AbstractRayVectorArray, + normal: na.AbstractCartesian3dVectorArray, + ) -> na.AbstractScalar: + """ + Compute the effective quantum efficiency of this CCD material using + :func:`optika.sensors.quantum_efficiency_effective`. + + Parameters + ---------- + rays + The light rays incident on the CCD surface + normal + The vector perpendicular to the surface of the CCD. + """ + k_ambient = rays.attenuation * rays.wavelength / (4 * np.pi) + n_ambient = rays.index_refraction + k_ambient * 1j + + k_substrate = self.attenuation(rays) * rays.wavelength / (4 * np.pi) + n_substrate = self.index_refraction(rays) + k_substrate * 1j + + return quantum_efficiency_effective( + wavelength=rays.wavelength, + direction=rays.direction, + thickness_oxide=self.thickness_oxide, + thickness_implant=self.thickness_implant, + thickness_substrate=self.thickness_substrate, + cce_backsurface=self.cce_backsurface, + n_ambient=n_ambient, + n_substrate=n_substrate, + normal=normal, + ) + + def transmissivity( + self, + rays: optika.rays.AbstractRayVectorArray, + normal: na.AbstractCartesian3dVectorArray, + ) -> na.ScalarLike: + return self.quantum_efficiency_effective( + rays=rays, + normal=normal, + ) diff --git a/optika/sensors/_materials/_materials_test.py b/optika/sensors/_materials/_materials_test.py index 12fc2ba..f3bc526 100644 --- a/optika/sensors/_materials/_materials_test.py +++ b/optika/sensors/_materials/_materials_test.py @@ -3,6 +3,7 @@ import astropy.units as u import named_arrays as na import optika +from optika.materials._tests.test_materials import AbstractTestAbstractMaterial @pytest.mark.parametrize( @@ -76,3 +77,61 @@ def test_quantum_efficiency_effective( ) assert np.all(result >= 0) assert np.all(result <= 1) + + +class AbstractTestAbstractImagingSensorMaterial( + AbstractTestAbstractMaterial, +): + pass + + +class AbstractTestAbstractCCDMaterial( + AbstractTestAbstractImagingSensorMaterial, +): + pass + + +class AbstractTestAbstractBackilluminatedCCDMaterial( + AbstractTestAbstractCCDMaterial, +): + def test_thickness_oxide( + self, + a: optika.sensors.AbstractBackilluminatedCCDMaterial, + ): + result = a.thickness_oxide + assert result >= 0 * u.mm + + def test_thickness_implant( + self, + a: optika.sensors.AbstractBackilluminatedCCDMaterial, + ): + result = a.thickness_implant + assert result >= 0 * u.mm + + def test_thickness_substrate( + self, + a: optika.sensors.AbstractBackilluminatedCCDMaterial, + ): + result = a.thickness_substrate + assert result >= 0 * u.mm + + def test_cce_backsurface( + self, + a: optika.sensors.AbstractBackilluminatedCCDMaterial, + ): + result = a.cce_backsurface + assert result >= 0 + + @pytest.mark.parametrize( + argnames="wavelength", + argvalues=[ + 500 * u.nm, + ], + ) + def test_quantum_yield_ideal( + self, + a: optika.sensors.AbstractBackilluminatedCCDMaterial, + wavelength: u.Quantity | na.AbstractScalar, + ): + result = a.quantum_yield_ideal(wavelength) + assert result >= 0 diff --git a/optika/sensors/_tests/test_sensors.py b/optika/sensors/_tests/test_sensors.py index 771a183..5d7544c 100644 --- a/optika/sensors/_tests/test_sensors.py +++ b/optika/sensors/_tests/test_sensors.py @@ -3,6 +3,3 @@ import astropy.units as u import named_arrays as na import optika - - -