From 7d0bb500239d9b3138b66407162eee38c0fe3dad Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Thu, 23 Jul 2026 06:23:36 -0600 Subject: [PATCH 1/3] Add `photons_absorbed`, the inverse of the sensor `signal`/`expose` Add `photons_absorbed` to the sensor materials and to `ImagingSensor`, mapping measured electrons back into the number of absorbed photons. For the materials this inverts the noiseless `signal` (which uses unit absorbance, since absorbance is folded into the effective area) and so depends only on the quantum yield and the charge collection efficiency, not the absorbance. `IdealSensorMaterial` and the silicon materials each supply a concrete implementation, and a new `test_photons_absorbed` checks that it recovers the photons passed through `signal`. `ImagingSensor.photons_absorbed` mirrors `expose`: it inverts the material response and divides by the exposure time, giving the incident photon rate. This provides the detector-response inverse needed to make `LinearSystem.image`/`backproject` a matched pair. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CtdKmedevWkDab6BWupXqQ --- optika/sensors/_sensors.py | 63 ++++++++++++++++++++ optika/sensors/materials/_materials.py | 66 +++++++++++++++++++++ optika/sensors/materials/_materials_test.py | 48 +++++++++++++++ 3 files changed, 177 insertions(+) diff --git a/optika/sensors/_sensors.py b/optika/sensors/_sensors.py index 33951fb0..aef5c072 100644 --- a/optika/sensors/_sensors.py +++ b/optika/sensors/_sensors.py @@ -235,6 +235,69 @@ def expose( return dataclasses.replace(image, outputs=electrons) + def photons_absorbed( + self, + image: na.FunctionArray[ + na.SpectralPositionalVectorArray, + na.AbstractScalar, + ], + direction: float | na.AbstractScalar = 1, + axis_wavelength: None | str = None, + timedelta: None | u.Quantity | na.AbstractScalar = None, + ) -> na.FunctionArray[ + na.SpectralPositionalVectorArray, + na.AbstractScalar, + ]: + """ + Invert :meth:`expose`, mapping the electrons measured in each pixel back + into a photon flux absorbed by the light-sensitive region. + + The absorbance is *not* restored, since :meth:`expose` runs the + detector with an absorbance of one (the absorbance is usually accounted + for elsewhere, such as in the effective area of an optical system), so + this only divides out the quantum yield, the charge collection + efficiency, and the exposure time. It is the deterministic inverse of + :meth:`expose`; the sensor noise is not undone. + + Parameters + ---------- + image + The electrons measured in each pixel, as a function of wavelength + and pixel position. + The wavelength inputs (``image.inputs.wavelength``) must be the + bin *edges*, not the centers. + direction + The cosine of the refracted angle inside the light-sensitive region, + matching the value passed to :meth:`expose`. + axis_wavelength + The logical axis of `image` corresponding to changing wavelength. + If :obj:`None` (the default), ``image.inputs.wavelength`` must have + only one logical axis. + timedelta + The exposure time of the measurement. + If :obj:`None` (the default), the value in :attr:`timedelta_exposure` + will be used. + """ + if axis_wavelength is None: + shape_wavelength = na.shape(image.inputs.wavelength) + if len(shape_wavelength) != 1: # pragma: nocover + raise ValueError( + f"if `axis_wavelength` is `None`, `image.inputs.wavelength` " + f"must have exactly one logical axis, got {shape_wavelength}." + ) + (axis_wavelength,) = shape_wavelength + + if timedelta is None: + timedelta = self.timedelta_exposure + + photons = self.material.photons_absorbed( + electrons=image.outputs, + wavelength=image.inputs.wavelength.cell_centers(axis_wavelength), + direction=direction, + ) + + return dataclasses.replace(image, outputs=photons / timedelta) + def measure( self, rays: optika.rays.RayVectorArray, diff --git a/optika/sensors/materials/_materials.py b/optika/sensors/materials/_materials.py index 770905b8..f78ab23a 100644 --- a/optika/sensors/materials/_materials.py +++ b/optika/sensors/materials/_materials.py @@ -1513,6 +1513,33 @@ def photons_incident( The vector perpendicular to the surface of the sensor. """ + @abc.abstractmethod + def photons_absorbed( + self, + electrons: u.Quantity | na.AbstractScalar, + wavelength: u.Quantity | na.AbstractScalar, + direction: float | na.AbstractScalar = 1, + ) -> na.AbstractScalar: + """ + Given the number of electrons measured by the sensor, compute the + expected number of photons *absorbed* by the light-sensitive region. + + This is the inverse of :meth:`signal`: it divides out only the quantum + yield and the charge collection efficiency, not the absorbance. The + absorbance is deliberately excluded because it is usually accounted for + elsewhere (for example in the effective area of the optical system). + + Parameters + ---------- + electrons + The number of electrons measured by each pixel. + wavelength + The vacuum wavelength of the absorbed photons. + direction + The cosine of the refracted angle inside the light-sensitive region, + as produced by :meth:`direction_refracted`. + """ + @dataclasses.dataclass(eq=False, repr=False) class IdealSensorMaterial( @@ -1575,6 +1602,14 @@ def photons_incident( ) -> na.AbstractScalar: return electrons * u.photon / u.electron + def photons_absorbed( + self, + electrons: u.Quantity | na.AbstractScalar, + wavelength: u.Quantity | na.AbstractScalar, + direction: float | na.AbstractScalar = 1, + ) -> na.AbstractScalar: + return electrons * u.photon / u.electron + @dataclasses.dataclass(eq=False, repr=False) class AbstractSiliconSensorMaterial( @@ -2106,6 +2141,37 @@ def photons_incident( return electrons / qe + def photons_absorbed( + self, + electrons: u.Quantity | na.AbstractScalar, + wavelength: u.Quantity | na.AbstractScalar, + direction: float | na.AbstractScalar = 1, + ) -> na.AbstractScalar: + # `direction` is the cosine of the refracted angle *inside* the + # substrate (as passed to `signal`), so compute the substrate + # absorption directly from it and divide out only the quantum yield and + # charge collection efficiency, matching `signal` at ``absorbance=1``. + n_substrate = self._chemical.n(wavelength) + + absorption = absorption_effective( + wavelength=wavelength, + n_substrate=n_substrate, + direction_substrate=direction, + ) + + iqy = quantum_yield_ideal( + wavelength=wavelength, + temperature=self.temperature, + ) + + cce = charge_collection_efficiency( + absorption=absorption, + thickness_implant=self.thickness_implant, + cce_backsurface=self.cce_backsurface, + ) + + return electrons / (iqy * cce) + def efficiency( self, rays: optika.rays.AbstractRayVectorArray, diff --git a/optika/sensors/materials/_materials_test.py b/optika/sensors/materials/_materials_test.py index 4367359f..18309f6d 100644 --- a/optika/sensors/materials/_materials_test.py +++ b/optika/sensors/materials/_materials_test.py @@ -417,6 +417,54 @@ def test_photons_incident( assert isinstance(na.as_named_array(result), na.AbstractScalar) assert result.unit.is_equivalent(u.photon) + @pytest.mark.parametrize( + argnames="photons", + argvalues=[ + 100 * u.photon, + ], + ) + @pytest.mark.parametrize( + argnames="wavelength", + argvalues=[ + 100 * u.AA, + ], + ) + @pytest.mark.parametrize( + argnames="direction", + argvalues=[ + 1, + 0.5, + ], + ) + def test_photons_absorbed( + self, + a: optika.sensors.materials.AbstractSensorMaterial, + photons: u.Quantity | na.AbstractScalar, + wavelength: u.Quantity | na.AbstractScalar, + direction: float | na.AbstractScalar, + ): + # `photons_absorbed` inverts the noiseless `signal` (which uses unit + # absorbance), recovering the number of absorbed photons. + electrons = a.signal( + photons=photons, + wavelength=wavelength, + direction=direction, + noise=False, + ) + result = a.photons_absorbed( + electrons=electrons, + wavelength=wavelength, + direction=direction, + ) + assert isinstance(na.as_named_array(result), na.AbstractScalar) + assert result.unit.is_equivalent(u.photon) + assert np.allclose( + na.as_named_array(result / photons).ndarray.to_value( + u.dimensionless_unscaled + ), + 1, + ) + @pytest.mark.parametrize( argnames="wavelength", argvalues=[ From bc81eb6e26e270ae0ce2fcdb5b8781e23ef5b601 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Thu, 23 Jul 2026 08:00:19 -0600 Subject: [PATCH 2/3] Test `ImagingSensor.photons_absorbed` inverts `expose` Add a roundtrip test covering `ImagingSensor.photons_absorbed`, which was otherwise unexercised, restoring patch coverage on the PR. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CtdKmedevWkDab6BWupXqQ --- optika/sensors/_sensors_test.py | 38 +++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/optika/sensors/_sensors_test.py b/optika/sensors/_sensors_test.py index 6cd105fe..2785585c 100644 --- a/optika/sensors/_sensors_test.py +++ b/optika/sensors/_sensors_test.py @@ -88,6 +88,44 @@ def test_measure( assert a.axis_pixel.x in result_lines.outputs.shape assert a.axis_pixel.y in result_lines.outputs.shape + def test_photons_absorbed(self, a: optika.sensors.AbstractImagingSensor): + # a photon rate incident on a few pixels, as a function of the + # wavelength bin edges + wavelength = na.linspace(500, 600, axis="wavelength", num=4) * u.nm + position = na.Cartesian2dVectorArray( + x=na.arange(0, 5, axis=a.axis_pixel.x) * u.pix, + y=na.arange(0, 5, axis=a.axis_pixel.y) * u.pix, + ) + rate = ( + na.random.uniform( + low=0, + high=100, + shape_random={"wavelength": 3, a.axis_pixel.x: 5, a.axis_pixel.y: 5}, + ) + * u.photon + / u.s + ) + image = na.FunctionArray( + inputs=na.SpectralPositionalVectorArray( + wavelength=wavelength, + position=position, + ), + outputs=rate, + ) + + # `photons_absorbed` is the deterministic inverse of `expose` + timedelta = 10 * u.s + electrons = a.expose(image, timedelta=timedelta, noise=False) + result = a.photons_absorbed(electrons, timedelta=timedelta) + + assert isinstance(result, na.FunctionArray) + assert isinstance(result.inputs, na.SpectralPositionalVectorArray) + assert result.outputs.unit.is_equivalent(u.photon / u.s) + assert np.allclose( + result.outputs.to_value(u.photon / u.s), + rate.to_value(u.photon / u.s), + ) + @pytest.mark.parametrize( argnames="a", From 2fcd39e0c78ad26c8bee4e527dc64aab5276f359 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Thu, 23 Jul 2026 09:17:20 -0600 Subject: [PATCH 3/3] Cover the default `timedelta` branch of `photons_absorbed` Use the sensor's own exposure time (via `dataclasses.replace`) instead of passing `timedelta` explicitly, so the default branch of `ImagingSensor.photons_absorbed` is exercised for patch coverage. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CtdKmedevWkDab6BWupXqQ --- optika/sensors/_sensors_test.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/optika/sensors/_sensors_test.py b/optika/sensors/_sensors_test.py index 2785585c..6a1216fb 100644 --- a/optika/sensors/_sensors_test.py +++ b/optika/sensors/_sensors_test.py @@ -1,4 +1,5 @@ import pytest +import dataclasses import numpy as np import astropy.units as u import named_arrays as na @@ -89,6 +90,9 @@ def test_measure( assert a.axis_pixel.y in result_lines.outputs.shape def test_photons_absorbed(self, a: optika.sensors.AbstractImagingSensor): + # use a nonzero exposure time so the default `timedelta` is invertible + a = dataclasses.replace(a, timedelta_exposure=10 * u.s) + # a photon rate incident on a few pixels, as a function of the # wavelength bin edges wavelength = na.linspace(500, 600, axis="wavelength", num=4) * u.nm @@ -114,9 +118,8 @@ def test_photons_absorbed(self, a: optika.sensors.AbstractImagingSensor): ) # `photons_absorbed` is the deterministic inverse of `expose` - timedelta = 10 * u.s - electrons = a.expose(image, timedelta=timedelta, noise=False) - result = a.photons_absorbed(electrons, timedelta=timedelta) + electrons = a.expose(image, noise=False) + result = a.photons_absorbed(electrons) assert isinstance(result, na.FunctionArray) assert isinstance(result.inputs, na.SpectralPositionalVectorArray)