Skip to content

Commit 9905960

Browse files
jacobdparkerclaude
andcommitted
Fixed spurious "Max iterations exceeded" errors in the stop root-finding problem
The Newton solve in `_calc_rayfunction_stops_only` used the default perturbation of `na.jacobian`, an absolute 1e-10 in the units of the variable, which is below the floating-point noise floor of the raytrace for variables measured in physical units, yielding a Jacobian made of noise and wild iterates. The default convergence tolerance of `na.optimize.root_newton` (1e-10 in the units of the residual) had the same scale-dependence problem: solves whose residuals had converged to machine precision were reported as "Max iterations exceeded". Both are now proportional to the size of the target aperture wire. Failures that remain are re-raised with the names of the stop surfaces involved and a hint about marking the object surface as the field stop for dispersive systems, instead of an anonymous "Max iterations exceeded" from deep inside named_arrays. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 9d38b2c commit 9905960

1 file changed

Lines changed: 60 additions & 14 deletions

File tree

optika/systems/_sequential.py

Lines changed: 60 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -316,24 +316,70 @@ def zfunc(xy: na.AbstractCartesian3dVectorArray):
316316
else:
317317
raise ValueError(f"unrecognized output grid unit, {na.unit(grid_last)}")
318318

319+
# The residual of the root-finding problem has the same units as
320+
# the target grid, so the convergence tolerance must scale with
321+
# the size of the target aperture to be achievable in floating
322+
# point for systems of any physical scale.
323+
scale = np.maximum(
324+
np.abs(grid_last.x).max(),
325+
np.abs(grid_last.y).max(),
326+
)
327+
max_abs_error = 1e-9 * np.maximum(
328+
scale,
329+
1 * na.unit_normalized(scale),
330+
)
331+
319332
variables = getattr(result.outputs, component_variable)
320333

321-
root = na.optimize.root_newton(
322-
function=functools.partial(
323-
self._ray_error,
324-
rays=result.outputs,
325-
subsystem=subsystem,
326-
grid_last=grid_last,
327-
component_variable=component_variable,
328-
component_target=component_target,
329-
zfunc=zfunc,
330-
),
331-
guess=na.Cartesian2dVectorArray(
332-
x=variables.x,
333-
y=variables.y,
334-
),
334+
function = functools.partial(
335+
self._ray_error,
336+
rays=result.outputs,
337+
subsystem=subsystem,
338+
grid_last=grid_last,
339+
component_variable=component_variable,
340+
component_target=component_target,
341+
zfunc=zfunc,
335342
)
336343

344+
# The default perturbation used by `na.jacobian` is an absolute
345+
# 1e-10 in the units of the variable, which is below the
346+
# floating-point noise floor of the raytrace for variables
347+
# measured in physical units, yielding a Jacobian made of noise.
348+
# Use a perturbation proportional to the scale of the problem
349+
# instead.
350+
if component_variable == "direction":
351+
dx = 1e-6
352+
else:
353+
dx = 1e-6 * np.maximum(
354+
scale,
355+
1 * na.unit_normalized(scale),
356+
)
357+
358+
def jacobian(x, _function=function, _dx=dx):
359+
return na.jacobian(function=_function, x=x, dx=_dx)
360+
361+
try:
362+
root = na.optimize.root_newton(
363+
function=function,
364+
guess=na.Cartesian2dVectorArray(
365+
x=variables.x,
366+
y=variables.y,
367+
),
368+
jacobian=jacobian,
369+
max_abs_error=max_abs_error,
370+
)
371+
except ValueError as e:
372+
raise ValueError(
373+
f"Could not solve for the rays connecting the stop "
374+
f"surfaces {surface_first.name!r} and "
375+
f"{surface_last.name!r}. "
376+
f"If the field stop is only partially reachable at a "
377+
f"single wavelength (e.g. a spectrograph sensor), "
378+
f"consider marking the object surface, with an angular "
379+
f"(dimensionless, sine of the half-angle) aperture, as "
380+
f"the field stop instead."
381+
) from e
382+
337383
variables.x = root.x
338384
variables.y = root.y
339385
variables.z = zfunc(root)

0 commit comments

Comments
 (0)