Source code for geometries.newton_raphson

"""Newton Raphson Geometry

The Newton Raphson geometry represents a surface utilizing the Newton-Raphson
method for ray tracing. This is an abstract base class that should be inherited
by any geometry that uses the Newton-Raphson method for ray tracing.

When the PyTorch backend is active with gradient tracking enabled, the
``distance`` method uses a DiffOptics-style one-step implicit correction
to compute correct first-order gradients through the converged intersection
point without unrolling the Newton-Raphson iterations through the autograd
graph.

The two-stage structure used here -- a graph-free primal Newton solve
followed by a single differentiable correction -- was contributed to Optiland
by Kushagra Kartik (https://github.com/Kushagra1480) in PR #550, addressing
the memory growth reported in issue #335.

See ``docs/developers_guide/implicit_differentiation.rst`` for the derivation,
the first-order-only contract, and the assumptions under which the derivative
is exact.

Kramer Harrison, 2024
"""

from __future__ import annotations

import contextlib
import warnings
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any

import optiland.backend as be
from optiland.coordinate_system import CoordinateSystem
from optiland.geometries.standard import StandardGeometry
from optiland.utils import machine_eps

try:
    import torch
except (ImportError, ModuleNotFoundError):
    torch = None


# Conservative multiplier applied to the machine epsilon when building the
# scale-aware floor for the Newton denominator ``dF/dt``. Large enough to stay
# clear of round-off in the residual, small enough that a genuinely
# well-conditioned intersection is never regularized.
_DENOM_EPS_MULTIPLIER = 32.0

# Multiplier for the round-off floor under the user-supplied convergence
# tolerance. See :func:`_effective_tolerance`.
_CONV_EPS_MULTIPLIER = 8.0


def _nz_threshold(nz):
    """Dtype-aware validity threshold for the surface-normal z component.

    The sag slopes are reconstructed from the normalized normal as
    ``s_x = -n_x / n_z``; when ``|n_z|`` approaches round-off the surface is
    not a numerically valid single-valued height function ``z = s(x, y)`` at
    that point, and the reconstruction (and therefore the implicit derivative)
    is meaningless. Uses the same conservative multiplier as the ``dF/dt``
    threshold. For float64 this lands near the historical ``1e-14``; for
    float32 it is orders of magnitude larger, which is the point -- a fixed
    ``1e-14`` is below float32 round-off and never triggers.
    """
    return _DENOM_EPS_MULTIPLIER * machine_eps(nz)


# -- utility functions --
def _is_radius_infinite(radius):
    """Checks if the given radius represents an infinite radius (a plane).

    Args:
        radius (float or be.ndarray): The radius value to check.

    Returns:
        bool: True if the radius is effectively infinite (or all elements are
        infinite if it's an array), False otherwise.
    """
    is_inf_tensor = be.isinf(radius)
    if hasattr(is_inf_tensor, "ndim") and is_inf_tensor.ndim > 0:
        # If it's a multi-element array, check if all are infinite
        return bool(be.all(is_inf_tensor))
    # For scalars or single-element arrays that can be converted by .item()
    return (
        bool(is_inf_tensor.item())
        if hasattr(is_inf_tensor, "item")
        else bool(is_inf_tensor)
    )


def _sign_preserving_floor(value, eps=None):
    """Clamp values to a minimum absolute magnitude while preserving sign.

    Preserving the sign matters: replacing a small *negative* denominator with
    a positive constant reverses the Newton step direction.

    When ``eps`` is None, a dtype-aware floor is derived from the machine
    epsilon of ``value``. This is a numerical safeguard for the graph-free
    primal iteration only; the implicit correction rejects rays for which the
    floor would have engaged (see :meth:`_classify_final_roots`).
    """
    if eps is None:
        eps = _DENOM_EPS_MULTIPLIER * machine_eps(value)
    return be.where(
        be.abs(value) > eps,
        value,
        be.where(value >= 0, eps, -eps),
    )


