Skip to content

Commit 79bcb98

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 5e98d24 commit 79bcb98

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
@@ -706,6 +706,80 @@ def rayfunction_default(self) -> optika.rays.RayFunctionArray:
706706
"""
707707
return self.rayfunction()
708708

709+
def footprint(
710+
self,
711+
surface: optika.surfaces.AbstractSurface,
712+
wavelength: None | u.Quantity | na.AbstractScalar = None,
713+
field: None | na.AbstractCartesian2dVectorArray = None,
714+
num: int = 101,
715+
normalized_field: bool = True,
716+
) -> tuple[na.AbstractCartesian3dVectorArray, na.AbstractScalar]:
717+
"""
718+
The beam footprint on a given surface: the positions where a dense
719+
grid of rays traced through the entire system intersects the surface,
720+
expressed in local surface coordinates, along with a mask selecting
721+
the rays that reach the sensor unvignetted.
722+
723+
This identifies the used portion of each optic, which is useful for
724+
restricting the sampling region of a physical-optics calculation
725+
(see :meth:`wavefield`) or for constructing the effective pupil of
726+
one channel of a multi-channel instrument
727+
(see :func:`optika.apertures.footprint_aperture`).
728+
729+
Parameters
730+
----------
731+
surface
732+
The surface on which to evaluate the footprint.
733+
Must be an element of :attr:`surfaces`, :attr:`object`,
734+
or the :attr:`sensor`.
735+
wavelength
736+
The wavelengths of the rays.
737+
If :obj:`None` (the default), ``self.grid_input.wavelength``
738+
will be used.
739+
field
740+
The field positions of the rays, in either normalized or
741+
physical units.
742+
If :obj:`None` (the default), ``self.grid_input.field``
743+
will be used.
744+
num
745+
The number of rays along each axis of the pupil grid.
746+
normalized_field
747+
A boolean flag indicating whether the `field` parameter is given
748+
in normalized or physical units.
749+
"""
750+
surfaces = self.surfaces_all
751+
index = [i for i, s in enumerate(surfaces) if s is surface]
752+
if not index:
753+
raise ValueError(
754+
f"surface {surface.name!r} is not an element of this system."
755+
)
756+
index = index[0]
757+
758+
axis = "_footprint"
759+
pupil = na.Cartesian2dVectorLinearSpace(
760+
start=-1,
761+
stop=1,
762+
axis=na.Cartesian2dVectorArray(f"{axis}_x", f"{axis}_y"),
763+
num=num,
764+
centers=True,
765+
)
766+
767+
raytrace = self.raytrace(
768+
wavelength=wavelength,
769+
field=field,
770+
pupil=pupil,
771+
axis=axis,
772+
normalized_field=normalized_field,
773+
)
774+
775+
position = raytrace.outputs.position[{axis: index}]
776+
where = raytrace.outputs.unvignetted[{axis: ~0}]
777+
778+
if surface.transformation is not None:
779+
position = surface.transformation.inverse(position)
780+
781+
return position, where
782+
709783
def wavefield(
710784
self,
711785
wavelength: None | u.Quantity | na.AbstractScalar = None,
@@ -718,7 +792,10 @@ def wavefield(
718792
chunk_size: int = 1024,
719793
normalized_field: bool = True,
720794
seed: None | int = None,
721-
bound: None | Sequence = None,
795+
bound: None | str | Sequence = None,
796+
padding_relative: float = 0.1,
797+
padding_absolute: u.Quantity | na.AbstractScalar = 0 * u.mm,
798+
num_footprint: int = 101,
722799
) -> optika.wavefields.WavefieldFunctionArray:
723800
r"""
724801
Propagate a wavefield through this system using physical optics and
@@ -774,12 +851,28 @@ def wavefield(
774851
bound
775852
An optional sequence of sampling regions, one for each surface
776853
with an aperture, where each element is either :obj:`None` (use
777-
the bounding box of the surface's aperture) or a tuple of the
778-
lower and upper corners of the region in local surface
779-
coordinates.
854+
the bounding box of the surface's aperture), the string
855+
``"footprint"`` (use the bounding box of the beam footprint from
856+
a geometric raytrace, padded by `padding_relative` and
857+
`padding_absolute`), or a tuple of the lower and upper corners
858+
of the region in local surface coordinates.
859+
The string ``"footprint"`` may also be given for `bound` itself,
860+
which applies it to every surface.
780861
This is important for surfaces near an intermediate focus,
781862
where the wavefield is concentrated in a region much smaller than
782863
the aperture and would otherwise be unresolved by the samples.
864+
padding_relative
865+
The fraction of the footprint extent by which to expand a
866+
``"footprint"`` sampling region.
867+
padding_absolute
868+
The minimum distance by which to expand a ``"footprint"``
869+
sampling region.
870+
At an intermediate focus the geometric footprint collapses to a
871+
point, so this should be at least a few times the expected size
872+
of the diffraction pattern there.
873+
num_footprint
874+
The number of rays along each axis of the pupil grid used to
875+
compute the ``"footprint"`` sampling regions.
783876
784877
Notes
785878
-----
@@ -798,6 +891,11 @@ def wavefield(
798891
filters) are often better excluded from the wave propagation
799892
(by setting their aperture to :obj:`None` in a copy of the system)
800893
than included as an undersampled propagation step.
894+
The effect of an excluded mask on the pupil can be retained by
895+
replacing the aperture of the nearest powered surface with the
896+
convex hull of its illuminated footprint,
897+
using :meth:`footprint` and
898+
:func:`optika.apertures.footprint_aperture`.
801899
"""
802900
if wavelength is None:
803901
wavelength = self.grid_input.wavelength
@@ -836,13 +934,46 @@ def wavefield(
836934
f"{len(surfaces)} surfaces with an aperture, got {len(num)}."
837935
)
838936

839-
if bound is None:
840-
bound = [None] * len(surfaces)
937+
if (bound is None) or isinstance(bound, str):
938+
bound = [bound] * len(surfaces)
841939
if len(bound) != len(surfaces):
842940
raise ValueError(
843941
f"`bound` must have one element for each of the "
844942
f"{len(surfaces)} surfaces with an aperture, got {len(bound)}."
845943
)
944+
bound = list(bound)
945+
for k, surface in enumerate(surfaces):
946+
if not isinstance(bound[k], str):
947+
continue
948+
if bound[k] != "footprint":
949+
raise ValueError(
950+
f"the only string allowed in `bound` is 'footprint', "
951+
f"got {bound[k]!r}."
952+
)
953+
position_footprint, where_footprint = self.footprint(
954+
surface=surface,
955+
wavelength=grid.wavelength,
956+
field=grid.field,
957+
num=num_footprint,
958+
normalized_field=False,
959+
)
960+
position_footprint = position_footprint.xy
961+
unit = na.unit(position_footprint.x)
962+
inf = np.inf if unit is None else np.inf * unit
963+
axis_footprint = ("_footprint_x", "_footprint_y")
964+
lower = na.Cartesian2dVectorArray(
965+
x=np.where(where_footprint, position_footprint.x, +inf),
966+
y=np.where(where_footprint, position_footprint.y, +inf),
967+
).min(axis_footprint)
968+
upper = na.Cartesian2dVectorArray(
969+
x=np.where(where_footprint, position_footprint.x, -inf),
970+
y=np.where(where_footprint, position_footprint.y, -inf),
971+
).max(axis_footprint)
972+
padding = np.maximum(
973+
padding_relative * (upper - lower),
974+
padding_absolute,
975+
)
976+
bound[k] = (lower - padding, upper + padding)
846977

847978
axes = [
848979
(f"_{axis[0]}_{k % 2}", f"_{axis[1]}_{k % 2}")
@@ -1043,6 +1174,79 @@ def psf(
10431174
The two logical axes of the grid of sensor positions.
10441175
kwargs
10451176
Additional keyword arguments passed to :meth:`wavefield`.
1177+
1178+
Examples
1179+
--------
1180+
Compute the Huygens PSF of an ideal parabolic telescope,
1181+
which should be an Airy pattern with its first minimum at a radius
1182+
of :math:`1.22 \lambda f / D`.
1183+
1184+
.. jupyter-execute::
1185+
1186+
import matplotlib.pyplot as plt
1187+
import astropy.units as u
1188+
import astropy.visualization
1189+
import named_arrays as na
1190+
import optika
1191+
1192+
primary = optika.surfaces.Surface(
1193+
name="primary",
1194+
sag=optika.sags.ParabolicSag(focal_length=-200 * u.mm),
1195+
aperture=optika.apertures.CircularAperture(20 * u.mm),
1196+
material=optika.materials.Mirror(),
1197+
is_pupil_stop=True,
1198+
transformation=na.transformations.Cartesian3dTranslation(
1199+
z=200 * u.mm,
1200+
),
1201+
)
1202+
1203+
sensor = optika.sensors.ImagingSensor(
1204+
name="sensor",
1205+
width_pixel=10 * u.um,
1206+
axis_pixel=na.Cartesian2dVectorArray("detector_x", "detector_y"),
1207+
num_pixel=na.Cartesian2dVectorArray(64, 64),
1208+
timedelta_exposure=1 * u.s,
1209+
is_field_stop=True,
1210+
)
1211+
1212+
system = optika.systems.SequentialSystem(
1213+
surfaces=[primary],
1214+
sensor=sensor,
1215+
grid_input=optika.vectors.ObjectVectorArray(
1216+
wavelength=500 * u.nm,
1217+
field=na.Cartesian2dVectorLinearSpace(
1218+
start=-1,
1219+
stop=1,
1220+
axis=na.Cartesian2dVectorArray("field_x", "field_y"),
1221+
num=5,
1222+
centers=True,
1223+
),
1224+
pupil=na.Cartesian2dVectorLinearSpace(
1225+
start=-1,
1226+
stop=1,
1227+
axis=na.Cartesian2dVectorArray("pupil_x", "pupil_y"),
1228+
num=5,
1229+
centers=True,
1230+
),
1231+
),
1232+
)
1233+
1234+
psf = system.psf(
1235+
field=na.Cartesian2dVectorArray(0, 0),
1236+
width_sensor=30 * u.um,
1237+
num=101**2,
1238+
num_sensor=51,
1239+
bound="footprint",
1240+
seed=42,
1241+
)
1242+
1243+
with astropy.visualization.quantity_support():
1244+
plt.figure()
1245+
plt.gca().set_aspect("equal")
1246+
na.plt.pcolormesh(
1247+
psf.inputs.position,
1248+
C=psf.outputs,
1249+
)
10461250
"""
10471251
wavefield = self.wavefield(axis=axis, **kwargs)
10481252

0 commit comments

Comments
 (0)