# The Clear BSD License
#
# Copyright (c) 2026 Tobias Heibges
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted (subject to the limitations in the disclaimer
# below) provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
#
# * Neither the name of the copyright holder nor the names of its
# contributors may be used to endorse or promote products derived from this
# software without specific prior written permission.
#
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
"""
Homogeneous Material Implementation
A material with uniform optical properties throughout the volume.
Supports wavelength-dependent refractive index via Sellmeier or Cauchy models.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import TYPE_CHECKING
import numpy as np
from numpy.typing import NDArray
from .material_field import MaterialField
if TYPE_CHECKING:
from ...propagation.kernels.registry import PropagatorID
[docs]
class HomogeneousMaterial(MaterialField):
"""
Homogeneous material with constant optical properties.
Properties are uniform throughout space but can depend on wavelength.
Supports analytic dispersion models (Sellmeier, Cauchy) or custom functions.
Parameters
----------
name : str
Descriptive name for this material.
refractive_index : float or callable
Refractive index value or function f(wavelength) -> n.
If float, uses constant n for all wavelengths.
If callable, wavelength is in meters.
absorption_coef : float, optional
Absorption coefficient α in m⁻¹. Default is 0.
scattering_coef : float, optional
Scattering coefficient μ_s in m⁻¹. Default is 0.
anisotropy : float, optional
Henyey-Greenstein anisotropy factor g ∈ [-1, 1]. Default is 0.
Attributes
----------
refractive_index : float or callable
Refractive index specification.
absorption_coef : float
Absorption coefficient in m⁻¹.
scattering_coef : float
Scattering coefficient in m⁻¹.
anisotropy : float
Scattering anisotropy factor.
Examples
--------
>>> # Constant refractive index
>>> glass = HomogeneousMaterial("Glass", 1.5)
>>> # Wavelength-dependent (Cauchy dispersion)
>>> def cauchy_n(wavelength):
... wl_um = wavelength * 1e6
... return 1.5 + 0.01 / wl_um**2
>>> glass = HomogeneousMaterial("Glass", cauchy_n)
>>> # With absorption (colored glass)
>>> colored_glass = HomogeneousMaterial(
... "Red Glass", 1.52, absorption_coef=0.1
... )
Notes
-----
For homogeneous materials:
- Rays propagate in straight lines (no gradient-driven bending)
- Reflection/refraction occurs only at interfaces
- Beer-Lambert absorption: I(d) = I₀ exp(-αd)
- No GPU kernels are needed (straight-line propagation)
"""
# =========================================================================
# COMPATIBILITY DECLARATIONS
# =========================================================================
@classmethod
def _init_compatibility(cls):
"""Initialize compatibility declarations (called once on first use)."""
from ...propagation.kernels.registry import PropagatorID
if not cls._supported_propagators:
# Homogeneous materials don't need GPU kernels - straight-line propagation
cls._supported_kernels = [] # No GPU kernels needed
cls._default_kernel = None
cls._supported_propagators = [PropagatorID.CPU_GRADIENT]
cls._default_propagator = PropagatorID.CPU_GRADIENT
[docs]
def __init__(
self,
name: str,
refractive_index: float | Callable[[float], float],
absorption_coef: float = 0.0,
scattering_coef: float = 0.0,
anisotropy: float = 0.0,
propagator: PropagatorID | None = None,
):
"""
Initialize homogeneous material.
Parameters
----------
name : str
Descriptive name for this material.
refractive_index : float or callable
Refractive index value or function f(wavelength) -> n.
absorption_coef : float, optional
Absorption coefficient α in m⁻¹. Default is 0.
scattering_coef : float, optional
Scattering coefficient μ_s in m⁻¹. Default is 0.
anisotropy : float, optional
Henyey-Greenstein anisotropy factor g ∈ [-1, 1]. Default is 0.
propagator : PropagatorID, optional
Override the default propagator. Only CPU_GRADIENT is supported.
Raises
------
ValueError
If anisotropy is outside [-1, 1].
"""
# Initialize compatibility declarations before calling super().__init__
self._init_compatibility()
# Homogeneous materials don't use GPU kernels
super().__init__(name, kernel=None, propagator=propagator)
self._is_homogeneous = True
# Validate inputs
if not -1.0 <= anisotropy <= 1.0:
raise ValueError(f"Anisotropy must be in [-1, 1], got {anisotropy}")
if absorption_coef < 0:
raise ValueError(
f"Absorption coefficient must be >= 0, got {absorption_coef}"
)
if scattering_coef < 0:
raise ValueError(
f"Scattering coefficient must be >= 0, got {scattering_coef}"
)
# Store properties
self.refractive_index = refractive_index
self.absorption_coef = absorption_coef
self.scattering_coef = scattering_coef
self.anisotropy = anisotropy
# Check if n is callable or constant
self._n_is_callable = callable(refractive_index)
[docs]
def get_refractive_index(
self,
x: float | NDArray[np.float64],
y: float | NDArray[np.float64],
z: float | NDArray[np.float64],
wavelength: float | NDArray[np.float64],
) -> float | NDArray[np.float64]:
"""
Get refractive index (position-independent, wavelength-dependent).
Parameters
----------
x, y, z : float or ndarray
Position coordinates in meters (ignored for homogeneous).
wavelength : float or ndarray
Wavelength in meters.
Returns
-------
n : float or ndarray
Refractive index.
"""
if self._n_is_callable:
if isinstance(wavelength, np.ndarray):
# Vectorize the callable
return np.array([self.refractive_index(wl) for wl in wavelength])
return self.refractive_index(wavelength)
else:
if isinstance(x, np.ndarray):
return np.full_like(x, self.refractive_index)
return self.refractive_index
[docs]
def get_refractive_index_gradient(
self,
x: float | NDArray[np.float64],
y: float | NDArray[np.float64],
z: float | NDArray[np.float64],
wavelength: float | NDArray[np.float64],
) -> tuple[
float | NDArray[np.float64],
float | NDArray[np.float64],
float | NDArray[np.float64],
]:
"""
Get refractive index gradient (always zero for homogeneous).
Parameters
----------
x, y, z : float or ndarray
Position coordinates in meters.
wavelength : float or ndarray
Wavelength in meters (unused).
Returns
-------
grad_n : tuple of (float or ndarray)
(0, 0, 0) since ∇n = 0 for homogeneous materials.
"""
if isinstance(x, np.ndarray):
zeros = np.zeros_like(x)
return (zeros, zeros, zeros)
return (0.0, 0.0, 0.0)
[docs]
def get_absorption_coefficient(
self,
x: float | NDArray[np.float64],
y: float | NDArray[np.float64],
z: float | NDArray[np.float64],
wavelength: float | NDArray[np.float64],
) -> float | NDArray[np.float64]:
"""
Get absorption coefficient (constant for homogeneous).
Parameters
----------
x, y, z : float or ndarray
Position coordinates in meters (ignored).
wavelength : float or ndarray
Wavelength in meters (currently ignored, could extend).
Returns
-------
alpha : float or ndarray
Absorption coefficient in m⁻¹.
"""
if isinstance(x, np.ndarray):
return np.full_like(x, self.absorption_coef)
return self.absorption_coef
[docs]
def get_scattering_coefficient(
self,
x: float | NDArray[np.float64],
y: float | NDArray[np.float64],
z: float | NDArray[np.float64],
wavelength: float | NDArray[np.float64],
) -> float | NDArray[np.float64]:
"""
Get scattering coefficient (constant for homogeneous).
Parameters
----------
x, y, z : float or ndarray
Position coordinates in meters (ignored).
wavelength : float or ndarray
Wavelength in meters (currently ignored, could extend).
Returns
-------
mu_s : float or ndarray
Scattering coefficient in m⁻¹.
"""
if isinstance(x, np.ndarray):
return np.full_like(x, self.scattering_coef)
return self.scattering_coef
[docs]
def get_anisotropy_factor(
self,
x: float | NDArray[np.float64],
y: float | NDArray[np.float64],
z: float | NDArray[np.float64],
wavelength: float | NDArray[np.float64],
) -> float | NDArray[np.float64]:
"""
Get scattering anisotropy factor (constant for homogeneous).
Parameters
----------
x, y, z : float or ndarray
Position coordinates in meters (ignored).
wavelength : float or ndarray
Wavelength in meters (ignored).
Returns
-------
g : float or ndarray
Anisotropy factor.
"""
if isinstance(x, np.ndarray):
return np.full_like(x, self.anisotropy)
return self.anisotropy
[docs]
def __repr__(self) -> str:
"""Return string representation with key properties."""
if self._n_is_callable:
n_str = "n(λ)"
else:
n_str = f"n={self.refractive_index:.4f}"
props = [n_str]
if self.absorption_coef > 0:
props.append(f"α={self.absorption_coef:.2e}")
if self.scattering_coef > 0:
props.append(f"μ_s={self.scattering_coef:.2e}")
return f"<HomogeneousMaterial('{self.name}', {', '.join(props)})>"