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