From 118f0b404c142b269f5ea4e2e11404ecb60a65a2 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Sun, 14 Jan 2024 22:21:57 -0700 Subject: [PATCH 1/4] Added `optika.sensors.E2VCCD97Material` class. --- docs/refs.bib | 8 + optika/sensors/_materials/__init__.py | 1 + .../sensors/_materials/_e2v_ccd97/__init__.py | 1 + .../_materials/_e2v_ccd97/_e2v_ccd97.py | 160 ++++++++++++++++++ .../_materials/_e2v_ccd97/_e2v_ccd97_test.py | 15 ++ .../_e2v_ccd97/e2v_ccd97_qe_moody2017.csv | 33 ++++ optika/sensors/_materials/_materials.py | 156 +++++++++++++++++ optika/sensors/_materials/_materials_test.py | 59 +++++++ optika/sensors/_tests/test_sensors.py | 3 - 9 files changed, 433 insertions(+), 3 deletions(-) create mode 100644 optika/sensors/_materials/_e2v_ccd97/__init__.py create mode 100644 optika/sensors/_materials/_e2v_ccd97/_e2v_ccd97.py create mode 100644 optika/sensors/_materials/_e2v_ccd97/_e2v_ccd97_test.py create mode 100644 optika/sensors/_materials/_e2v_ccd97/e2v_ccd97_qe_moody2017.csv 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..5689c98 --- /dev/null +++ b/optika/sensors/_materials/_e2v_ccd97/_e2v_ccd97.py @@ -0,0 +1,160 @@ +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. + + .. 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") + """ + + @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..87faaf1 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,153 @@ 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 - - - From ee73dd6c8cfba1dbd0e3bdd80334f875f873d7a7 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Sun, 14 Jan 2024 22:23:57 -0700 Subject: [PATCH 2/4] Fixing Black errors. --- optika/sensors/_materials/_e2v_ccd97/_e2v_ccd97.py | 2 -- optika/sensors/_materials/_materials.py | 3 --- 2 files changed, 5 deletions(-) diff --git a/optika/sensors/_materials/_e2v_ccd97/_e2v_ccd97.py b/optika/sensors/_materials/_e2v_ccd97/_e2v_ccd97.py index 5689c98..c7534c4 100644 --- a/optika/sensors/_materials/_e2v_ccd97/_e2v_ccd97.py +++ b/optika/sensors/_materials/_e2v_ccd97/_e2v_ccd97.py @@ -156,5 +156,3 @@ def thickness_substrate(self) -> u.Quantity: @property def cce_backsurface(self) -> float: return self._quantum_efficiency_fit["cce_backsurface"] - - diff --git a/optika/sensors/_materials/_materials.py b/optika/sensors/_materials/_materials.py index 87faaf1..d8db082 100644 --- a/optika/sensors/_materials/_materials.py +++ b/optika/sensors/_materials/_materials.py @@ -534,6 +534,3 @@ def transmissivity( rays=rays, normal=normal, ) - - - From d4ccae7c54f5c7b214163fefe5f749a4423a6737 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Sun, 14 Jan 2024 22:24:56 -0700 Subject: [PATCH 3/4] Slight tweak to the documentation of the `optika.sensors.E2VCCD97Material` class. --- optika/sensors/_materials/_e2v_ccd97/_e2v_ccd97.py | 1 + 1 file changed, 1 insertion(+) diff --git a/optika/sensors/_materials/_e2v_ccd97/_e2v_ccd97.py b/optika/sensors/_materials/_e2v_ccd97/_e2v_ccd97.py index c7534c4..d7983d3 100644 --- a/optika/sensors/_materials/_e2v_ccd97/_e2v_ccd97.py +++ b/optika/sensors/_materials/_e2v_ccd97/_e2v_ccd97.py @@ -70,6 +70,7 @@ class E2VCCD97Material( ax.set_xscale("log") ax.set_xlabel(f"wavelength ({wavelength_fit.unit:latex_inline})") ax.set_ylabel("quantum efficiency") + ax.legend() """ @property From 06d688c9b6ff210d73bc1fe8810962f747c9cc96 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Sun, 14 Jan 2024 22:38:36 -0700 Subject: [PATCH 4/4] Improvements to the documentation of the `optika.sensors.E2VCCD97Material` class. --- .../_materials/_e2v_ccd97/_e2v_ccd97.py | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/optika/sensors/_materials/_e2v_ccd97/_e2v_ccd97.py b/optika/sensors/_materials/_e2v_ccd97/_e2v_ccd97.py index d7983d3..c345ca1 100644 --- a/optika/sensors/_materials/_e2v_ccd97/_e2v_ccd97.py +++ b/optika/sensors/_materials/_e2v_ccd97/_e2v_ccd97.py @@ -23,7 +23,7 @@ class E2VCCD97Material( -------- Plot the measured E2VCCD97 quantum efficiency vs the fitted - quantum efficiency. + quantum efficiency calculated using the method of :cite:t:`Stern1994`. .. jupyter-execute:: @@ -71,6 +71,31 @@ class E2VCCD97Material( 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