Source code for materials.base

"""Base Material

This module defines the base class for materials. The base class provides
methods to calculate the refractive index, extinction coefficient, and Abbe
number of a material. Subclasses implement `_calculate_n` and `_calculate_k`;
the public methods manage evaluation and optional caching.

Kramer Harrison, 2024
"""

from __future__ import annotations

import hashlib
import math
from abc import ABC, abstractmethod

import numpy as np

import optiland.backend as be
from optiland._suggest import options_hint
from optiland.propagation.base import BasePropagationModel
from optiland.propagation.homogeneous import HomogeneousPropagation

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


def _array_content_key(value, digest: bytes) -> tuple:
    """Assemble a cache-key tuple from a content digest plus coarse metadata."""
    return (
        "array-content",
        digest,
        tuple(getattr(value, "shape", ())),
        str(getattr(value, "dtype", type(value).__name__)),
        str(getattr(value, "device", None)),
    )


def _array_uniform_key(value, scalar: float) -> tuple:
    """Assemble a cache-key tuple for an array whose elements are all equal."""
    return (
        "array-uniform",
        scalar,
        tuple(getattr(value, "shape", ())),
        str(getattr(value, "dtype", type(value).__name__)),
        str(getattr(value, "device", None)),
    )


def _uniform_scalar(value) -> float | None:
    """Return the common value of a constant array, or None if not constant.

    Ray bundles traced at a single wavelength carry that wavelength repeated
    per ray, so the by-far most common "large array" seen here is a broadcast
    constant. Detecting it costs one on-device reduction instead of the full
    device-to-host copy plus O(N) hash of the content-digest path, and lets
    the property caches store a single value instead of an N-element result.
    """
    try:
        if torch is not None and isinstance(value, torch.Tensor):
            # The key only needs the numeric value; detaching avoids the
            # scalar-conversion warning for grad-attached bundles and keeps
            # the uniformity probe off the autograd graph.
            detached = value.detach()
            first = detached.reshape(-1)[0]
            if bool(torch.eq(detached, first).all()):
                return float(first)
            return None
        array = np.asarray(value)
        first = array.flat[0]
        if np.all(array == first):
            return float(first)
        return None
    except (IndexError, TypeError, ValueError, RuntimeError):
        return None


