Source code for materials.data

"""Optical materials built from owned data, with self-contained native persistence."""

from __future__ import annotations

import json
from copy import deepcopy
from typing import TYPE_CHECKING, Any

import optiland.backend as be
from optiland.materials.base import BaseMaterial
from optiland.materials.definition import (
    IndexTable,
    MaterialDefinition,
    definition_from_dict,
    definition_to_dict,
)
from optiland.materials.dispersion import evaluate_formula
from optiland.materials.spectral import (
    BoundsPolicy,
    checked_wavelengths,
    interpolate_linear,
    validate_bounds,
)

if TYPE_CHECKING:
    from optiland.propagation.base import BasePropagationModel


[docs] class DataMaterial(BaseMaterial): """Own sampled or analytic dispersion and optional measured extinction. Use ``from_samples`` or ``from_coefficients`` to construct optical data, or pass an explicit native definition. No file or registry is accessed. Definition records are immutable; create a replacement to change optical data. Wavelengths use micrometers and extinction k is dimensionless. Args: definition: Native dispersion and optional extinction objects. name: Descriptive label, never a catalog-lookup instruction. metadata: JSON-compatible descriptive provenance, copied on input/output. propagation_model: Optional registered propagation model. bounds: Tabulated n/k queries outside their respective sample intervals raise by default. ``"clamp"`` holds the nearest endpoint value. Analytic formula validity limits remain enforced independently. """ def __init__( self, definition: dict[str, Any], *, name: str = "", metadata: dict[str, Any] | None = None, propagation_model: BasePropagationModel | None = None, bounds: BoundsPolicy = "raise", ) -> None: super().__init__(propagation_model) self._bounds = validate_bounds(bounds) if not isinstance(name, str): raise ValueError("Material name must be a string") if metadata is not None and not isinstance(metadata, dict): raise ValueError("Material metadata must be an object") self._definition = definition_from_dict(definition) self.name = name try: self._metadata = json.loads(json.dumps(metadata or {}, allow_nan=False)) except (TypeError, ValueError, OverflowError) as error: raise ValueError( "Material metadata must contain finite JSON data" ) from error
[docs] @classmethod def from_samples( cls, wavelengths: Any, indices: Any, *, name: str = "", extinction: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None, propagation_model: BasePropagationModel | None = None, bounds: BoundsPolicy = "raise", ) -> DataMaterial: """Interpolate n/k samples; optionally clamp outside each table's interval.""" return cls( { "dispersion": { "kind": "tabulated", "wavelengths_um": wavelengths, "indices": indices, }, "extinction": extinction, }, name=name, metadata=metadata, propagation_model=propagation_model, bounds=bounds, )
[docs] @classmethod def from_coefficients( cls, formula: str, coefficients: Any, *, name: str = "", wavelength_range: Any = None, extinction: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None, propagation_model: BasePropagationModel | None = None, bounds: BoundsPolicy = "raise", ) -> DataMaterial: """Preserve an analytic equation; ``bounds`` controls tabulated k only. A supplied formula wavelength range (in µm) is always enforced. """ return cls( { "dispersion": { "kind": "formula", "formula": formula, "coefficients": coefficients, "wavelength_range_um": wavelength_range, }, "extinction": extinction, }, name=name, metadata=metadata, propagation_model=propagation_model, bounds=bounds, )
@property def bounds(self) -> BoundsPolicy: """The read-only out-of-range policy for tabulated n and k.""" return self._bounds @property def definition(self) -> MaterialDefinition: """The read-only optical definition, independent of descriptive metadata.""" return self._definition @property def metadata(self) -> dict[str, Any]: """An independent copy of optional provenance.""" return deepcopy(self._metadata) @property def display_name(self) -> str: """Use the optional descriptive name without inventing catalog identity.""" return self.name or "DataMaterial"
[docs] def spectral_range(self, property_name: str = "n") -> tuple[float, float] | None: """Bounds of the requested property; absent extinction is unbounded zero.""" super().spectral_range(property_name) data = ( self.definition.dispersion if property_name == "n" else self.definition.extinction ) if data is None: return None if hasattr(data, "wavelengths_um"): return data.wavelengths_um[0], data.wavelengths_um[-1] return data.wavelength_range_um
def _cache_state(self) -> tuple: """Track the owned definition and its table evaluation policy.""" return (self._definition, self.bounds) def _calculate_n(self, wavelength: Any, **kwargs: Any) -> Any: index = self.definition.dispersion if isinstance(index, IndexTable): wave = checked_wavelengths(wavelength) return interpolate_linear( wave, index.wavelengths_um, index.indices, bounds=self.bounds ) wave = checked_wavelengths(wavelength, index.wavelength_range_um) result = evaluate_formula(index.formula, be.asarray(index.coefficients), wave) result = result + wave * 0 # Constant formulas retain the full query shape. if not be.all(be.isfinite(result)): raise ValueError( "Dispersion formula produced a non-finite refractive index" ) return result def _calculate_k(self, wavelength: Any, **kwargs: Any) -> Any: wave = checked_wavelengths(wavelength) table = self.definition.extinction if table is None: return wave * 0 return interpolate_linear( wave, table.wavelengths_um, table.values, bounds=self.bounds )
[docs] def to_dict(self) -> dict[str, Any]: """Serialize the complete definition inline with independent containers.""" return { **super().to_dict(), "name": self.name, "definition": definition_to_dict(self.definition), "bounds": self.bounds, "metadata": self.metadata, }
[docs] @classmethod def from_dict(cls, data: dict[str, Any]) -> DataMaterial: """Restore owned optical data; BaseMaterial restores propagation dispatch.""" return cls( data["definition"], name=data.get("name", ""), metadata=data.get("metadata"), bounds=data["bounds"], )