@@ -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