Skip to content

Commit 3010b30

Browse files
jacobdparkerclaude
andcommitted
Add beam-footprint utilities and point-spread-function metrics
- SequentialSystem.footprint(): the positions where a dense grid of rays traced through the entire system intersects a given surface, in local surface coordinates, with a mask selecting rays that reach the sensor. - optika.apertures.footprint_aperture(): a PolygonalAperture from the convex hull of a footprint, for building the effective pupil of one channel of a multi-channel instrument. - SequentialSystem.wavefield(bound="footprint"): automatic per-surface sampling regions from the beam footprint, padded by a relative fraction and an absolute minimum (which handles intermediate-focus surfaces). - optika.wavefields.encircled_energy_radius / ensquared_energy / fwhm: image-quality metrics of sampled point-spread functions, validated against an analytic Gaussian. - jupyter-execute example on SequentialSystem.psf demonstrating the footprint-bounded Huygens PSF of a parabolic telescope. Validated end-to-end on the ESIS flight-1 design: the channel pupil built from footprint() + footprint_aperture() with bound="footprint" matches the geometric image centroid to ~0.1 um and reproduces the expected diffraction-limited EE50. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 60267ad commit 3010b30

8 files changed

Lines changed: 667 additions & 6 deletions

File tree

optika/apertures/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
OctagonalAperture,
1717
AbstractIsoscelesTrapezoidalAperture,
1818
IsoscelesTrapezoidalAperture,
19+
footprint_aperture,
1920
)
2021

2122
__all__ = [
@@ -32,4 +33,5 @@
3233
"OctagonalAperture",
3334
"AbstractIsoscelesTrapezoidalAperture",
3435
"IsoscelesTrapezoidalAperture",
36+
"footprint_aperture",
3537
]

optika/apertures/_apertures.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import dataclasses
33
import numpy as np
44
import numpy.typing as npt
5+
import scipy.spatial
56
import matplotlib.axes
67
import matplotlib.lines
78
import matplotlib.pyplot as plt
@@ -24,9 +25,80 @@
2425
"OctagonalAperture",
2526
"AbstractIsoscelesTrapezoidalAperture",
2627
"IsoscelesTrapezoidalAperture",
28+
"footprint_aperture",
2729
]
2830

2931