[docs] class BaseMaterial(ABC): """Base class for materials. This class defines the interface for material properties such as refractive index (n) and extinction coefficient (k). It also provides a method to calculate the Abbe number. Subclasses implement `_calculate_n` and `_calculate_k`. Result caching is optional: override `_cache_state` only when all optical state can be tracked. Attributes: propagation_model: The model used to propagate rays through this material. Methods: n(wavelength: float | be.ndarray) -> float | be.ndarray: Abstract method to calculate the refractive index at a given wavelength(s) in microns. k(wavelength: float | be.ndarray) -> float | be.ndarray: Abstract method to calculate the extinction coefficient at a given wavelength(s) in microns. abbe() -> float: Method to calculate the Abbe number of the material. """ _registry = {} _MAX_VALUE_KEY_ARRAY_SIZE = 1024 def __init__(self, propagation_model: BasePropagationModel | None = None): """Initializes the material and its caches. Args: propagation_model: The propagation model to use for this material. If None, a default HomogeneousPropagation model is created. """ self._n_cache = {} self._k_cache = {} self._cache_context = None if propagation_model is None: self.propagation_model = HomogeneousPropagation(self) else: self.propagation_model = propagation_model def __init_subclass__(cls, **kwargs): """Automatically register subclasses.""" super().__init_subclass__(**kwargs) BaseMaterial._registry[cls.__name__] = cls def __eq__(self, value: object) -> bool: return isinstance(value, type(self)) and value.to_dict() == self.to_dict() @classmethod def _array_size(cls, value) -> int | None: """Return the total element count for array-like values if available.""" shape = getattr(value, "shape", None) if shape is None: return None try: return math.prod(shape) except Exception: return None @staticmethod def _array_metadata_key(value) -> tuple: """Build a content-addressed cache key for large arrays. The key identifies an array by its *contents*, never by its memory location. Raw buffer pointers (``ndarray.__array_interface__["data"]``, ``Tensor.data_ptr()``) and ``id()`` are reused by the allocator once an array is garbage-collected, so two distinct wavelength arrays of the same shape/dtype that reuse a freed slot would collide and ``n()`` would return the previous array's refractive index -- a silent cross-wavelength leak (issue #630). Inspect caller-owned storage on every lookup. NumPy has no mutation counter, Torch inference tensors are mutable without a version counter, and writes through a NumPy alias do not increment a Torch version. Identity/version memoization therefore cannot guarantee correctness. Constant arrays -- the overwhelmingly common case, since a ray bundle traced at one wavelength repeats that wavelength per ray -- are detected first and keyed by their single value, skipping the device-to-host copy and O(N) hash entirely while remaining content-addressed. """ # Lists and tuples have no shape/dtype attributes. Normalize only the # key input so nested and flat sequences cannot share a fingerprint. if isinstance(value, (list, tuple)): value = np.asarray(value) scalar = _uniform_scalar(value) if scalar is not None: key = _array_uniform_key(value, scalar) else: if hasattr(value, "detach"): # torch tensor array = value.detach().cpu().contiguous().numpy() else: # numpy ndarray, list, or tuple array = np.ascontiguousarray(np.asarray(value)) digest = hashlib.sha256(memoryview(array)).digest() key = _array_content_key(value, digest) return key def _create_cache_key(self, wavelength: float | be.ndarray, **kwargs) -> tuple: """Creates a hashable cache key from wavelength and kwargs.""" if be.is_array_like(wavelength): size = self._array_size(wavelength) if size is not None and size <= self._MAX_VALUE_KEY_ARRAY_SIZE: wavelength_key = ( "array-values", tuple(np.ravel(be.to_numpy(wavelength))), tuple(wavelength.shape), str(wavelength.dtype), str(getattr(wavelength, "device", None)), ) else: wavelength_key = self._array_metadata_key(wavelength) else: wavelength_key = (type(wavelength), wavelength) return (wavelength_key, self._state_key(tuple(sorted(kwargs.items())))) def _cache_state(self) -> tuple | None: """Return a hashable optical-state token, or None to disable caching. An immutable model can return an empty tuple. Mutable models must include every parameter affecting n/k, and return None while parameters require gradients. `_state_key` fingerprints ordinary numerical data safely, including arrays mutated through aliases. Unknown custom state defaults to uncached evaluation; subclasses need not opt in to remain usable. Each concrete subclass must explicitly override this hook, including subclasses of built-in materials that might add optical state. """ return None @classmethod def _state_key(cls, value) -> tuple | None: """Fingerprint numerical state; unsupported or trainable data disables reuse.""" if cls._requires_grad(value): return None if isinstance(value, (tuple, list)): items = [] for item in value: key = cls._state_key(item) if key is None: return None items.append(key) return tuple(items) if isinstance(value, (str, int, float, bool, type(None))): return (type(value), value) if isinstance(value, np.ndarray) or ( torch is not None and isinstance(value, torch.Tensor) ): if cls._array_size(value) <= cls._MAX_VALUE_KEY_ARRAY_SIZE: # Small live parameter arrays need a snapshot, not a uniformity # reduction or digest. Copy bytes so subsequent alias writes # cannot alter the stored state token. array = be.to_numpy(value) return _array_content_key(value, array.tobytes()) return cls._array_metadata_key(value) return None @staticmethod def _backend_context() -> tuple: """Identify result type, precision, device and backend-created gradients.""" backend = be.get_backend() device = be.get_device() if backend == "torch" else "cpu" gradients = be.grad_mode.requires_grad if backend == "torch" else False inference = torch.is_inference_mode_enabled() if backend == "torch" else False return backend, be.get_precision(), device, gradients, inference @staticmethod def _as_backend_array(value, *, preserve_dtype: bool = False): """Use live parameters in the active backend without replacing their storage. Typed parameters can opt out of conversion to the backend's default precision. Untyped values still use that default. """ if be.get_backend() == "numpy" and hasattr(value, "detach"): value = value.detach().cpu().numpy() if preserve_dtype and hasattr(value, "dtype"): return be.asarray(value, dtype=None) return be.asarray(value) @staticmethod def _requires_grad(value) -> bool: """Check if a value is a torch tensor that requires gradient.""" return hasattr(value, "requires_grad") and value.requires_grad @staticmethod def _compute_grad_aware(calculate, wavelength, **kwargs): """Evaluate a property calculation with grad recording enabled. The first evaluation of a material property can happen inside a ``torch.no_grad()`` block -- the implicit-differentiation primal solves trace the system graph-free. Computed under ``no_grad``, a value derived from a *trainable* parameter reports ``requires_grad == False``, so the do-not-cache-differentiable-values check below would mistake it for a constant and cache it detached; every later grad-attached trace would then read the stale detached value and the parameter's gradient would silently collapse to zero. Evaluating under ``torch.enable_grad()`` (the same principle as the Forbes coefficient-cache fix) keeps a grad-connected result recognizable regardless of the caller's ambient grad context. Only this evaluation is recorded; the caller's ``no_grad`` scope still applies to everything downstream. """ if torch is not None and be.get_backend() == "torch": with torch.enable_grad(): return calculate(wavelength, **kwargs) return calculate(wavelength, **kwargs) @staticmethod def _detach_if_tensor(value): """Detach a torch tensor to sever the computation graph link. This prevents the 'backward through the graph a second time' error that occurs when a cached tensor still references a freed computation graph. """ if hasattr(value, "detach"): return value.detach() return value @staticmethod def _is_uniform_key(cache_key: tuple) -> bool: """True when the key's wavelength part marks a constant array.""" wavelength_key = cache_key[0] return ( isinstance(wavelength_key, tuple) and len(wavelength_key) > 0 and wavelength_key[0] == "array-uniform" ) @staticmethod def _uniform_representative(wavelength): """A 1-element view of a constant wavelength array (same dtype/device).""" if torch is not None and isinstance(wavelength, torch.Tensor): return wavelength.reshape(-1)[:1] return np.asarray(wavelength).reshape(-1)[:1] @staticmethod def _broadcast_like(value, wavelength): """Expand a single-value result to the wavelength's shape as a view. The expansion allocates no memory (stride-0 view), so a property evaluated once per wavelength serves bundles of any size for free. """ if torch is not None and isinstance(value, torch.Tensor): return value.reshape(1).expand(tuple(np.shape(wavelength))) return np.broadcast_to(np.asarray(value).reshape(1), np.shape(wavelength)) def _evaluate_property(self, property_name: str, wavelength, **kwargs): """Evaluate n/k with one state-aware cache and fresh gradient graphs.""" calculate = getattr(self, f"_calculate_{property_name}") if self._requires_grad(wavelength): return self._compute_grad_aware(calculate, wavelength, **kwargs) state = self._cache_state() if "_cache_state" in type(self).__dict__ else None kwargs_state = self._state_key(tuple(sorted(kwargs.items()))) if state is None or kwargs_state is None: self._n_cache.clear() self._k_cache.clear() self._cache_context = None return self._compute_grad_aware(calculate, wavelength, **kwargs) context = (self._backend_context(), state) if context != self._cache_context: self._n_cache.clear() self._k_cache.clear() self._cache_context = context # Only non-trainable, tracked inputs reach cache lookup or reduction. # Equal values do not imply equal derivatives for each query element. cache = self._n_cache if property_name == "n" else self._k_cache cache_key = self._create_cache_key(wavelength, **kwargs) uniform = self._is_uniform_key(cache_key) and all( value is None or np.isscalar(value) for value in kwargs.values() ) if cache_key not in cache: query = self._uniform_representative(wavelength) if uniform else wavelength result = self._compute_grad_aware(calculate, query, **kwargs) if self._requires_grad(result): return self._broadcast_like(result, wavelength) if uniform else result cache[cache_key] = self._detach_if_tensor(result) result = cache[cache_key] return self._broadcast_like(result, wavelength) if uniform else result
[docs] def n(self, wavelength: float | be.ndarray, **kwargs) -> float | be.ndarray: """Calculates the refractive index at a given wavelength with caching. Args: wavelength (float | be.ndarray): The wavelength(s) of light in microns. Can be a float, numpy array, or torch tensor. **kwargs: Additional keyword arguments for calculation (e.g., temperature). Returns: float | be.ndarray: The refractive index at the given wavelength(s). """ return self._evaluate_property("n", wavelength, **kwargs)
[docs] def k(self, wavelength: float | be.ndarray, **kwargs) -> float | be.ndarray: """Calculates the extinction coefficient at a given wavelength with caching. Args: wavelength (float | be.ndarray): The wavelength(s) of light in microns. Can be a float, numpy array, or torch tensor. **kwargs: Additional keyword arguments for calculation. Returns: float | be.ndarray: The extinction coefficient at the given wavelength(s). """ return self._evaluate_property("k", wavelength, **kwargs)
@abstractmethod def _calculate_n( self, wavelength: float | be.ndarray, **kwargs ) -> float | be.ndarray: """Calculates the refractive index at a given wavelength. Args: wavelength (float | be.ndarray): The wavelength(s) of light in microns. Returns: float | be.ndarray: The refractive index at the given wavelength(s). """ pass # pragma: no cover @abstractmethod def _calculate_k( self, wavelength: float | be.ndarray, **kwargs ) -> float | be.ndarray: """Calculates the extinction coefficient at a given wavelength. Args: wavelength (float | be.ndarray): The wavelength(s) of light in microns. Returns: float | be.ndarray: The extinction coefficient at the given wavelength(s). """ pass # pragma: no cover
[docs] def abbe(self) -> float: """Calculate the Abbe number (Vd) of the material. The Abbe number is a measure of the material's dispersion, defined as Vd = (n_d - 1) / (n_F - n_C), where n_d, n_F, and n_C are the refractive indices at the Fraunhofer d (587.5618 nm), F (486.1327 nm), and C (656.2725 nm) spectral lines, respectively. Returns: float: The Abbe number of the material. """ nD = self.n(0.5875618) nF = self.n(0.4861327) nC = self.n(0.6562725) return (nD - 1) / (nF - nC)
[docs] def to_dict(self): """Convert the material to a dictionary. Returns: dict: The dictionary representation of the material. """ return { "type": self.__class__.__name__, "propagation_model": self.propagation_model.to_dict(), }
[docs] @classmethod def from_dict(cls, data): """Create a material from a dictionary representation. This factory method first delegates to the appropriate subclass to create the material instance, then handles the deserialization of the propagation model. Args: data (dict): The dictionary representation of the material. Returns: BaseMaterial: An instance of a specific material subclass created from the dictionary data. """ material_type = data.get("type") if material_type not in cls._registry: raise ValueError( f"Unknown material type {material_type!r} in the material data." f"{options_hint(str(material_type), cls._registry)}" ) # Delegate to the correct subclass to create the instance. material_subclass = cls._registry[material_type] material = material_subclass.from_dict(data) # Handle the propagation model deserialization here. propagation_model_data = data.get("propagation_model") if propagation_model_data: # Create the model, passing the material to resolve dependencies. new_prop_model = BasePropagationModel.from_dict( propagation_model_data, material=material ) # Overwrite the default propagation model. material.propagation_model = new_prop_model return material