"""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 numpy as np
import optiland.backend as be
from optiland.materials.base import BaseMaterial
from optiland.materials.buchdahl import buchdahl_coordinate, evaluate_buchdahl
[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])
coefficients_file = resources.files("optiland.database").joinpath(
"glass_model_coefficients.npy"
)
self._fit_coefficients = np.load(str(coefficients_file))
self._p = self._get_coefficients()
def _cache_state(self) -> tuple | None:
"""Track the parameters and fixed polynomial fit used by the wrapper."""
return BaseMaterial._state_key(
("polynomial", self.index, self.abbe, self._fit_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.")
# Recompute the small coefficient vector from live parameters. A stored
# derived graph cannot be reused after backward or a parameter update.
self._p = self._get_coefficients()
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
index = BaseMaterial._as_backend_array(self.index)
abbe = BaseMaterial._as_backend_array(self.abbe)
X_poly = be.ravel(
be.array(
[
index,
abbe,
index**2,
abbe**2,
index**3,
abbe**3,
]
)
)
coefficients = BaseMaterial._as_backend_array(self._fit_coefficients)
return be.matmul(X_poly, coefficients)
[docs]
class BuchdahlModel(AbbeModel):
"""Base class for Buchdahl 3-term models."""
ALPHA = 2.5
def __init__(self, index, abbe):
self.index = be.array([index])
self.abbe = be.array([abbe])
self.v1, self.v2, self.v3 = self._calculate_buchdahl_coefficients()
def _cache_state(self) -> tuple | None:
"""Track reference convention, live inputs and configurable fitted constants."""
return BaseMaterial._state_key(
(
"buchdahl",
self.index,
self.abbe,
self.WAVE_REF,
self.ALPHA,
getattr(self, "V1_COEFFS", None),
getattr(self, "V2_COEFFS", None),
getattr(self, "V3_COEFFS", None),
)
)
@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:
self.v1, self.v2, self.v3 = self._calculate_buchdahl_coefficients()
omega = buchdahl_coordinate(wavelength, self.WAVE_REF, self.ALPHA)
n_pred = evaluate_buchdahl(
BaseMaterial._as_backend_array(self.index),
(self.v1, self.v2, self.v3),
omega,
)
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 = BaseMaterial._as_backend_array(self.index)
vd = BaseMaterial._as_backend_array(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 = BaseMaterial._as_backend_array(self.index)
ve = BaseMaterial._as_backend_array(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
class _AbbeMaterialParameters:
"""Expose model-owned parameters identically through both public wrappers."""
model: AbbeModel
@property
def display_name(self) -> str:
"""Show the reference index and Abbe number with their line convention."""
label = f"{self.index.item():.4f}, {self.abbe.item():.2f}"
return label + (" (ne, Ve)" if isinstance(self, AbbeMaterialE) else "")
def spectral_range(self, property_name: str = "n") -> tuple[float, float] | None:
"""The polynomial fit's known range; Buchdahl has no declared limits."""
super().spectral_range(property_name)
return (
(0.38, 0.75)
if property_name == "n" and isinstance(self.model, AbbePolynomialModel)
else None
)
@property
def index(self):
"""The model's live reference-line refractive index."""
return self.model.index
@index.setter
def index(self, value):
self.model.index = value
@property
def abbe(self):
"""The model's live Abbe number in its reference-line convention."""
return self.model.abbe
@abbe.setter
def abbe(self, value):
self.model.abbe = value
def _model_cache_state(self) -> tuple | None:
if type(self.model) not in (
AbbePolynomialModel,
BuchdahlDModel,
BuchdahlEModel,
):
return None
return self.model._cache_state()
def _calculate_n(self, wavelength, **kwargs):
"""Evaluate the selected model from its authoritative parameters."""
return self.model.predict_n(wavelength)
def _calculate_k(self, wavelength, **kwargs):
"""Evaluate extinction through the existing Abbe model contract."""
return self.model.predict_k(wavelength)
[docs]
class AbbeMaterial(_AbbeMaterialParameters, 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__()
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 _cache_state(self) -> tuple | None:
"""Track the selected d-line model's authoritative parameters."""
return self._model_cache_state()
[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(_AbbeMaterialParameters, 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.model = BuchdahlEModel(n, abbe)
def _cache_state(self) -> tuple | None:
"""Track the e-line model's authoritative parameters."""
return self._model_cache_state()
[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"])