32+
def footprint_aperture(
33+
position: na.AbstractCartesian2dVectorArray | na.AbstractCartesian3dVectorArray,
34+
where: None | bool | na.AbstractScalar = None,
35+
) -> "PolygonalAperture":
36+
"""
37+
Construct a polygonal aperture from the convex hull of a beam footprint.
38+
39+
This is useful for building the effective pupil of one channel of a
40+
multi-channel instrument: trace rays through the full system (including
41+
any obscurations) with :meth:`optika.systems.SequentialSystem.footprint`,
42+
then convert the illuminated portion of a surface into an explicit
43+
aperture for a physical-optics model of that channel.
44+
45+
Parameters
46+
----------
47+
position
48+
The positions of the rays intersecting the surface,
49+
expressed in local surface coordinates.
50+
where
51+
An optional boolean mask selecting the unvignetted rays.
52+
53+
Notes
54+
-----
55+
The footprint is approximated by its convex hull, which is exact only
56+
for convex footprints.
57+
Where an edge of the hull comes from the projection of a downstream
58+
aperture (instead of a physical edge on this surface), light diffracted
59+
past that projection is clipped, which slightly underestimates the
60+
diffraction pattern of the downstream aperture.
61+
"""
62+
position = position.xy
63+
64+
if where is None:
65+
where = True
66+
where = na.broadcast_to(
67+
na.as_named_array(where),
68+
na.shape_broadcasted(position.x, position.y, where),
69+
)
70+
71+
shape = where.shape
72+
x = na.broadcast_to(position.x, shape).ndarray.ravel()
73+
y = na.broadcast_to(position.y, shape).ndarray.ravel()
74+
mask = where.ndarray.ravel()
75+
76+
x = x[mask]
77+
y = y[mask]
78+
79+
unit = na.unit(x)
80+
if unit is not None:
81+
x = x.to_value(unit)
82+
y = y.to(unit).value
83+
84+
points = np.stack([x, y], axis=~0)
85+
hull = scipy.spatial.ConvexHull(points)
86+
87+
x = x[hull.vertices]
88+
y = y[hull.vertices]
89+
z = np.zeros_like(x)
90+
if unit is not None:
91+
x, y, z = x << unit, y << unit, z << unit
92+
93+
return PolygonalAperture(
94+
vertices=na.Cartesian3dVectorArray(
95+
x=na.ScalarArray(x, axes=("vertex",)),
96+
y=na.ScalarArray(y, axes=("vertex",)),
97+
z=na.ScalarArray(z, axes=("vertex",)),
98+
),
99+
)
100+
101+
30102
@dataclasses.dataclass(eq=False, repr=False)
31103
class AbstractAperture(
32104
optika.mixins.DxfWritable,

optika/systems/_sequential.py

Lines changed: 210 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -746,6 +746,80 @@ def rayfunction_default(self) -> optika.rays.RayFunctionArray:
746746
"""
747747
return self.rayfunction()
748748

749+
def footprint(
750+
self,
751+
surface: optika.surfaces.AbstractSurface,
752+
wavelength: None | u.Quantity | na.AbstractScalar = None,
753+
field: None | na.AbstractCartesian2dVectorArray = None,
754+
num: int = 101,
755+
normalized_field: bool = True,
756+
) -> tuple[na.AbstractCartesian3dVectorArray, na.AbstractScalar]:
757+
"""
758+
The beam footprint on a given surface: the positions where a dense
759+
grid of rays traced through the entire system intersects the surface,
760+
expressed in local surface coordinates, along with a mask selecting
761+
the rays that reach the sensor unvignetted.
762+
763+
This identifies the used portion of each optic, which is useful for
764+
restricting the sampling region of a physical-optics calculation
765+
(see :meth:`wavefield`) or for constructing the effective pupil of
766+
one channel of a multi-channel instrument
767+
(see :func:`optika.apertures.footprint_aperture`).
768+
769+
Parameters
770+
----------
771+
surface
772+
The surface on which to evaluate the footprint.
773+
Must be an element of :attr:`surfaces`, :attr:`object`,
774+
or the :attr:`sensor`.
775+
wavelength
776+
The wavelengths of the rays.
777+
If :obj:`None` (the default), ``self.grid_input.wavelength``
778+
will be used.
779+
field
780+
The field positions of the rays, in either normalized or
781+
physical units.
782+
If :obj:`None` (the default), ``self.grid_input.field``
783+
will be used.
784+
num
785+
The number of rays along each axis of the pupil grid.
786+
normalized_field
787+
A boolean flag indicating whether the `field` parameter is given
788+
in normalized or physical units.
789+
"""
790+
surfaces = self.surfaces_all
791+
index = [i for i, s in enumerate(surfaces) if s is surface]
792+
if not index:
793+
raise ValueError(
794+
f"surface {surface.name!r} is not an element of this system."
795+
)
796+
index = index[0]
797+
798+
axis = "_footprint"
799+
pupil = na.Cartesian2dVectorLinearSpace(
800+
start=-1,
801+
stop=1,
802+
axis=na.Cartesian2dVectorArray(f"{axis}_x", f"{axis}_y"),
803+
num=num,
804+
centers=True,
805+
)
806+
807+
raytrace = self.raytrace(
808+
wavelength=wavelength,
809+
field=field,
810+
pupil=pupil,
811+
axis=axis,
812+
normalized_field=normalized_field,
813+
)
814+
815+
position = raytrace.outputs.position[{axis: index}]
816+
where = raytrace.outputs.unvignetted[{axis: ~0}]
817+
818+
if surface.transformation is not None:
819+
position = surface.transformation.inverse(position)
820+
821+
return position, where
822+
749823
def wavefield(
750824
self,
751825
wavelength: None | u.Quantity | na.AbstractScalar = None,
@@ -758,7 +832,10 @@ def wavefield(
758832
chunk_size: int = 1024,
759833
normalized_field: bool = True,
760834
seed: None | int = None,
761-
bound: None | Sequence = None,
835+
bound: None | str | Sequence = None,
836+
padding_relative: float = 0.1,
837+
padding_absolute: u.Quantity | na.AbstractScalar = 0 * u.mm,
838+
num_footprint: int = 101,
762839
) -> optika.wavefields.WavefieldFunctionArray:
763840
r"""
764841
Propagate a wavefield through this system using physical optics and
@@ -814,12 +891,28 @@ def wavefield(
814891
bound
815892
An optional sequence of sampling regions, one for each surface
816893
with an aperture, where each element is either :obj:`None` (use
817-
the bounding box of the surface's aperture) or a tuple of the
818-
lower and upper corners of the region in local surface
819-
coordinates.
894+
the bounding box of the surface's aperture), the string
895+
``"footprint"`` (use the bounding box of the beam footprint from
896+
a geometric raytrace, padded by `padding_relative` and
897+
`padding_absolute`), or a tuple of the lower and upper corners
898+
of the region in local surface coordinates.
899+
The string ``"footprint"`` may also be given for `bound` itself,
900+
which applies it to every surface.
820901
This is important for surfaces near an intermediate focus,
821902
where the wavefield is concentrated in a region much smaller than
822903
the aperture and would otherwise be unresolved by the samples.
904+
padding_relative
905+
The fraction of the footprint extent by which to expand a
906+
``"footprint"`` sampling region.
907+
padding_absolute
908+
The minimum distance by which to expand a ``"footprint"``
909+
sampling region.
910+
At an intermediate focus the geometric footprint collapses to a
911+
point, so this should be at least a few times the expected size
912+
of the diffraction pattern there.
913+
num_footprint
914+
The number of rays along each axis of the pupil grid used to
915+
compute the ``"footprint"`` sampling regions.
823916
824917
Notes
825918
-----
@@ -838,6 +931,11 @@ def wavefield(
838931
filters) are often better excluded from the wave propagation
839932
(by setting their aperture to :obj:`None` in a copy of the system)
840933
than included as an undersampled propagation step.
934+
The effect of an excluded mask on the pupil can be retained by
935+
replacing the aperture of the nearest powered surface with the
936+
convex hull of its illuminated footprint,
937+
using :meth:`footprint` and
938+
:func:`optika.apertures.footprint_aperture`.
841939
"""
842940
if wavelength is None:
843941
wavelength = self.grid_input.wavelength
@@ -874,13 +972,46 @@ def wavefield(
874972
f"{len(surfaces)} surfaces with an aperture, got {len(num)}."
875973
)
876974

877-
if bound is None:
878-
bound = [None] * len(surfaces)
975+
if (bound is None) or isinstance(bound, str):
976+
bound = [bound] * len(surfaces)
879977
if len(bound) != len(surfaces):
880978
raise ValueError(
881979
f"`bound` must have one element for each of the "
882980
f"{len(surfaces)} surfaces with an aperture, got {len(bound)}."
883981
)
982+
bound = list(bound)
983+
for k, surface in enumerate(surfaces):
984+
if not isinstance(bound[k], str):
985+
continue
986+
if bound[k] != "footprint":
987+
raise ValueError(
988+
f"the only string allowed in `bound` is 'footprint', "
989+
f"got {bound[k]!r}."
990+
)
991+
position_footprint, where_footprint = self.footprint(
992+
surface=surface,
993+
wavelength=grid.wavelength,
994+
field=grid.field,
995+
num=num_footprint,
996+
normalized_field=False,
997+
)
998+
position_footprint = position_footprint.xy
999+
unit = na.unit(position_footprint.x)
1000+
inf = np.inf if unit is None else np.inf * unit
1001+
axis_footprint = ("_footprint_x", "_footprint_y")
1002+
lower = na.Cartesian2dVectorArray(
1003+
x=np.where(where_footprint, position_footprint.x, +inf),
1004+
y=np.where(where_footprint, position_footprint.y, +inf),
1005+
).min(axis_footprint)
1006+
upper = na.Cartesian2dVectorArray(
1007+
x=np.where(where_footprint, position_footprint.x, -inf),
1008+
y=np.where(where_footprint, position_footprint.y, -inf),
1009+
).max(axis_footprint)
1010+
padding = np.maximum(
1011+
padding_relative * (upper - lower),
1012+
padding_absolute,
1013+
)
1014+
bound[k] = (lower - padding, upper + padding)
8841015

8851016
axes = [
8861017
(f"_{axis[0]}_{k % 2}", f"_{axis[1]}_{k % 2}") for k in range(len(surfaces))
@@ -1076,6 +1207,79 @@ def psf(
10761207
The two logical axes of the grid of sensor positions.
10771208
kwargs
10781209
Additional keyword arguments passed to :meth:`wavefield`.
1210+
1211+
Examples
1212+
--------
1213+
Compute the Huygens PSF of an ideal parabolic telescope,
1214+
which should be an Airy pattern with its first minimum at a radius
1215+
of :math:`1.22 \lambda f / D`.
1216+
1217+
.. jupyter-execute::
1218+
1219+
import matplotlib.pyplot as plt
1220+
import astropy.units as u
1221+
import astropy.visualization
1222+
import named_arrays as na
1223+
import optika
1224+
1225+
primary = optika.surfaces.Surface(
1226+
name="primary",
1227+
sag=optika.sags.ParabolicSag(focal_length=-200 * u.mm),
1228+
aperture=optika.apertures.CircularAperture(20 * u.mm),
1229+
material=optika.materials.Mirror(),
1230+
is_pupil_stop=True,
1231+
transformation=na.transformations.Cartesian3dTranslation(
1232+
z=200 * u.mm,
1233+
),
1234+
)
1235+
1236+
sensor = optika.sensors.ImagingSensor(
1237+
name="sensor",
1238+
width_pixel=10 * u.um,
1239+
axis_pixel=na.Cartesian2dVectorArray("detector_x", "detector_y"),
1240+
num_pixel=na.Cartesian2dVectorArray(64, 64),
1241+
timedelta_exposure=1 * u.s,
1242+
is_field_stop=True,
1243+
)
1244+
1245+
system = optika.systems.SequentialSystem(
1246+
surfaces=[primary],
1247+
sensor=sensor,
1248+
grid_input=optika.vectors.ObjectVectorArray(
1249+
wavelength=500 * u.nm,
1250+
field=na.Cartesian2dVectorLinearSpace(
1251+
start=-1,
1252+
stop=1,
1253+
axis=na.Cartesian2dVectorArray("field_x", "field_y"),
1254+
num=5,
1255+
centers=True,
1256+
),
1257+
pupil=na.Cartesian2dVectorLinearSpace(
1258+
start=-1,
1259+
stop=1,
1260+
axis=na.Cartesian2dVectorArray("pupil_x", "pupil_y"),
1261+
num=5,
1262+
centers=True,
1263+
),
1264+
),
1265+
)
1266+
1267+
psf = system.psf(
1268+
field=na.Cartesian2dVectorArray(0, 0),
1269+
width_sensor=30 * u.um,
1270+
num=101**2,
1271+
num_sensor=51,
1272+
bound="footprint",
1273+
seed=42,
1274+
)
1275+
1276+
with astropy.visualization.quantity_support():
1277+
plt.figure()
1278+
plt.gca().set_aspect("equal")
1279+
na.plt.pcolormesh(
1280+
psf.inputs.position,
1281+
C=psf.outputs,
1282+
)
10791283
"""
10801284
wavefield = self.wavefield(axis=axis, **kwargs)
10811285

0 commit comments

Comments
 (0)