def _denominator_threshold(value, scale=None, multiplier=_DENOM_EPS_MULTIPLIER):
    """Dtype- and scale-aware singularity threshold for ``dF/dt``.

    Implements ``tau = C * eps_dtype * max(1, scale)`` where ``scale`` is the
    local magnitude ``|s_x L| + |s_y M| + |N|``. For float64 this lands near
    the historical ``1e-14``; for float32 it is ~9 orders of magnitude larger,
    which is the point -- a fixed ``1e-14`` is below float32 round-off and so
    never triggers.
    """
    tau = multiplier * machine_eps(value)
    if scale is None:
        return tau
    return tau * be.maximum(scale, be.ones_like(scale))


def _regularize_signed(value, scale=None):
    """Floor ``value`` away from zero, sign-preserving and dtype-aware.

    Returns ``(regularized_value, near_singular_mask)``. The mask flags entries
    where the true sensitivity is singular (a tangent/grazing intersection) and
    the returned derivative is therefore a *regularization*, not the exact
    physics.
    """
    tau = _denominator_threshold(value, scale)
    near_singular = be.abs(value) <= tau
    floored = be.where(
        near_singular,
        be.where(value >= 0, tau * be.ones_like(value), -tau * be.ones_like(value)),
        value,
    )
    return floored, near_singular


def _effective_tolerance(tol, t):
    """Raise ``tol`` to the round-off floor of the working dtype.

    A residual can only ever be driven down to about ``eps * |t|``. The
    default ``tol=1e-10`` is comfortably reachable in float64 but sits *below*
    float32 round-off, so in float32 every ray would be classified
    non-converged -- suppressing its implicit gradient and emitting a warning
    on every call -- despite the root being as good as that dtype allows.

    In float64 the user-supplied tolerance dominates, so behavior there is
    unchanged.
    """
    scale = float(be.to_numpy(be.max(be.abs(t))))
    floor = _CONV_EPS_MULTIPLIER * machine_eps(t) * max(1.0, scale)
    return max(float(tol), floor)


@dataclass
class _DistanceSolveResult:
    """Outcome of the graph-free primal Newton-Raphson distance solve.

    Attributes:
        t: Propagation distance to the intersection.
        residual: Surface residual ``F(t) = sag(x, y) - z`` at ``t``.
        converged: Per-ray boolean mask, ``|F(t)| < tol``.
        iterations: Number of Newton updates actually performed.
    """

    t: Any
    residual: Any
    converged: Any
    iterations: int


@dataclass
class _RootClassification:
    """Regularity classification of every ray at the final primal root.

    ``regular`` rays satisfy the full implicit-function-theorem contract:
    converged, all quantities finite, ``|n_z|`` above the dtype-aware
    threshold and ``|dF/dt|`` above the dtype- and scale-aware threshold.
    Only these rays receive the exact first-order implicit derivative.

    The rejection masks are mutually exclusive diagnostic categories, in
    priority order: ``nonfinite`` (any non-finite quantity at the root),
    ``nonconverged`` (finite but did not meet the tolerance), ``nz_singular``
    (converged but the surface is not a valid local height function),
    ``near_singular`` (converged but tangent/grazing, ``|dF/dt|`` below
    threshold).

    Attributes:
        regular: Per-ray mask of rays eligible for implicit differentiation.
        df_dt: Detached, unclipped ``dF/dt`` at the final root.
        nonfinite: Rejection mask -- non-finite state at the root.
        nonconverged: Rejection mask -- residual above tolerance.
        nz_singular: Rejection mask -- ``|n_z|`` below dtype threshold.
        near_singular: Rejection mask -- ``|dF/dt|`` below threshold.
    """

    regular: Any
    df_dt: Any
    nonfinite: Any
    nonconverged: Any
    nz_singular: Any
    near_singular: Any


def _all_finite(*values):
    """Elementwise AND of ``isfinite`` over several same-shaped arrays."""
    finite = be.isfinite(values[0])
    for value in values[1:]:
        finite = be.logical_and(finite, be.isfinite(value))
    return finite


