Source code for materials.material_file

"""Material File

This module contains a class for representing a material based on a material
YAML file from the refractiveindex.info database.

Kramer Harrison, 2024
"""

from __future__ import annotations

import contextlib
import os

import yaml

import optiland.backend as be
from optiland.materials.base import BaseMaterial
from optiland.materials.dispersion import evaluate_formula
from optiland.materials.rii import decode_formula, decode_table
from optiland.materials.spectral import (
    BoundsPolicy,
    interpolate_linear,
    validate_bounds,
)


[docs] class MaterialFile(BaseMaterial): """Represents a material based on a material YAML file from the refractiveindex.info database. Material refractive indices are based on various dispersion formulas or tabulated data. The material file contains the coefficients for the dispersion formulas and/or tabulated data. See https://refractiveindex.info/database/doc/Dispersion%20formulas.pdf Args: filename (str): The path to the material file. propagation_model (BasePropagationModel, optional): The propagation model to use. Defaults to None, which creates a HomogeneousPropagation model. bounds: Out-of-range policy for tabulated n/k. ``"clamp"`` (default) preserves endpoint values; ``"raise"`` rejects queries outside each table. This option does not change analytic formula evaluation. Attributes: filename (str): The filename of the material file. coefficients (list): A list of coefficients for calculating the refractive index. Methods: n(wavelength, temperature, pressure): Calculates the refractive index of the material at a given wavelength, temperature and pressure. k(wavelength): Retrieves the extinction coefficient of the material at a given wavelength. """ def __init__( self, filename, propagation_model=None, *, bounds: BoundsPolicy = "clamp" ): super().__init__(propagation_model) self._bounds = validate_bounds(bounds) self.filename = filename self._k_warning_printed = False self.coefficients = [] self.thermdispcoef = [] self._k_wavelength = None self._k = None self._n_formula = None self._n_wavelength = None self._n = None self._formula_wavelength_range = None self._t0 = None self.reference_data = None self.formula_map = { "formula 1": self._formula_1, "formula 2": self._formula_2, "formula 3": self._formula_3, "formula 4": self._formula_4, "formula 5": self._formula_5, "formula 6": self._formula_6, "formula 7": self._formula_7, "formula 8": self._formula_8, "formula 9": self._formula_9, "tabulated n": self._tabulated_n, "tabulated nk": self._tabulated_n, } data = self._read_file() self._parse_file(data) @property def bounds(self) -> BoundsPolicy: """The read-only out-of-range policy for tabulated n and k.""" return self._bounds def _cache_state(self) -> tuple | None: """Track the live optical arrays used by file and catalog materials.""" if not isinstance(self._n_formula, str): return None method_name = ( "_" + self._n_formula.replace(" ", "_") if self._n_formula.startswith("formula ") else "_tabulated_n" ) formula = self.formula_map.get(self._n_formula) if getattr(formula, "__func__", None) is not getattr( MaterialFile, method_name, None ): return None # A custom callable may depend on untracked mutable state. return self._state_key( ( self._n_formula, self.bounds, self.coefficients, self.thermdispcoef, self._t0, self._n_wavelength, self._n, self._k_wavelength, self._k, ) ) def _calculate_n(self, wavelength, **kwargs): """Calculates the refractive index of the material at given wavelengths. The method first calculates the refractive index from the dispersion formula, which is assumed to be relative to air at a reference temperature. If system temperature and pressure are provided, it applies a correction based on the material's thermal dispersion coefficients. Args: wavelength (float or be.ndarray): The wavelength(s) in microns. temperature (float): The material temperature in Celsius. If None, no thermal correction is applied. pressure (float): The ambient pressure in atmospheres (atm). Defaults to 1.0 atm in calculations if not provided but temperature is. Returns: float or be.ndarray: The refractive index(s) of the material. """ temperature = kwargs.get("temperature") pressure = kwargs.get("pressure") # Apply environmental corrections only if temperature data is available. if ( temperature is not None and self._t0 is not None and be.any(self._as_backend_array(self.thermdispcoef)) ): pressure = 1.0 if pressure is None else pressure # Calculate the 'relative' wavelength which is input wavelength scaled # to reference temperature and pressure waverel = ( wavelength * self._nair(wavelength, temperature, pressure) / self._nair(wavelength, self._t0, 1.0) ) # Calculate the baseline refractive index. This is relative to air at the # reference temperature (self._t0) and for 'relative' wavelength. base_relative_n = self.formula_map[self._n_formula](waverel) return self._apply_environmental_correction( base_relative_n, wavelength, temperature, pressure ) else: # If no temperature is given or material has no thermal data, # return the catalog value. # Calculate the baseline refractive index. This is relative to air at the # reference temperature (self._t0). base_relative_n = self.formula_map[self._n_formula](wavelength) return base_relative_n def _apply_environmental_correction( self, base_relative_n, wavelength, temp_c, pressure_atm ): """Applies temperature and pressure corrections to a refractive index. This method encapsulates the physics of converting from a relative index to absolute, applying thermal changes, and converting back to the new relative index. Args: base_relative_n (float or be.ndarray): The catalog refractive index relative to air at reference temperature. wavelength (float or be.ndarray): The wavelength(s) in microns. temp_c (float): The system temperature in Celsius. pressure_atm (float): The system pressure in atmospheres. Returns: float or be.ndarray: The corrected refractive index relative to the ambient conditions. """ # If pressure is not specified, assume standard atmosphere if pressure_atm is None: pressure_atm = 1.0 # Compute the index of air at the material's reference temperature and standard # pressure (1 atm) n_air_reference = self._nair(wavelength, self._t0, 1.0) # Compute the absolute index of the material (relative to vacuum) at its # reference temperature n_absolute_reference = base_relative_n * n_air_reference # Compute the change in the absolute index due to the temperature difference c = self._as_backend_array(self.thermdispcoef) delta_t = temp_c - self._t0 # This is the Schott formula for the change in absolute refractive index term1 = c[0] + c[1] * delta_t + c[2] * delta_t**2 term2 = (c[3] + c[4] * delta_t) / (wavelength**2 - c[5] ** 2) dn_abs = ( (n_absolute_reference**2 - 1.0) / (2.0 * n_absolute_reference) * (term1 + term2) * delta_t ) n_absolute_corrected = n_absolute_reference + dn_abs # Compute the index of air at the new system temperature and pressure. n_air_system = self._nair(wavelength, temp_c, pressure_atm) # Compute the final index of the material relative to the ambient air at the # new system conditions. final_relative_n = n_absolute_corrected / n_air_system return final_relative_n def _nair(self, wavelength_um, temp_c, pressure_atm=1.0): """Computes the refractive index of air This formula is a variant of the Edlén equation for the dispersion of air. Args: wavelength_um (float or be.ndarray): The wavelength(s) in microns. temp_c (float): The temperature of air in degrees Celsius. pressure_atm (float): The relative pressure in atmospheres (atm). Defaults to 1.0. Returns: float or be.ndarray: The refractive index of air. """ AIR_REF_TEMP_C = 15.0 AIR_THERMAL_COEFF = 0.0034785 # Corresponds to 3.4785 / 1000 # Calculate (n-1) for air at the reference temperature (15°C) # and 1 atm pressure using the dispersion formula. w2 = wavelength_um**2 n_ref_minus_1 = ( 6432.8 + (2949810 * w2) / (146 * w2 - 1) + (25540 * w2) / (41 * w2 - 1) ) * 1e-8 # Adjust for the actual system temperature and pressure. n_air = 1.0 + (n_ref_minus_1 * pressure_atm) / ( 1.0 + (temp_c - AIR_REF_TEMP_C) * AIR_THERMAL_COEFF ) return n_air def _calculate_k(self, wavelength, **kwargs): """Retrieves the extinction coefficient of the material at a given wavelength. If no exxtinction coefficient data is found, it is assumed to be 0 and prints a warning message, only once. Args: wavelength (float or be.ndarray): The wavelength(s) in microns. Returns: float or be.ndarray: The extinction coefficient of the material. If no data is available, returns 0.0 or an array of zeros. """ # If the extinction coefficient is missing from the file, return 0 if self._k is None or self._k_wavelength is None: if not self._k_warning_printed: material_name = os.path.basename(self.filename) print( f"WARNING: No extinction coefficient data found " f"for {material_name}. Assuming it is 0.", ) # we set it to True to avoid printing the warning again self._k_warning_printed = True if be.is_array_like(wavelength): return be.zeros_like(wavelength) return 0.0 return interpolate_linear( wavelength, self._as_backend_array(self._k_wavelength), self._as_backend_array(self._k), bounds=self.bounds, ) def _formula_1(self, w): """Delegate formula 1 to the shared dispersion evaluator.""" return evaluate_formula( "formula 1", self._as_backend_array(self.coefficients), w ) def _formula_2(self, w): """Delegate formula 2 to the shared dispersion evaluator.""" return evaluate_formula( "formula 2", self._as_backend_array(self.coefficients), w ) def _formula_3(self, w): """Delegate formula 3 to the shared dispersion evaluator.""" return evaluate_formula( "formula 3", self._as_backend_array(self.coefficients), w ) def _formula_4(self, w): """Delegate formula 4 to the shared dispersion evaluator.""" return evaluate_formula( "formula 4", self._as_backend_array(self.coefficients), w ) def _formula_5(self, w): """Delegate formula 5 to the shared dispersion evaluator.""" return evaluate_formula( "formula 5", self._as_backend_array(self.coefficients), w ) def _formula_6(self, w): """Delegate formula 6 to the shared dispersion evaluator.""" return evaluate_formula( "formula 6", self._as_backend_array(self.coefficients), w ) def _formula_7(self, w): """Delegate formula 7 to the shared dispersion evaluator.""" return evaluate_formula( "formula 7", self._as_backend_array(self.coefficients), w ) def _formula_8(self, w): """Delegate formula 8 to the shared dispersion evaluator.""" return evaluate_formula( "formula 8", self._as_backend_array(self.coefficients), w ) def _formula_9(self, w): """Delegate formula 9 to the shared dispersion evaluator.""" return evaluate_formula( "formula 9", self._as_backend_array(self.coefficients), w ) def _tabulated_n(self, w): """Calculate the refractive index using tabulated data. Args: w (float or be.ndarray): The wavelength(s) in microns. Returns: float or be.ndarray: Interpolated refractive index(s). """ try: return interpolate_linear( w, self._as_backend_array(self._n_wavelength), self._as_backend_array(self._n), bounds=self.bounds, ) except ValueError as err: # Typically if _n_wavelength or _n is None or empty raise ValueError( f"No tabular refractive index data found or data is invalid: {err}" ) from err def _read_file(self) -> dict: """Read the material YAML file. Returns: dict: Parsed YAML data. """ # The RefractiveIndex.INFO YAML files contain non-ASCII characters # (e.g. the degree sign, em dashes). Decode explicitly as UTF-8 so the # read does not depend on the platform's default text encoding -- on # Windows that is cp1252/cp936 (GBK), which raises UnicodeDecodeError. with open(self.filename, encoding="utf-8") as stream: return yaml.safe_load(stream) def _set_formula_type(self, formula_type): """Set the refractive index formula type.""" if self._n_formula is None: self._n_formula = formula_type else: raise ValueError("Multiple refractive index formulas found.") def _parse_file(self, data: dict) -> None: """Parse a material file's structured data. Args: data (dict): Dictionary containing parsed JSON/YAML material data. """ for sub_data in data.get("DATA", []): self._parse_sub_data(sub_data) self._parse_thermal_dispersion(data) self._parse_reference(data) def _parse_sub_data(self, sub_data: dict) -> None: """Parse a single DATA block from the material file.""" sub_data_type = sub_data.get("type", "") if sub_data_type.startswith("formula "): self._parse_formula_data(sub_data, sub_data_type) elif sub_data_type.startswith("tabulated"): self._parse_tabulated_data(sub_data, sub_data_type) def _parse_formula_data(self, sub_data: dict, sub_data_type: str) -> None: """Parse formula-based material data.""" definition = decode_formula({**sub_data, "type": sub_data_type}) # Build a 2D column array directly from Python values so the result is # always a graph-leaf (avoids a non-leaf view from reshape on a 1-D tensor). self.coefficients = be.array([[c] for c in definition.coefficients]) self._formula_wavelength_range = definition.wavelength_range_um self._set_formula_type(sub_data_type) def _parse_tabulated_data(self, sub_data: dict, sub_data_type: str) -> None: """Parse tabulated material data.""" index, extinction = decode_table({**sub_data, "type": sub_data_type}) if index is not None: self._n_wavelength = be.asarray(index.wavelengths_um) self._n = be.asarray(index.indices) self._set_formula_type(sub_data_type) if extinction is not None: self._k_wavelength = be.asarray(extinction.wavelengths_um) self._k = be.asarray(extinction.values) def _parse_thermal_dispersion(self, data: dict) -> None: """Parse thermal dispersion and reference temperature data.""" try: coeff = data["SPECS"]["thermal_dispersion"][0] if coeff.get("type", "").startswith("Schott"): td_values = [float(k) for k in coeff.get("coefficients", "").split()] self.thermdispcoef = be.array([[c] for c in td_values]) self._t0 = float(data["SPECS"]["temperature"].split(" ")[0]) except KeyError: pass # Optional field, safe to ignore def _parse_reference(self, data: dict) -> None: """Parse optional reference information.""" with contextlib.suppress(KeyError): self.reference_data = data["REFERENCE"] @property def display_name(self) -> str: """Show the source filename without resolving a catalog identity.""" return os.path.basename(self.filename)
[docs] def spectral_range(self, property_name: str = "n") -> tuple[float, float] | None: """Report table support or the formula's declared wavelength range.""" super().spectral_range(property_name) waves = self._n_wavelength if property_name == "n" else self._k_wavelength if waves is not None and len(waves): return float(waves[0]), float(waves[-1]) return self._formula_wavelength_range if property_name == "n" else None
[docs] def to_dict(self): """Returns the material data as a dictionary. Returns: dict: The material data. """ material_dict = super().to_dict() material_dict.update( { "filename": self.filename, "bounds": self.bounds, }, ) return material_dict
[docs] @classmethod def from_dict(cls, data): """Creates a material from a dictionary representation. Args: data (dict): The dictionary representation of the material. Returns: MaterialFile: An instance of MaterialFile. """ if "filename" not in data: raise ValueError("Material file data missing filename.") return cls(data["filename"], bounds=data.get("bounds", "clamp"))