Source code for materials.abbe

"""Abbe Material

This module defines a material based on the refractive index and Abbe number.
It provides support for both d-line (587.56 nm) and e-line (546.07 nm)
definitions.

Models:
1.  "polynomial": A polynomial fit to glass data (Legacy, D-line only).
2.  "buchdahl": A Buchdahl 3-term model with LASSO-derived coefficients.

Kramer Harrison, 2024
"""

from __future__ import annotations

import warnings
from abc import ABC, abstractmethod
from importlib import resources

import optiland.backend as be
from optiland.materials.base import BaseMaterial


[docs] class AbbeModel(ABC): """Abstract base class for Abbe number based material models."""
[docs] @abstractmethod def predict_n(self, wavelength: float | be.ndarray) -> float | be.ndarray: """Predicts the refractive index at a given wavelength.""" pass
[docs] @abstractmethod def predict_k(self, wavelength: float | be.ndarray) -> float | be.ndarray: """Predicts the extinction coefficient at a given wavelength.""" pass
[docs] class AbbePolynomialModel(AbbeModel): """Legacy polynomial model for Abbe materials (d-line). This model uses a polynomial fit to glass data from the Schott catalog. It corresponds to the original implementation of AbbeMaterial. """ def __init__(self, index: float, abbe: float): self.index = be.array([index]) self.abbe = be.array([abbe]) self._p = self._get_coefficients()
[docs] def predict_n(self, wavelength: float | be.ndarray) -> float | be.ndarray: wavelength = be.array(wavelength) if be.any(wavelength < 0.380) or be.any(wavelength > 0.750): # This legacy check is preserved raise ValueError("Wavelength out of range for this model.") return be.atleast_1d(be.polyval(self._p, wavelength))
[docs] def predict_k(self, wavelength: float | be.ndarray) -> float | be.ndarray: return be.zeros_like(0)
def _get_coefficients(self): # Polynomial fit to the refractive index data X_poly = be.ravel( be.array( [ self.index, self.abbe, self.index**2, self.abbe**2, self.index**3, self.abbe**3, ] ) ) coefficients_file = str( resources.files("optiland.database").joinpath( "glass_model_coefficients.npy", ), ) coefficients = be.load(coefficients_file) return be.matmul(X_poly, coefficients)
[docs] class BuchdahlModel(AbbeModel): """Base class for Buchdahl 3-term models.""" ALPHA = 2.5 def __init__(self, index: float, abbe: float): self.index = float(index) self.abbe = float(abbe) self.v1, self.v2, self.v3 = self._calculate_buchdahl_coefficients() @property @abstractmethod def WAVE_REF(self) -> float: """Reference wavelength in microns.""" pass @abstractmethod def _calculate_buchdahl_coefficients(self) -> tuple[float, float, float]: """Calculates v1, v2, v3 based on index and abbe.""" pass
[docs] def predict_n(self, wavelength: float | be.ndarray) -> float | be.ndarray: wavelength = be.array(wavelength) # Calculate Buchdahl coordinate omega # omega = (lambda - lambda_d) / (1 + alpha * (lambda - lambda_d)) d_lambda = wavelength - self.WAVE_REF omega = d_lambda / (1 + self.ALPHA * d_lambda) # Buchdahl polynomial: n = nd + v1*w + v2*w^2 + v3*w^3 n_pred = ( self.index + self.v1 * omega + self.v2 * (omega**2) + self.v3 * (omega**3) ) return be.atleast_1d(n_pred)
[docs] def predict_k(self, wavelength: float | be.ndarray) -> float | be.ndarray: return be.zeros_like(0)
[docs] class BuchdahlDModel(BuchdahlModel): """Buchdahl model for d-line (587.56 nm) materials.""" WAVE_REF = 0.5875618 # Coefficients for v1 prediction V1_COEFFS = [ 0.004160, # Intercept 4.462559, # 1/V 2.326660, # 1/V^2 0.002330, # n -0.003697, # n^2 -4.697604, # n/V ] # Coefficients for v2 prediction V2_COEFFS = [ 0.066434, # Intercept -7.636396, # 1/V 12.597434, # 1/V^2 -0.037014, # n^2 5.551013, # n/V ] # Coefficients for v3 prediction V3_COEFFS = [ -0.032218, # Intercept 2.230357, # 1/V -103.318994, # 1/V^2 -0.009654, # n^2 1.934983, # n/V ] def _calculate_buchdahl_coefficients(self): nd = self.index vd = self.abbe inv_v = 1.0 / vd inv_v2 = 1.0 / (vd**2) nd_sq = nd**2 nd_div_v = nd / vd # Calculate v1 # Terms: [1, 1/V, 1/V^2, n, n^2, n/V] c = self.V1_COEFFS v1 = ( c[0] + c[1] * inv_v + c[2] * inv_v2 + c[3] * nd + c[4] * nd_sq + c[5] * nd_div_v ) # Calculate v2 # Terms: [1, 1/V, 1/V^2, n^2, n/V] c = self.V2_COEFFS v2 = c[0] + c[1] * inv_v + c[2] * inv_v2 + c[3] * nd_sq + c[4] * nd_div_v # Calculate v3 # Terms: [1, 1/V, 1/V^2, n^2, n/V] c = self.V3_COEFFS v3 = c[0] + c[1] * inv_v + c[2] * inv_v2 + c[3] * nd_sq + c[4] * nd_div_v return v1, v2, v3
[docs] class BuchdahlEModel(BuchdahlModel): """Buchdahl model for e-line (546.07 nm) materials.""" WAVE_REF = 0.546074 def _calculate_buchdahl_coefficients(self): ne = self.index ve = self.abbe inv_v = 1.0 / ve inv_v2 = 1.0 / (ve**2) n_sq = ne**2 n_div_v = ne / ve # Derived via Lasso regression v1 = ( -0.01271580 + 5.86039368 * inv_v + 0.00000000 * inv_v2 - 0.00840567 * n_sq - 6.04120358 * n_div_v ) v2 = ( -0.11714561 - 19.45035516 * inv_v + 0.00000000 * inv_v2 - 0.18747797 * n_sq + 14.33541100 * n_div_v ) v3 = ( 0.00000000 + 18.43536735 * inv_v - 241.00526954 * inv_v2 + 0.10881050 * n_sq - 4.93439893 * n_div_v ) return v1, v2, v3
[docs] class AbbeMaterial(BaseMaterial): """Represents a material based on the refractive index and Abbe number. This class serves as a wrapper around specific model implementations. Currently supported models: - 'polynomial': The legacy polynomial fit (Default, deprecated). - 'buchdahl': The new Buchdahl 3-term model (Recommended). Args: n (float): The refractive index of the material at 587.56 nm (n_d). abbe (float): The Abbe number of the material (V_d). model (str, optional): The model to use. Defaults to "polynomial". Valid options are "polynomial" and "buchdahl". Attributes: index (float): The refractive index of the material at 587.56 nm. abbe (float): The Abbe number of the material. model_name (str): The name of the model being used. model (AbbeModel): The underlying model instance. """ def __init__(self, n, abbe, model=None): super().__init__() self.index = be.array([n]) self.abbe = be.array([abbe]) if model is None: warnings.warn( "The default model for AbbeMaterial will change from 'polynomial' " "to 'buchdahl' in v0.7.0. The 'buchdahl' model offers improved " "accuracy. To silence this warning, specify `model='polynomial'` " "explicitly if you intended to use the legacy model, or switch to " "`model='buchdahl'`.", FutureWarning, stacklevel=2, ) model = "polynomial" self.model_name = model if model == "polynomial": self.model = AbbePolynomialModel(n, abbe) elif model == "buchdahl": self.model = BuchdahlDModel(n, abbe) else: raise ValueError( f"Unknown model: {model}. Valid options: 'polynomial', 'buchdahl'" ) def _calculate_n(self, wavelength, **kwargs): """Returns the refractive index of the material.""" return self.model.predict_n(wavelength) def _calculate_k(self, wavelength, **kwargs): """Returns the extinction coefficient of the material.""" return self.model.predict_k(wavelength)
[docs] def to_dict(self): """Returns a dictionary representation of the material.""" material_dict = super().to_dict() material_dict.update( { "index": float(self.index.item()), "abbe": float(self.abbe.item()), "model": self.model_name, } ) return material_dict
[docs] @classmethod def from_dict(cls, data): """Creates a material from a dictionary representation.""" required_keys = ["index", "abbe"] for key in required_keys: if key not in data: raise ValueError(f"Missing required key: {key}") model = data.get("model") return cls(data["index"], data["abbe"], model=model)
[docs] class AbbeMaterialE(BaseMaterial): """Represents a material based on the refractive index and Abbe number at e-line. This class uses a Buchdahl 3-term model fitted to e-line (546.07 nm) data. Args: n (float): The refractive index of the material at 546.07 nm (n_e). abbe (float): The Abbe number of the material (V_e). Attributes: index (float): The refractive index of the material at 546.07 nm. abbe (float): The Abbe number of the material (V_e). model (BuchdahlEModel): The underlying model instance. """ def __init__(self, n, abbe): super().__init__() self.index = be.array([n]) self.abbe = be.array([abbe]) self.model = BuchdahlEModel(n, abbe) def _calculate_n(self, wavelength, **kwargs): """Returns the refractive index of the material.""" return self.model.predict_n(wavelength) def _calculate_k(self, wavelength, **kwargs): """Returns the extinction coefficient of the material.""" return self.model.predict_k(wavelength)
[docs] def to_dict(self): """Returns a dictionary representation of the material.""" material_dict = super().to_dict() material_dict.update( { "index": float(self.index.item()), "abbe": float(self.abbe.item()), } ) return material_dict
[docs] @classmethod def from_dict(cls, data): """Creates a material from a dictionary representation.""" required_keys = ["index", "abbe"] for key in required_keys: if key not in data: raise ValueError(f"Missing required key: {key}") return cls(data["index"], data["abbe"])