Source code for optiland.nonsequential.materials.nsq_material

"""NSQ Material adapter -- thin differentiable wrapper over optiland.materials.

Evaluates refractive index as an attached computation node when the backend
is PyTorch, enabling gradients w.r.t. material dispersion parameters.

Kramer Harrison, 2026
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING, Union

import numpy as np

import optiland.backend as be

if TYPE_CHECKING:
    import torch

    from optiland.materials import BaseMaterial
    from optiland.nonsequential.bsdf.base import BaseBSDF

# Wavelength input: Python float, numpy array, or torch Tensor
WavelengthInput = Union[float, np.ndarray, "torch.Tensor"]


[docs] @dataclass class NSQMaterial: """Thin differentiable adapter over optiland.materials.BaseMaterial. Evaluates ``n(wavelength_um)`` without any grad-severing casts (no ``float()``, no ``np.asarray()``) so the result stays in the autograd graph when using the Torch backend. Attributes: optiland_material: Underlying material model. None means vacuum (n=1). bsdf: Optional surface scatter model. """ optiland_material: BaseMaterial | None = None bsdf: BaseBSDF | None = None
[docs] @classmethod def from_glass(cls, name: str) -> NSQMaterial: """Resolve a glass catalog name to an NSQMaterial. Args: name: Glass catalog name (e.g. ``'N-BK7'``, ``'SF11'``). Returns: NSQMaterial wrapping the resolved BaseMaterial. Raises: ValueError: If the glass name is not found in the catalog. """ from optiland.materials import Material # noqa: PLC0415 try: mat = Material(name) except Exception as exc: raise ValueError( f"Glass '{name}' not found in the Optiland material catalog." ) from exc return cls(optiland_material=mat)
[docs] def n(self, wavelength_um: WavelengthInput) -> WavelengthInput: """Refractive index at the given wavelength(s). Differentiable: when ``wavelength_um`` is a torch Tensor with ``requires_grad=True``, the returned value carries attached gradients. No ``float()`` or ``np.asarray()`` casts are applied to the result. Args: wavelength_um: Wavelength(s) in micrometres [µm]. Accepts Python float, NumPy ndarray, or torch Tensor. Returns: Refractive index with the same array type as the input. Returns ``1.0`` (scalar) for vacuum when input is a scalar, or a ones-like array/tensor matching the input shape for array inputs. """ if self.optiland_material is None: # Vacuum: return ones matching the input type/device try: return be.ones_like(wavelength_um) except (TypeError, AttributeError): return 1.0 # Pass wavelength directly -- preserves the grad graph return self.optiland_material.n(wavelength_um)
[docs] def k(self, wavelength_um: WavelengthInput) -> WavelengthInput: """Extinction coefficient at the given wavelength(s). Feeds Beer-Lambert bulk absorption: ``alpha = 4*pi*k/wavelength_um`` [1/um], matching ``optiland.propagation.homogeneous .HomogeneousPropagation`` so NSQ and the sequential engine attenuate a glass path by the same amount. Args: wavelength_um: Wavelength(s) in micrometres [µm]. Returns: Extinction coefficient (dimensionless). Returns ``0.0`` (scalar) for vacuum when input is a scalar, or a zeros-like array/tensor matching the input shape for array inputs. """ if self.optiland_material is None: # Vacuum: non-absorbing. try: return be.zeros_like(wavelength_um) except (TypeError, AttributeError): return 0.0 return self.optiland_material.k(wavelength_um)
# Module-level vacuum constant VACUUM: NSQMaterial = NSQMaterial(optiland_material=None) def medium_stack_id(material: NSQMaterial) -> int: """Canonical integer id for a medium, for the ray-level medium stack. Every vacuum-like material (``optiland_material is None``) maps to the same id ``0`` regardless of which ``NSQMaterial`` instance wraps it -- two separately constructed vacuum wrappers are physically the same medium. Any other material is identified by Python object identity: two ``NSQMaterial`` instances that happen to wrap the same physical glass are only treated as the same medium if the scene reuses one instance for both (as :class:`~optiland.nonsequential.components.volume.Volume` and the ``Lens``/``Doublet`` builders do for a shared interior). This is a conservative default -- reusing distinct instances for the same physical glass on either side of a gap can produce a spurious ``medium_stack_underflows`` count, never a wrong flux/index result (the stack is a diagnostic cross-check; ``n1``/``n2`` are always resolved geometrically, never from the stack). Args: material: The medium to identify. Returns: A stable (for the process lifetime) non-negative integer id. """ if material.optiland_material is None: return 0 return id(material)