Source code for optiland.physical_optics.field

"""Two-dimensional scalar optical fields."""

from __future__ import annotations

import math
from numbers import Complex, Real
from typing import Generic, Literal

import optiland.backend as be
from optiland._types import BEArrayT, ScalarOrArrayT
from optiland.backend.utils import is_torch_tensor


def _positive_float(value: float, name: str) -> float:
    if not isinstance(value, Real):
        raise TypeError(f"{name} must be a real scalar.")
    value = float(value)
    if not math.isfinite(value) or value <= 0:
        raise ValueError(f"{name} must be finite and greater than zero.")
    return value


def _cast_real_like(values: BEArrayT, like: BEArrayT) -> BEArrayT:
    if is_torch_tensor(like):
        return values.to(device=like.device, dtype=like.real.dtype)
    return values.astype(like.real.dtype, copy=False)


def _centered_axis(size: int, spacing: float, like: BEArrayT | None = None):
    indices = be.arange_indices(size)
    indices = be.cast(indices) if like is None else _cast_real_like(indices, like)
    return (indices - (size - 1) / 2) * spacing


def _validate_amplitude(amplitude: complex | ScalarOrArrayT) -> None:
    if isinstance(amplitude, Complex):
        return
    if not isinstance(amplitude, be.ndarray):
        raise TypeError("amplitude must be a complex scalar or scalar backend array.")

    backend = be.get_backend()
    if (backend == "torch") != is_torch_tensor(amplitude):
        raise TypeError(f"amplitude must belong to the active {backend!r} backend.")
    if amplitude.ndim != 0:
        raise ValueError("amplitude must be scalar.")


[docs] class ScalarField(Generic[BEArrayT]): """A sampled two-dimensional complex scalar optical field. The last array dimension is the x-axis and the first is the y-axis. All spatial quantities, including ``dx``, ``dy``, and ``wavelength``, must use the same unit. Args: data: Two-dimensional NumPy array or PyTorch tensor containing the sampled complex amplitude. Real arrays are promoted to complex without changing their device or floating-point precision. dx: Sample spacing along x. wavelength: Vacuum wavelength in the same unit as ``dx``. dy: Sample spacing along y. Defaults to ``dx``. refractive_index: Homogeneous-medium refractive index. Defaults to 1. Raises: TypeError: If ``data`` does not belong to the active backend. ValueError: If the field or physical parameters are invalid. """ def __init__( self, data: BEArrayT, dx: float, wavelength: float, dy: float | None = None, refractive_index: float = 1.0, ) -> None: if not isinstance(data, be.ndarray): raise TypeError("data must be a NumPy array or PyTorch tensor.") backend = be.get_backend() if (backend == "torch") != is_torch_tensor(data): raise TypeError(f"data must belong to the active {backend!r} backend.") if data.ndim != 2: raise ValueError("data must be a two-dimensional array.") if any(size < 2 for size in data.shape): raise ValueError("each field dimension must contain at least two samples.") self.data = data + 0j self.dx = _positive_float(dx, "dx") self.dy = self.dx if dy is None else _positive_float(dy, "dy") self.wavelength = _positive_float(wavelength, "wavelength") self.refractive_index = _positive_float(refractive_index, "refractive_index") self._backend = backend @property def shape(self) -> tuple[int, int]: """Return the field shape as ``(ny, nx)``.""" return int(self.data.shape[0]), int(self.data.shape[1]) @property def intensity(self) -> BEArrayT: """Return sampled intensity, ``abs(data) ** 2``.""" self._ensure_active_backend() return be.real(self.data * be.conj(self.data)) @property def power(self) -> ScalarOrArrayT: """Return the sampled intensity integral over the field plane.""" return be.sum(self.intensity) * self.dx * self.dy
[docs] def coordinates(self) -> tuple[BEArrayT, BEArrayT]: """Return centered one-dimensional x and y coordinate arrays.""" self._ensure_active_backend() ny, nx = self.shape return ( _centered_axis(nx, self.dx, like=self.data), _centered_axis(ny, self.dy, like=self.data), )
[docs] def propagate( self, distance: float | ScalarOrArrayT, evanescent: Literal["discard", "decay"] = "discard", ) -> ScalarField[BEArrayT]: """Propagate the field through a homogeneous medium. Args: distance: Signed propagation distance in the field's spatial unit. evanescent: ``"discard"`` filters evanescent content even at zero distance. ``"decay"`` preserves the complete field at zero, up to FFT roundoff. See :func:`~optiland.physical_optics.propagation.angular_spectrum` for distance-gradient behavior. Returns: ScalarField: Propagated field on the same sampling grid. """ from optiland.physical_optics.propagation import angular_spectrum return angular_spectrum(self, distance, evanescent=evanescent)
def _ensure_active_backend(self) -> None: if be.get_backend() != self._backend: raise RuntimeError( "the active backend changed after this ScalarField was created" ) def __repr__(self) -> str: """Return a concise field description.""" return ( f"ScalarField(shape={self.shape}, dx={self.dx}, dy={self.dy}, " f"wavelength={self.wavelength}, " f"refractive_index={self.refractive_index})" )
[docs] def gaussian_field( shape: tuple[int, int], dx: float, wavelength: float, waist_radius: float, dy: float | None = None, refractive_index: float = 1.0, amplitude: complex | ScalarOrArrayT = 1.0, ) -> ScalarField: """Create a fundamental Gaussian beam sampled at its waist. ``waist_radius`` is the conventional 1/e field-amplitude radius, or equivalently the 1/e-squared intensity radius. Args: shape: Number of samples as ``(ny, nx)``. dx: Sample spacing along x. wavelength: Vacuum wavelength. waist_radius: Gaussian beam waist radius. dy: Sample spacing along y. Defaults to ``dx``. refractive_index: Homogeneous-medium refractive index. Defaults to 1. amplitude: On-axis complex-field amplitude. Defaults to 1. Returns: ScalarField: Gaussian field at its waist plane. """ if ( not isinstance(shape, tuple) or len(shape) != 2 or any(not isinstance(size, int) or size < 2 for size in shape) ): raise ValueError("shape must be a (ny, nx) tuple with dimensions >= 2.") dx = _positive_float(dx, "dx") dy = dx if dy is None else _positive_float(dy, "dy") waist_radius = _positive_float(waist_radius, "waist_radius") _validate_amplitude(amplitude) ny, nx = shape like = amplitude if isinstance(amplitude, be.ndarray) else None x = _centered_axis(nx, dx, like=like) y = _centered_axis(ny, dy, like=like) x_grid, y_grid = be.meshgrid(x, y) data = amplitude * be.exp(-(x_grid * x_grid + y_grid * y_grid) / waist_radius**2) return ScalarField( data=data, dx=dx, dy=dy, wavelength=wavelength, refractive_index=refractive_index, )