[docs] class NewtonRaphsonGeometry(StandardGeometry, ABC): """Represents a geometry that uses the Newton-Raphson method for ray tracing. Args: coordinate_system (CoordinateSystem): The coordinate system of the geometry. radius (float): The radius of curvature of the base sphere. conic (float, optional): The conic constant of the base sphere. Defaults to 0.0. tol (float, optional): Tolerance for Newton-Raphson iteration. Defaults to 1e-10. max_iter (int, optional): Maximum iterations for Newton-Raphson. Defaults to 100. """ def __init__(self, coordinate_system, radius, conic=0.0, tol=1e-10, max_iter=100): super().__init__(coordinate_system, radius, conic) self.tol = tol self.max_iter = max_iter def __str__(self): return "Newton Raphson" # pragma: no cover
[docs] def flip(self): """Flip the geometry. Changes the sign of the radius of curvature. The conic constant remains unchanged. """ self.radius = -self.radius
[docs] @abstractmethod def sag(self, x=0, y=0): """Calculate the surface sag of the geometry. Args: x (float or be.ndarray, optional): The x-coordinate(s). Defaults to 0. y (float or be.ndarray, optional): The y-coordinate(s). Defaults to 0. Returns: float or be.ndarray: The surface sag of the geometry at the given coordinates. """
# pragma: no cover @abstractmethod def _surface_normal(self, x, y): """Calculate the surface normal of the geometry at the given x and y position. Args: x (be.ndarray): The x-coordinate(s) at which to calculate the normal. y (be.ndarray): The y-coordinate(s) at which to calculate the normal. Returns: tuple[be.ndarray, be.ndarray, be.ndarray]: The surface normal components (nx, ny, nz). """ # pragma: no cover
[docs] def surface_normal(self, rays): """Calculates the surface normal of the geometry at the given rays. Args: rays (RealRays): The rays, positioned at the surface, for which to calculate the surface normal. Returns: tuple[be.ndarray, be.ndarray, be.ndarray]: The surface normal components (nx, ny, nz). """ return self._surface_normal(rays.x, rays.y)
# ------------------------------------------------------------------ # Primal Newton-Raphson solve (no autograd graph) # ------------------------------------------------------------------ def _surface_residual(self, t, rays): """Residual ``F(t) = sag(x0 + tL, y0 + tM) - (z0 + tN)``. The intersection distance ``t*`` is the root ``F(t*) = 0``. """ x_int = rays.x + t * rays.L y_int = rays.y + t * rays.M z_int = rays.z + t * rays.N return self.sag(x_int, y_int) - z_int def _surface_residual_dt(self, t, rays): """Derivative ``dF/dt = s_x L + s_y M - N``, its scale, and validity. The sag slopes ``s_x, s_y`` are recovered from the surface normal. The returned scale ``|s_x L| + |s_y M| + |N|`` is used to build a scale-aware singularity threshold (see :func:`_denominator_threshold`). Returns: tuple: ``(df_dt, scale, nz_regular)``. The ``nz_regular`` mask flags rays for which ``|n_z|`` is safely above the dtype-aware threshold, i.e. the surface is a numerically valid local height function there. The primal solver may still take a floored step for irregular rays; the implicit correction must reject them. """ x_int = rays.x + t * rays.L y_int = rays.y + t * rays.M nx, ny, nz = self._surface_normal(x_int, y_int) tau_nz = _nz_threshold(nz) nz_regular = be.logical_and(be.isfinite(nz), be.abs(nz) > tau_nz) nz_safe = _sign_preserving_floor(nz, tau_nz) fx = -nx / nz_safe fy = -ny / nz_safe df_dt = fx * rays.L + fy * rays.M - rays.N scale = be.abs(fx * rays.L) + be.abs(fy * rays.M) + be.abs(rays.N) return df_dt, scale, nz_regular def _solve_distance_primal(self, rays): """Run the Newton-Raphson iteration to find the intersection distance. This is a pure numerical solve with **no** autograd graph. It is used both by the differentiable path (inside torch.no_grad) and by the non-differentiable path. Convergence is tested *before* the surface normal is evaluated, so a batch whose residual is already acceptable never triggers a normal computation (which can be singular at a grazing/tangent point) purely to satisfy the loop structure. Potential future optimization: support an optional fused ``eval_sag_and_grad(x, y)`` API to return ``(sag_val, fx, fy)`` in a single pass and reduce duplicate surface computations. Args: rays: An object with attributes x, y, z, L, M, N. Returns: _DistanceSolveResult: Distance, final residual, per-ray convergence mask and iteration count. """ # Better initial guess via base conic intersection t = super().distance(rays) tol = _effective_tolerance(self.tol, t) iterations = 0 f_t = self._surface_residual(t, rays) converged = be.abs(f_t) < tol for i in range(self.max_iter): # 1-3. Convergence is checked before any normal evaluation. if be.all(converged): break # 4. Only reached while at least one ray is still unconverged. df_dt, scale, _ = self._surface_residual_dt(t, rays) safe_df_dt, _ = _regularize_signed(df_dt, scale) # 5. Freeze already-converged rays so a converged root is not # perturbed by further steps. step = be.where(converged, be.zeros_like(f_t), f_t / safe_df_dt) t = t - step iterations = i + 1 f_t = self._surface_residual(t, rays) converged = be.abs(f_t) < tol return _DistanceSolveResult( t=t, residual=f_t, converged=converged, iterations=iterations ) def _classify_final_roots(self, result, rays): """Classify every ray at the *final* primal root (graph-free). Must be called with autograd disabled. Evaluates ``dF/dt`` at the final root -- never reusing a value from an earlier iteration -- and builds the regularity mask required for the implicit derivative to be the exact first-order physics: ``regular = converged AND finite(everything) AND |n_z| > tau_nz AND |dF/dt| > tau_dFdt`` Returns: _RootClassification: Detached masks and the detached, unclipped final-root ``dF/dt``. """ t = result.t df_dt, scale, nz_regular = self._surface_residual_dt(t, rays) finite = _all_finite( t, result.residual, rays.x, rays.y, rays.z, rays.L, rays.M, rays.N, df_dt, scale, ) tau = _denominator_threshold(df_dt, scale) denom_regular = be.abs(df_dt) > tau converged = result.converged regular = be.logical_and( be.logical_and(converged, finite), be.logical_and(nz_regular, denom_regular), ) # Mutually exclusive diagnostic categories, in priority order. nonfinite = be.logical_not(finite) nonconverged = be.logical_and(finite, be.logical_not(converged)) conv_finite = be.logical_and(finite, converged) nz_singular = be.logical_and(conv_finite, be.logical_not(nz_regular)) near_singular = be.logical_and( be.logical_and(conv_finite, nz_regular), be.logical_not(denom_regular) ) return _RootClassification( regular=regular, df_dt=df_dt, nonfinite=nonfinite, nonconverged=nonconverged, nz_singular=nz_singular, near_singular=near_singular, ) def _surface_residual_subset(self, t, rays, mask): """Grad-attached residual evaluated only for the rays in ``mask``. Restricting the evaluation to the regular subset matters: PyTorch can propagate ``NaN`` through the *backward* pass from an invalid branch even when that branch is later discarded by ``where``. Rejected rays are therefore never traced through a grad-attached residual at all. """ x_int = rays.x[mask] + t[mask] * rays.L[mask] y_int = rays.y[mask] + t[mask] * rays.M[mask] z_int = rays.z[mask] + t[mask] * rays.N[mask] return self.sag(x_int, y_int) - z_int def _warn_rejected_rays(self, state, result): """Emit one grouped ``RuntimeWarning`` for all rejected rays. Reports the per-category counts, the iteration count and (where meaningful) the worst residual, without dumping arrays. """ def _count(mask) -> int: return int(be.to_numpy(be.sum(mask))) n_nonfinite = _count(state.nonfinite) n_nonconverged = _count(state.nonconverged) n_nz = _count(state.nz_singular) n_tangent = _count(state.near_singular) n_rejected = n_nonfinite + n_nonconverged + n_nz + n_tangent if n_rejected == 0: return parts = [] if n_nonconverged: masked = be.where(state.nonconverged, be.abs(result.residual), 0.0) max_residual = float(be.to_numpy(be.max(masked))) parts.append( f"{n_nonconverged} non-converged " f"(max residual {max_residual:.3e} > tol {self.tol:.3e})" ) if n_nonfinite: parts.append(f"{n_nonfinite} with non-finite state at the root") if n_tangent: parts.append( f"{n_tangent} tangent/grazing (|dF/dt| below the dtype- and " "scale-aware threshold)" ) if n_nz: parts.append( f"{n_nz} with |n_z| below the dtype-aware threshold " "(surface not a valid local height function)" ) n_total = int(be.size(result.t)) warnings.warn( f"Newton-Raphson intersection rejected {n_rejected} of {n_total} " f"ray(s) from implicit differentiation after {result.iterations} " f"iteration(s): {'; '.join(parts)}. Rejected rays keep their " "detached primal forward value and contribute zero gradient.", RuntimeWarning, stacklevel=3, ) def _invalidate_cached_derived_state_for_autograd(self) -> None: """Invalidate derived caches that must be rebuilt grad-attached. Default no-op. Subclasses that cache tensors derived from trainable parameters may override this to force a rebuild before the differentiable correction. Forbes geometries instead build their coefficient cache under an explicit ``torch.enable_grad()``, which keeps the cache differentiable regardless of the caller's grad context and avoids rebuilding it on every differentiable trace. """ # ------------------------------------------------------------------ # Public distance method with autograd dispatch # ------------------------------------------------------------------
[docs] def distance(self, rays): """ Calculates the distance from the ray origin to the surface intersection using a robust Newton-Raphson method. This version uses the base conic intersection as a strong initial guess. **Differentiable mode (torch backend with grad enabled):** The primal Newton-Raphson solve runs inside ``torch.no_grad()`` so that the iterative loop is never recorded in the autograd graph. A one step implicit correction (in DiffOptics style) is then applied: t_implicit = t_detached - F(t_detached) / (dF/dt)_detached Since F is near zero at convergence, the forward value is unchanged, but the gradients are correct to first order via the implicit function theorem. Note: This implicit correction is intended for correct first-order gradients. Higher order derivatives (double backward and beyond) are not guaranteed to match the exact unrolled Newton system. **Non differentiable mode (numpy backend, or torch without grad):** Returns the converged t directly. Assumptions required for the implicit derivative to be exact: 1. the primal solve converged to the intended physical root; 2. that root stays on the same branch under small parameter changes; 3. ``dF/dt`` is not zero or numerically singular (no tangent/grazing intersection); 4. ``|n_z|`` is above the dtype-aware threshold, so the surface is a numerically valid local height function; 5. ``sag()`` and ``_surface_normal()`` describe the same surface; 6. only first derivatives are supported. Rays that fail any of (1)-(4) keep their **detached** primal forward value and are never evaluated through a grad-attached residual, so a failed or singular root never carries a confident-looking but invalid gradient and never contaminates the gradients of valid rays in the same batch. A grouped ``RuntimeWarning`` reports the rejections by category. Args: rays (RealRays): The rays used for calculating distance. Returns: be.ndarray: An array of propagation distances 't' from each ray's current position to its intersection point with the geometry. """ use_torch_diff = ( torch is not None and be.get_backend() == "torch" and torch.is_grad_enabled() ) # --- Phase A: graph-free primal solve and root classification ---- ctx = torch.no_grad() if use_torch_diff else contextlib.nullcontext() with ctx: result = self._solve_distance_primal(rays) state = self._classify_final_roots(result, rays) if use_torch_diff else None if not use_torch_diff: return result.t # Give subclasses a chance to rebuild caches that must be attached to # the autograd graph before the differentiable correction runs. self._invalidate_cached_derived_state_for_autograd() # --- Phase B: grad-attached correction on the regular subset ----- # DiffOptics-style one-step implicit correction, # # t_valid = t_bar - F(t_bar, theta) / stopgrad(dF/dt), # # applied only to rays whose final root is regular. The forward value # is approximately unchanged (F ~ 0 at convergence) but the expression # carries the exact first-order gradient dt*/dtheta = -F_theta / F_t. # The implicit function theorem needs only the *value* of the inverse # Jacobian for a first derivative, so the denominator is detached -- # and for regular rays it is used unclipped: regularity guarantees it # is safely above the singularity threshold. t_out = result.t.detach() regular = state.regular if bool(be.to_numpy(be.all(regular))): F = self._surface_residual(t_out, rays) t_out = t_out - F / state.df_dt elif bool(be.to_numpy(be.any(regular))): F_valid = self._surface_residual_subset(t_out, rays, regular) t_valid = t_out[regular] - F_valid / state.df_dt[regular] # Functional scatter: keeps t_out detached everywhere else while # gradients flow from the inserted values. t_out = t_out.masked_scatter(regular, t_valid) # If no ray is regular, the detached primal result is returned without # ever evaluating the differentiable residual. # --- Phase C: grouped failure reporting -------------------------- self._warn_rejected_rays(state, result) return t_out
def _intersection_plane(self, rays): """Calculates the intersection points of the rays with a plane (z=0). Args: rays (RealRays): The rays to calculate the intersection points for. Returns: tuple[be.ndarray, be.ndarray, be.ndarray]: The x, y, and z coordinates of the intersection points. """ # handle infinite radius: intersection with plane z=0 t = be.full_like(rays.z, be.nan) # rays not parallel to the XY plane (N != 0) mask_N_nonzero = be.abs(rays.N) > self.tol t = be.where(mask_N_nonzero, -rays.z / rays.N, t) mask_N_zero_and_z_zero = (~mask_N_nonzero) & (be.abs(rays.z) < self.tol) t = be.where(mask_N_zero_and_z_zero, 0.0, t) x = rays.x + rays.L * t y = rays.y + rays.M * t z = rays.z + rays.N * t return x, y, z def _intersection_sphere(self, rays): """Calculates the intersection points of the rays with the geometry. Args: rays (RealRays): The rays to calculate the intersection points for. Returns: tuple[be.ndarray, be.ndarray, be.ndarray]: The x, y, and z coordinates of the intersection points. """ a = rays.L**2 + rays.M**2 + rays.N**2 b = ( 2 * rays.L * rays.x + 2 * rays.M * rays.y - 2 * rays.N * self.radius + 2 * rays.N * rays.z ) c = rays.x**2 + rays.y**2 + rays.z**2 - 2 * self.radius * rays.z # discriminant d = b**2 - 4 * a * c # two solutions for distance to sphere with warnings.catch_warnings(): warnings.simplefilter("ignore") t1 = (-b + be.sqrt(d)) / (2 * a) t2 = (-b - be.sqrt(d)) / (2 * a) # find intersection points in z z1 = rays.z + t1 * rays.N z2 = rays.z + t2 * rays.N # take intersection closest to z = 0 (i.e., vertex of geometry) t = be.where(be.abs(z1) <= be.abs(z2), t1, t2) # handle case when a = 0 cond = a == 0 t[cond] = -c[cond] / b[cond] x = rays.x + rays.L * t y = rays.y + rays.M * t z = rays.z + rays.N * t return x, y, z def _intersection(self, rays): """Calculates the initial intersection points of the rays with the base geometry (sphere or plane) before Newton-Raphson iteration. Args: rays (RealRays): The rays to calculate the intersection points for. Returns: tuple[be.ndarray, be.ndarray, be.ndarray]: The x, y, and z coordinates of the initial intersection points. """ if _is_radius_infinite(self.radius): return self._intersection_plane(rays) else: return self._intersection_sphere(rays)
[docs] def to_dict(self): """Converts the geometry to a dictionary. Returns: dict: The dictionary representation of the geometry. """ geometry_dict = super().to_dict() geometry_dict.update({"tol": self.tol, "max_iter": self.max_iter}) return geometry_dict
[docs] @classmethod def from_dict(cls, data): # pragma: no cover """Creates a geometry from a dictionary representation. Args: data (dict): The dictionary representation of the geometry. Returns: NewtonRaphsonGeometry: An instance of a subclass of NewtonRaphsonGeometry, created from the dictionary data. """ required_keys = {"cs", "radius"} if not required_keys.issubset(data): missing = required_keys - data.keys() raise ValueError(f"Missing required keys: {missing}") cs = CoordinateSystem.from_dict(data["cs"]) conic = data.get("conic", 0.0) tol = data.get("tol", 1e-10) max_iter = data.get("max_iter", 100) return cls(cs, data["radius"], conic, tol, max_iter)