Bug description
SequentialSystem.rayfunction() and any method that uses normalized field/pupil
coordinates (field=1, pupil=1, etc.) produces wrong results when the pupil stop
is a transmissive optic — specifically a Fresnel Zone Plate (FZP) or any surface
whose material is not a mirror.
The internal _calc_rayfunction_stops method performs a backward raytrace to
determine the physical extents of the field and pupil stops, which are then used
to map normalized [-1, 1] coordinates to physical units. For transmissive pupil
stops this backward raytrace gives extents that are off by ~20×, causing all
normalized-coordinate calls (raytrace(), rayfunction(), spot_diagram(),
plot(plot_rays=True)) to trace rays far outside the valid aperture.
Purely reflective systems (e.g. a prime-focus mirror telescope) are not affected.
Root cause
There are two coupled errors in _calc_rayfunction_stops_only and
_calc_rayfunction_stops in optika/systems/_sequential.py.
1. Root-finding skips the grating (_ray_error, line ~198)
_calc_rayfunction_stops_only places trial rays at the pupil stop edge and
root-finds the direction that makes them reach the field stop boundary. The inner
propagation call uses subsystem[1:], which omits subsystem[0] — the FZP itself.
The root-finder therefore finds straight-line geometric angles that reach the sensor
without applying diffraction.
2. Backward trace re-applies the grating (_calc_rayfunction_stops, line ~373)
_calc_rayfunction_stops takes the (already-wrong) directions from step 1 and
propagates them backward through the full subsystem = [FZP, source]. Because the
FZP is transmissive, propagate_rays applies the grating equation again in the
forward direction, compounding the error.
For a mirror this step works correctly: Snell's law with is_mirror=True reflects
the ray direction, which is the natural time-reversal of reflection. Transmissive
surfaces have no equivalent automatic reversal.
Workaround
Build rayfunction_default manually with physical (non-normalized) coordinates and
cache it on the system before calling any normalized-coordinate method:
rays_default = system.rayfunction(
field=field, # physical units (deg or mm)
pupil=pupil, # physical units (mm or deg)
normalized_field=False,
normalized_pupil=False,
)
system.__dict__["rayfunction_default"] = rays_default
Minimal reproduction
import numpy as np
import astropy.units as u
import named_arrays as na
import optika
focal_length = 1000 * u.mm
radius = 70 * u.mm
wavelength = 171 * u.AA
spacing = optika.rulings.HolographicRulingSpacing(
x1=na.Cartesian3dVectorArray(0, 0, -1) * (1 * u.au).to(u.mm),
x2=na.Cartesian3dVectorArray(0 * u.mm, 0 * u.mm, focal_length),
wavelength=wavelength,
is_diverging_1=True,
is_diverging_2=False,
)
fzp = optika.surfaces.Surface(
name="FZP",
aperture=optika.apertures.CircularAperture(radius),
rulings=optika.rulings.Rulings(spacing=spacing, diffraction_order=1),
is_pupil_stop=True,
)
sensor = optika.sensors.ImagingSensor(
name="sensor",
width_pixel=13 * u.um,
axis_pixel=na.Cartesian2dVectorArray("x", "y"),
num_pixel=na.Cartesian2dVectorArray(512, 512),
transformation=na.transformations.Cartesian3dTranslation(z=focal_length),
is_field_stop=True,
)
grid = optika.vectors.ObjectVectorArray(
wavelength=wavelength,
field=na.Cartesian2dVectorLinearSpace(start=-1, stop=1,
axis=na.Cartesian2dVectorArray("fx","fy"), num=5),
pupil=na.Cartesian2dVectorLinearSpace(start=-1, stop=1,
axis=na.Cartesian2dVectorArray("px","py"), num=5),
)
system = optika.systems.SequentialSystem(
surfaces=[fzp], sensor=sensor, grid_input=grid,
)
# Bug: field_max should be ~0.191 deg, pupil_max should be ~70 mm
print(system.field_max) # gives ~3-4 deg (wrong)
print(system.pupil_max) # gives ~1400 mm (wrong)
Notes on the fix
The fix requires detecting whether the first stop surface is transmissive
(not surface.material.is_mirror) and:
- Including the surface in the root-finding propagation so diffraction
is part of the residual function.
- Excluding the surface from the backward propagation (the pre-diffraction
incident directions found by root-finding are already the correct object-space
directions; re-applying the grating would corrupt them).
An alternative, more architecturally principled approach would be to introduce a
reverse=True mode in propagate_rays that negates the diffraction order (m → −m)
for time-reversal. This requires restructuring the backward-trace algorithm and
propagating a reverse flag through propagators.py, surfaces.py, and
rulings/_rulings.py, and is recommended as a future refactor.
Bug description
SequentialSystem.rayfunction()and any method that uses normalized field/pupilcoordinates (
field=1,pupil=1, etc.) produces wrong results when the pupil stopis a transmissive optic — specifically a Fresnel Zone Plate (FZP) or any surface
whose material is not a mirror.
The internal
_calc_rayfunction_stopsmethod performs a backward raytrace todetermine the physical extents of the field and pupil stops, which are then used
to map normalized
[-1, 1]coordinates to physical units. For transmissive pupilstops this backward raytrace gives extents that are off by ~20×, causing all
normalized-coordinate calls (
raytrace(),rayfunction(),spot_diagram(),plot(plot_rays=True)) to trace rays far outside the valid aperture.Purely reflective systems (e.g. a prime-focus mirror telescope) are not affected.
Root cause
There are two coupled errors in
_calc_rayfunction_stops_onlyand_calc_rayfunction_stopsinoptika/systems/_sequential.py.1. Root-finding skips the grating (
_ray_error, line ~198)_calc_rayfunction_stops_onlyplaces trial rays at the pupil stop edge androot-finds the direction that makes them reach the field stop boundary. The inner
propagation call uses
subsystem[1:], which omitssubsystem[0]— the FZP itself.The root-finder therefore finds straight-line geometric angles that reach the sensor
without applying diffraction.
2. Backward trace re-applies the grating (
_calc_rayfunction_stops, line ~373)_calc_rayfunction_stopstakes the (already-wrong) directions from step 1 andpropagates them backward through the full
subsystem = [FZP, source]. Because theFZP is transmissive,
propagate_raysapplies the grating equation again in theforward direction, compounding the error.
For a mirror this step works correctly: Snell's law with
is_mirror=Truereflectsthe ray direction, which is the natural time-reversal of reflection. Transmissive
surfaces have no equivalent automatic reversal.
Workaround
Build
rayfunction_defaultmanually with physical (non-normalized) coordinates andcache it on the system before calling any normalized-coordinate method:
Minimal reproduction
Notes on the fix
The fix requires detecting whether the first stop surface is transmissive
(
not surface.material.is_mirror) and:is part of the residual function.
incident directions found by root-finding are already the correct object-space
directions; re-applying the grating would corrupt them).
An alternative, more architecturally principled approach would be to introduce a
reverse=Truemode inpropagate_raysthat negates the diffraction order (m → −m)for time-reversal. This requires restructuring the backward-trace algorithm and
propagating a
reverseflag throughpropagators.py,surfaces.py, andrulings/_rulings.py, and is recommended as a future refactor.