From 8a2cfc8764493ae217b625c52d6e994ed2337e1d Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Mon, 1 Jun 2026 13:06:44 -0600 Subject: [PATCH 1/3] Added `optika.sensors.transmittance()`, a function that computes the fraction of light transmitted into the light-sensitive region of an imaging sensor. --- optika/sensors/__init__.py | 2 + optika/sensors/materials/_materials.py | 115 +++++++++++++++++++- optika/sensors/materials/_materials_test.py | 51 +++++++++ 3 files changed, 165 insertions(+), 3 deletions(-) diff --git a/optika/sensors/__init__.py b/optika/sensors/__init__.py index 1db9a1ab..42f8d154 100644 --- a/optika/sensors/__init__.py +++ b/optika/sensors/__init__.py @@ -15,6 +15,7 @@ quantum_yield_ideal, fano_factor, fano_factor_inf, + transmittance, absorbance, charge_collection_efficiency, quantum_efficiency_effective, @@ -40,6 +41,7 @@ "quantum_yield_ideal", "fano_factor", "fano_factor_inf", + "transmittance", "absorbance", "charge_collection_efficiency", "quantum_efficiency_effective", diff --git a/optika/sensors/materials/_materials.py b/optika/sensors/materials/_materials.py index 3e1d13ae..58e38582 100644 --- a/optika/sensors/materials/_materials.py +++ b/optika/sensors/materials/_materials.py @@ -49,6 +49,110 @@ ] +def transmittance( + wavelength: u.Quantity | na.AbstractScalar, + direction: float | na.AbstractScalar = 1, + n: float | 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 component of the incident light's propagation direction antiparallel + to the surface normal of the sensor. + Default is normal incidence. + n + The 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 absorbance 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 absorbance vs wavelength + absorbance = optika.sensors.absorbance( + wavelength=wavelength, + ) + + # Plot the average absorbance vs. wavelength + fig, ax = plt.subplots(constrained_layout=True) + na.plt.plot( + wavelength, + absorbance.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 + def absorbance( wavelength: u.Quantity | na.AbstractScalar, direction: float | na.AbstractScalar = 1, @@ -111,7 +215,7 @@ def absorbance( wavelength=wavelength, ) - # Plot the effective and maximum quantum efficiency + # Plot the average absorbance vs. wavelength fig, ax = plt.subplots(constrained_layout=True) na.plt.plot( wavelength, @@ -1581,7 +1685,7 @@ def signal( electrons = signal( photons_expected=intensity, wavelength=wavelength, - absorbance=self.absorbance(rays, normal).average, + absorbance=1, absorption=self._chemical.absorption(wavelength), thickness_implant=self.thickness_implant, cce_backsurface=self.cce_backsurface, @@ -1652,7 +1756,12 @@ def efficiency( rays: optika.rays.AbstractRayVectorArray, normal: na.AbstractCartesian3dVectorArray, ) -> na.ScalarLike: - return 1 + result = self.absorbance( + rays=rays, + normal=normal, + ) + + return result.average @dataclasses.dataclass(eq=False, repr=False) diff --git a/optika/sensors/materials/_materials_test.py b/optika/sensors/materials/_materials_test.py index 61e5a18c..1f662c2c 100644 --- a/optika/sensors/materials/_materials_test.py +++ b/optika/sensors/materials/_materials_test.py @@ -6,6 +6,57 @@ from optika.materials._tests.test_materials import AbstractTestAbstractMaterial +@pytest.mark.parametrize( + argnames="wavelength", + argvalues=[ + 304 * u.AA, + na.linspace(100, 200, axis="wavelength", num=4) * u.AA, + ], +) +@pytest.mark.parametrize( + argnames="direction", + argvalues=[ + 1, + 0.5, + ], +) +@pytest.mark.parametrize( + argnames="n", + argvalues=[ + 1, + ], +) +@pytest.mark.parametrize( + argnames="thickness_oxide", + argvalues=[ + 10 * u.AA, + ], +) +@pytest.mark.parametrize( + argnames="thickness_substrate", + argvalues=[ + 10 * u.um, + ], +) +def test_transmittance( + wavelength: u.Quantity | na.AbstractScalar, + direction: float | na.AbstractScalar, + n: float | na.AbstractScalar, + thickness_oxide: u.Quantity | na.AbstractScalar, + thickness_substrate: u.Quantity | na.AbstractScalar, +): + result = optika.sensors.transmittance( + wavelength=wavelength, + direction=direction, + n=n, + thickness_oxide=thickness_oxide, + thickness_substrate=thickness_substrate, + ) + + assert np.all(result >= 0) + assert np.all(result <= 1) + + @pytest.mark.parametrize( argnames="wavelength", argvalues=[ From e2fa229786bf22b5a2ca39a3e31e1636f0c51a2a Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Mon, 1 Jun 2026 13:43:28 -0600 Subject: [PATCH 2/3] black --- optika/sensors/materials/_materials.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/optika/sensors/materials/_materials.py b/optika/sensors/materials/_materials.py index 58e38582..c9605339 100644 --- a/optika/sensors/materials/_materials.py +++ b/optika/sensors/materials/_materials.py @@ -122,13 +122,13 @@ def transmittance( 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, @@ -150,9 +150,10 @@ def transmittance( ), ), ) - + return transmission + def absorbance( wavelength: u.Quantity | na.AbstractScalar, direction: float | na.AbstractScalar = 1, From 510328c68f4bac6bfe50c4bf7f046b19cdc5b1dc Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Mon, 1 Jun 2026 13:46:51 -0600 Subject: [PATCH 3/3] docs --- optika/sensors/materials/_materials.py | 37 +++++++++++++++----------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/optika/sensors/materials/_materials.py b/optika/sensors/materials/_materials.py index c9605339..38606333 100644 --- a/optika/sensors/materials/_materials.py +++ b/optika/sensors/materials/_materials.py @@ -94,7 +94,7 @@ def transmittance( Examples -------- - Plot the absorbance as a function of wavelength. + Plot the transmittance as a function of wavelength. .. jupyter-execute:: @@ -106,16 +106,16 @@ def transmittance( # Define a grid of wavelengths wavelength = na.geomspace(10, 10000, axis="wavelength", num=1001) * u.AA - # Compute the absorbance vs wavelength - absorbance = optika.sensors.absorbance( + # Compute the transmittance vs wavelength + transmittance = optika.sensors.transmittance( wavelength=wavelength, ) - # Plot the average absorbance vs. wavelength + # Plot the average transmittance vs. wavelength fig, ax = plt.subplots(constrained_layout=True) na.plt.plot( wavelength, - absorbance.average, + transmittance.average, ax=ax, ); ax.set_xscale("log"); @@ -199,7 +199,8 @@ def absorbance( Examples -------- - Plot the absorbance as a function of wavelength. + Plot the absorbance as a function of wavelength and compare it to the + transmittance. .. jupyter-execute:: @@ -211,6 +212,11 @@ def absorbance( # 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 = optika.sensors.absorbance( wavelength=wavelength, @@ -218,14 +224,22 @@ def absorbance( # 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.average, ax=ax, + label="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) @@ -698,7 +712,6 @@ def _discrete_gamma( vmr: float | na.ScalarArray, shape_random: None | dict[str, int] = None, ) -> na.ScalarArray: - x = na.random.gamma( shape=mean / vmr, scale=vmr, @@ -1211,19 +1224,16 @@ def vmr_signal( result = 0 if shot: - F_shot = n * cce result = result + F_shot if fano: - F_fano = cce * F result = result + F_fano if pcc: - n0 = cce_backsurface aW = (absorption * thickness_implant).to(u.dimensionless_unscaled).value F_cce = 2 * np.exp(-aW) * np.square((n0 - 1) / aW) * (np.sinh(aW) - aW) / cce @@ -1253,13 +1263,13 @@ def signal( noise: bool = True, ) -> optika.rays.RayVectorArray: """ - Given a set of incident rays, compute the number of electrons + Given a set of absorbed rays, compute the number of electrons measured by the sensor using :func:`signal`. Parameters ---------- rays - The rays incident on the sensor surface. + The rays absorbed by the light-sensitive silicon layer. The :attr:`optika.rays.RayVectorArray.intensity` field should either be in units of photons or energy. normal @@ -1328,7 +1338,6 @@ def signal( normal: na.AbstractCartesian3dVectorArray, noise: bool = False, ) -> optika.rays.RayVectorArray: - intensity = rays.intensity if not intensity.unit.is_equivalent(u.photon): h = astropy.constants.h @@ -1669,7 +1678,6 @@ def signal( normal: na.AbstractCartesian3dVectorArray, noise: bool = True, ) -> optika.rays.RayVectorArray: - intensity = rays.intensity wavelength = rays.wavelength @@ -1736,7 +1744,6 @@ def charge_diffusion( rays: optika.rays.RayVectorArray, normal: na.AbstractCartesian3dVectorArray, ) -> optika.rays.RayVectorArray: - width = self.width_charge_diffusion(rays, normal) position = dataclasses.replace(