From 6f31a3dae67e2b5b180cd56ed48468add77f9057 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Sun, 12 Jul 2026 18:28:43 -0600 Subject: [PATCH] Interpret grid_input as cell vertices and trace rays at cell centers The components of grid_input (and of the grids given to distortion() and vignetting()) are now interpreted as cell vertices, and the rays of the system are traced at the corresponding cell centers, so that the inputs of rayfunction_default and of the rayfunctions used by the fitting methods are defined on cell vertices while the outputs are defined on cell centers. This makes the system grids consistent with the vertex convention already used by image(), enables area-weighted methods such as the upcoming effective area calculation, and avoids tracing rays exactly on the edges of the apertures. raytrace() and rayfunction() keep their exact-sample-point semantics, since image() passes precomputed cell centers with stratified random offsets. Migration note for downstream configurations: grids built with na.*LinearSpace(..., centers=True) should drop centers=True and increment num by one, which reproduces the previously traced rays exactly. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Lm9SxSpyNg9hx8dNL9pUXc --- optika/systems/_sequential.py | 126 ++++++++++++++++++++++++++-------- 1 file changed, 99 insertions(+), 27 deletions(-) diff --git a/optika/systems/_sequential.py b/optika/systems/_sequential.py index f75fa9cb..b4458980 100644 --- a/optika/systems/_sequential.py +++ b/optika/systems/_sequential.py @@ -117,6 +117,10 @@ def grid_input(self) -> optika.vectors.ObjectVectorArray: """ The input grid to sample with rays. + The components of this grid are interpreted as cell vertices, + and the rays of the system are traced at the corresponding cell + centers. + This grid is simultaneously projected onto both the field stop and the pupil stop. @@ -738,8 +742,32 @@ def _calc_rayfunction_input( @functools.cached_property def _rayfunction_input(self) -> optika.rays.RayFunctionArray: - return self._calc_rayfunction_input( - grid_input=self.grid_input, + result = self._calc_rayfunction_input( + grid_input=self._grid_input_centers, + normalized_field=False, + normalized_pupil=False, + ) + result.inputs = self._grid_input_physical + return result + + @functools.cached_property + def _grid_input_physical(self) -> optika.vectors.ObjectVectorArray: + """The vertices of :attr:`grid_input` in physical units.""" + return self._denormalize_grid( + grid=self.grid_input, + ) + + @functools.cached_property + def _grid_input_centers(self) -> optika.vectors.ObjectVectorArray: + """ + The cell centers of :attr:`grid_input` in physical units. + + The components of :attr:`grid_input` are interpreted as cell vertices, + and the rays of the system are traced at the corresponding cell + centers. + """ + return self._grid_input_physical.cell_centers( + axis=self.axis_wavelength_ + self.axis_field_ + self.axis_pupil_, ) def raytrace( @@ -902,6 +930,11 @@ def rayfunction_default(self) -> optika.rays.RayFunctionArray: Computes the rays in local coordinates at the last surface in the system as a function of input wavelength and position using :attr:`grid_input`. + The components of :attr:`grid_input` are interpreted as cell vertices, + and the rays are traced at the corresponding cell centers, so the + inputs of this rayfunction are defined on cell vertices while the + outputs are defined on cell centers. + This property is cached to increase performance. If :attr:`grid_input` is updated, the cache must be cleared with ``del system.rayfunction_default`` before calling this property. @@ -1130,16 +1163,16 @@ def distortion( Parameters ---------- wavelength - The wavelengths of the input rays. + The vertices of the wavelength grid. If :obj:`None` (the default), ``self.grid_input.wavelength`` will be used. field - The field positions of the input rays, in either normalized or + The vertices of the field grid, in either normalized or physical units. If :obj:`None` (the default), ``self.grid_input.field`` will be used. pupil - The pupil positions of the input rays, in either normalized or + The vertices of the pupil grid, in either normalized or physical units. If :obj:`None` (the default), ``self.grid_input.pupil`` will be used. @@ -1165,11 +1198,15 @@ def distortion( "fitting a distortion model requires the wavelength grid " "to vary along its own logical axis." ) + + grid = rays.inputs.cell_centers( + axis=axis_wavelength + axis_field, + ) (axis_wavelength,) = axis_wavelength coordinates_scene = na.SpectralPositionalVectorArray( - wavelength=rays.inputs.wavelength, - position=rays.inputs.field, + wavelength=grid.wavelength, + position=grid.field, ) unvignetted = rays.outputs.unvignetted @@ -1215,16 +1252,16 @@ def vignetting( Parameters ---------- wavelength - The wavelengths of the input rays. + The vertices of the wavelength grid. If :obj:`None` (the default), ``self.grid_input.wavelength`` will be used. field - The field positions of the input rays, in either normalized or + The vertices of the field grid, in either normalized or physical units. If :obj:`None` (the default), ``self.grid_input.field`` will be used. pupil - The pupil positions of the input rays, in either normalized or + The vertices of the pupil grid, in either normalized or physical units. If :obj:`None` (the default), ``self.grid_input.pupil`` will be used. @@ -1250,11 +1287,15 @@ def vignetting( "fitting a vignetting model requires the wavelength grid " "to vary along its own logical axis." ) + + grid = rays.inputs.cell_centers( + axis=axis_wavelength + axis_field, + ) (axis_wavelength,) = axis_wavelength coordinates_scene = na.SpectralPositionalVectorArray( - wavelength=rays.inputs.wavelength, - position=rays.inputs.field, + wavelength=grid.wavelength, + position=grid.field, ) unvignetted = rays.outputs.unvignetted @@ -1293,21 +1334,23 @@ def _rayfunction_and_axes( Trace the given grids through this system and return the resulting rays along with the normalized wavelength, field, and pupil axes. + The grids are interpreted as cell vertices, and the rays are traced + at the corresponding cell centers, so that the inputs of the + resulting rayfunction are defined on cell vertices while the outputs + are defined on cell centers. If all of the grids are :obj:`None`, :attr:`rayfunction_default` and - the normalized axes of :attr:`grid_input` are used. - Otherwise, the rays are computed with :meth:`rayfunction` and the - axes are inferred from the resulting inputs, so that grids left as + the normalized axes of :attr:`grid_input` are used, and grids left as :obj:`None` inherit the corresponding component of :attr:`grid_input`. Parameters ---------- wavelength - The wavelengths of the input rays. + The vertices of the wavelength grid. field - The field positions of the input rays, in either normalized or + The vertices of the field grid, in either normalized or physical units. pupil - The pupil positions of the input rays, in either normalized or + The vertices of the pupil grid, in either normalized or physical units. normalized_field A boolean flag indicating whether the `field` parameter is given @@ -1322,28 +1365,53 @@ def _rayfunction_and_axes( axis_field = self.axis_field_ axis_pupil = self.axis_pupil_ else: - rays = self.rayfunction( + if wavelength is None: + wavelength = self.grid_input.wavelength + if field is None: + field = self.grid_input.field + if pupil is None: + pupil = self.grid_input.pupil + + grid = optika.vectors.ObjectVectorArray( wavelength=wavelength, field=field, pupil=pupil, - normalized_field=normalized_field, - normalized_pupil=normalized_pupil, ) + axis_wavelength = self._normalize_axis_wavelength( axis_wavelength=None, - wavelength=rays.inputs.wavelength, + wavelength=grid.wavelength, ) axis_field = self._normalize_axis_field( axis_field=None, axis_wavelength=axis_wavelength, - field=rays.inputs.field, + field=grid.field, ) axis_pupil = self._normalize_axis_pupil( axis_pupil=None, axis_field=axis_field, axis_wavelength=axis_wavelength, - pupil=rays.inputs.pupil, + pupil=grid.pupil, ) + + grid = self._denormalize_grid( + grid=grid, + normalized_field=normalized_field, + normalized_pupil=normalized_pupil, + ) + + grid_centers = grid.cell_centers( + axis=axis_wavelength + axis_field + axis_pupil, + ) + + rays = self.rayfunction( + wavelength=grid_centers.wavelength, + field=grid_centers.field, + pupil=grid_centers.pupil, + normalized_field=False, + normalized_pupil=False, + ) + rays.inputs = grid return rays, axis_wavelength, axis_field, axis_pupil def plot( @@ -1447,7 +1515,7 @@ def spot_diagram( cmap The colormap used to map scalar data to colors. """ - grid = self.grid_input + grid = self._grid_input_centers wavelength = na.as_named_array(grid.wavelength) field = grid.field @@ -1511,7 +1579,7 @@ def spot_diagram( ax=ax, s=s, where=rays.unvignetted, - c=self.grid_input.wavelength.value, + c=na.value(wavelength), colorizer=colorizer, ) @@ -1523,7 +1591,7 @@ def spot_diagram( na.plt.set_xlabel(f"$x$ ({position.x.unit:latex_inline})", ax=ax_lower) na.plt.set_ylabel(f"$y$ ({position.y.unit:latex_inline})", ax=ax_left) - angle = self.rayfunction_default.inputs.field + angle = field angle_x = angle.x angle_y = angle.y @@ -1829,6 +1897,10 @@ class SequentialSystem( """ The input grid to sample with rays. + The components of this grid are interpreted as cell vertices, + and the rays of the system are traced at the corresponding cell + centers. + This grid is simultaneously projected onto both the field stop and the pupil stop.