Source code for optiland.rays.ray_aiming.cached

"""Cached Ray Aiming Module

This module implements a caching wrapper for ray aiming algorithms.
It stores previous results to speed up repetitive calculations when the inputs
and optical system remain unchanged.

Retained for backward compatibility (explicit ``cache=True``) but no longer
the default warm-start mechanism for ``"robust"``, which has its own
intrinsic, always-on ``PupilMapCache`` (see ``pupil_map.py`` and
``robust.py``).

Kramer Harrison, 2025
"""

from __future__ import annotations

import hashlib
import pickle
from typing import TYPE_CHECKING, Any

import optiland.backend as be
from optiland.rays.ray_aiming.base import BaseRayAimer

if TYPE_CHECKING:
    from optiland.optic import Optic


[docs] class CachedRayAimer(BaseRayAimer): """Cached ray aiming strategy. This class wraps another ray aimer and caches its results. It checks if the inputs and the optical system state have changed. If they match a cached entry in the same numerical execution context, the result is returned immediately. Otherwise, the wrapped aimer computes a fresh result without an automatic initial guess. Explicit caller guesses bypass caching. Coordinate-frame note: cached entries are full launch states in global coordinates. The system hash covers every surface, so any rigid pose change (translation, fold reorientation) misses the exact-reuse path. After a system change, a cached launch is not used as a starting guess: its fixed field-dependent components may no longer represent the requested field, even if the ray still satisfies the stop constraint. Torch calls clear and bypass this cache whenever either backend gradient tracking or ambient autograd is enabled, so computation graphs are never reused. Backend, precision, device, and Torch inference-mode changes also clear numerical entries. The wrapped robust aimer's detached pupil-map seed cache is independent. Attributes: optic (Optic): The optical system being traced. wrapped_aimer (BaseRayAimer): The actual aiming strategy being cached. max_cache_size (int): Maximum number of entries in the cache. """ def __init__( self, optic: Optic, wrapped_aimer: BaseRayAimer, max_cache_size: int = 128, **kwargs: Any, ) -> None: """Initialize the CachedRayAimer. Args: optic (Optic): The optical system instance. wrapped_aimer (BaseRayAimer): The aimer instance to wrap. max_cache_size (int, optional): Max cache entries. Defaults to 128. **kwargs: Additional arguments passed to BaseRayAimer. """ super().__init__(optic, **kwargs) self.wrapped_aimer = wrapped_aimer self.max_cache_size = max_cache_size self._cache: dict[str, tuple[str, tuple]] = {} self._cache_context: tuple[str, int, str | None, bool] | None = None
[docs] def aim_rays( self, fields: tuple, wavelengths: Any, pupil_coords: tuple, initial_guess: tuple | None = None, ) -> tuple: """Calculate ray starting coordinates, using cache if available. Args: fields (tuple): Field coordinates. wavelengths (Any): Wavelengths. pupil_coords (tuple): Pupil coordinates. initial_guess (tuple | None, optional): Explicit starting guess. Returns: tuple: Ray parameters (x, y, z, L, M, N). """ # Invalidate before any early return, including explicit caller guesses, # so a bypass cannot leave incompatible entries for subsequent calls. backend = be.get_backend() bypass_cache = False if backend == "torch": import torch # Either Optiland's gradient control or ambient Torch autograd can # require a fresh graph. Equal numerical values do not identify # its lifetime or the parameter leaves to which it is attached. bypass_cache = be.grad_mode.requires_grad or torch.is_grad_enabled() if bypass_cache: # Discard earlier entries too, so returning to numerical execution # starts a new cache rather than reviving pre-gradient results. self.clear_cache() else: # Results must match the active backend, precision and device. # Inference mode also differs from no_grad: inference tensors cannot # be mutated outside inference mode or saved for backward. context = ( backend, be.get_precision(), be.get_device() if backend == "torch" else None, torch.is_inference_mode_enabled() if backend == "torch" else False, ) if context != self._cache_context: self.clear_cache() self._cache_context = context # If an explicit initial guess is provided, we skip the cache check if initial_guess is not None: return self.wrapped_aimer.aim_rays( fields, wavelengths, pupil_coords, initial_guess=initial_guess ) if bypass_cache: # Do not hash, read or store results in a differentiable call. return self.wrapped_aimer.aim_rays(fields, wavelengths, pupil_coords) # 1. Generate Input Hash input_key = self._get_input_hash(fields, wavelengths, pupil_coords) # 2. Generate System Hash current_sys_hash = self._get_system_hash() # 3. Check Cache cached_entry = self._cache.get(input_key) if cached_entry is not None: cached_sys_hash, cached_result = cached_entry if cached_sys_hash == current_sys_hash: # Exact match: inputs same, system same return cached_result # 4. Delegate to wrapped aimer # A full launch fixes the object point for finite conjugates or the # incident direction for infinite conjugates. The old launch can still # satisfy the stop constraint after a field edit, so do not use it as # an automatic initial guess. Omitting the keyword also supports aimers # whose public interface accepts only the three required arguments. result = self.wrapped_aimer.aim_rays(fields, wavelengths, pupil_coords) # 5. Update Cache self._cache[input_key] = (current_sys_hash, result) # 6. Manage Cache Size if len(self._cache) > self.max_cache_size: # Simple FIFO removal (Python dicts preserve insertion order) first_key = next(iter(self._cache)) del self._cache[first_key] return result
[docs] def clear_cache(self) -> None: """Clear the internal cache.""" self._cache.clear()
def _get_input_hash( self, fields: tuple, wavelengths: Any, pupil_coords: tuple ) -> str: """Generate a hash for the input parameters.""" def _to_hashable(obj: Any) -> Any: if hasattr(obj, "tobytes"): return obj.tobytes() elif isinstance(obj, list | tuple): return tuple(_to_hashable(x) for x in obj) return obj data = ( _to_hashable(fields), _to_hashable(wavelengths), _to_hashable(pupil_coords), ) return hashlib.md5(pickle.dumps(data)).hexdigest() def _get_system_hash(self) -> str: """Generate a hash for the current state of the optical system.""" data = ( self.optic.surfaces.to_dict(), self.optic.fields.to_dict(), self.optic.wavelengths.to_dict(), self.optic.aperture.to_dict() if self.optic.aperture else None, self.optic.ray_tracer.ray_aiming_config, ) # Array and tensor display formatting rounds values, so str(data) can # hide small field or aperture changes and produce a stale exact hit. # Serialize the numeric contents instead; no pickle payload is loaded. return hashlib.md5(pickle.dumps(data)).hexdigest()