Source code for bornsim.medium.random_medium

"""Seeded random fields with explicitly chosen spatial covariance."""

from abc import abstractmethod
from dataclasses import dataclass
import numpy as np
from .base import Medium
from ..units import _integer, _refractive_index_values, Quantity, validate_units, ureg
from ..grid import Grid
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from ..volume import Volume


[docs] @dataclass(frozen=True, kw_only=True) class RandomFieldMedium(Medium): """Abstract spectral synthesis shared by covariance-specific random fields. Fields have Gaussian one-point statistics. Synthesis preserves expected variance and the DC mode, using a doubled box followed by cropping. Individual realizations are never recentered or rescaled. """ background_refractive_index: float refractive_index_std: float correlation_length: Quantity @property @abstractmethod def correlation(self) -> str: """Spatial covariance family recorded in provenance.""" raise NotImplementedError def __post_init__(self) -> None: validate_units( self.correlation_length, unit="meter", name="correlation_length", scalar=True, ) if not np.isfinite(self.correlation_length) or self.correlation_length <= 0: raise ValueError("correlation_length must be finite and positive.") for name in ("background_refractive_index", "refractive_index_std"): value = _refractive_index_values( value=getattr(self, name), name=name, scalar=True, ) if not np.isfinite(value) or value < 0 or (name == "background_refractive_index" and value == 0): raise ValueError( f"{name} must be finite and {'nonnegative' if name == 'refractive_index_std' else 'positive'}." ) @property def is_random(self) -> bool: """Statistical media support independent seeded realizations.""" return True @property def metadata(self) -> dict[str, object]: """Return independent SI statistics for result provenance. Lengths use the ``_m`` suffix. This describes ensemble statistics rather than a particular seed or a sample's measured mean and variance. """ return { **super().metadata, "refractive_index_std": self.refractive_index_std, "correlation_length_m": float(self.correlation_length.to("meter").magnitude), "correlation": self.correlation, "smoothness": getattr(self, "smoothness", None), }
[docs] def spectral_weight(self, *, q_squared: Quantity) -> np.ndarray: """Return dimensionless spectrum shape at squared wavenumbers in m^-2. The zero-wavenumber weight is one. This is not the normalized continuum power spectral density; the synthesis code supplies discrete variance normalization. Inputs must be finite and nonnegative. """ validate_units( q_squared, unit="1 / meter**2", name="q_squared", ) if np.any(~np.isfinite(q_squared)) or np.any(q_squared < 0): raise ValueError("q_squared must be finite and nonnegative.") scaled = (q_squared * self.correlation_length**2).to("dimensionless").magnitude return self._spectral_weight(scaled_squared_wavenumber=scaled)
@abstractmethod def _spectral_weight(self, *, scaled_squared_wavenumber: np.ndarray) -> np.ndarray: raise NotImplementedError
[docs] def to_volume(self, *, grid: Grid, seed: int = 0) -> "Volume": """Generate a seeded random refractive-index Volume on an explicit Grid. The field has a Gaussian one-point probability distribution for every spatial covariance model. Spectral synthesis uses a doubled periodic box and crops its first half. Expected point variance is normalized; individual samples are never recentered or rescaled. Grid resolution and synthesis-box size can alter sampled covariance. The seed defaults to zero and remains explicit in the generated volume's provenance.""" from ..volume import Volume grid = Grid._resolve(grid=grid) # FFT frequencies require numeric spacing in a chosen length unit. sample_grid_shape = grid.shape voxel_spacing_m = float(grid.spacing.to("meter").magnitude) seed = _integer( value=seed, name="seed", low=0, high=2**32 - 1, ) synthesis_grid_shape = tuple(2 * voxel_count for voxel_count in sample_grid_shape) axis_wavenumbers_per_meter = [ 2 * np.pi * np.fft.fftfreq(voxel_count, voxel_spacing_m) for voxel_count in synthesis_grid_shape ] squared_wavenumbers_per_meter_squared = np.zeros(synthesis_grid_shape) for wavenumber_component_per_meter in np.meshgrid(*axis_wavenumbers_per_meter, indexing="ij"): squared_wavenumbers_per_meter_squared += wavenumber_component_per_meter**2 normalized_spectral_weights = self.spectral_weight( q_squared=squared_wavenumbers_per_meter_squared * (1 / ureg.meter**2) ) # Normalize expected point variance, not each sample's spatial variance. normalized_spectral_weights /= normalized_spectral_weights.mean() uncorrelated_gaussian_noise = np.random.default_rng(seed).normal(size=synthesis_grid_shape) synthesized_refractive_index_fluctuations = ( np.fft.ifftn(np.fft.fftn(uncorrelated_gaussian_noise) * np.sqrt(normalized_spectral_weights)).real * self.refractive_index_std ) sample_crop = tuple(slice(0, voxel_count) for voxel_count in sample_grid_shape) return Volume( delta_refractive_index=synthesized_refractive_index_fluctuations[sample_crop], grid=grid, background_refractive_index=self.background_refractive_index, medium=self, seed=seed, )
[docs] @dataclass(frozen=True, kw_only=True) class GaussianMedium(RandomFieldMedium): """Gaussian spatial covariance: C(r) = refractive_index_std² exp(-r²/(2 ell²)).""" correlation = "gaussian" def _spectral_weight(self, *, scaled_squared_wavenumber: np.ndarray) -> np.ndarray: return np.exp(-scaled_squared_wavenumber / 2)
[docs] @dataclass(frozen=True, kw_only=True) class ExponentialMedium(RandomFieldMedium): """Exponential spatial covariance: C(r) = refractive_index_std² exp(-r/ell).""" correlation = "exponential" def _spectral_weight(self, *, scaled_squared_wavenumber: np.ndarray) -> np.ndarray: return (1 + scaled_squared_wavenumber) ** -2
[docs] @dataclass(frozen=True, kw_only=True) class WhittleMaternMedium(RandomFieldMedium): """Whittle–Matérn covariance with explicitly chosen positive smoothness. The spectrum is proportional to (1 + (q ell)²/(2 smoothness))**(-smoothness-3/2). Smoothness 0.5 reproduces ExponentialMedium with the same correlation length. """ smoothness: float correlation = "matern" def _spectral_weight(self, *, scaled_squared_wavenumber: np.ndarray) -> np.ndarray: # log1p preserves precision for small wavenumbers and large smoothness. return np.exp(-(self.smoothness + 1.5) * np.log1p(scaled_squared_wavenumber / (2 * self.smoothness))) def __post_init__(self) -> None: super().__post_init__() if isinstance(self.smoothness, Quantity) or np.ndim(self.smoothness) != 0: raise ValueError("smoothness must be a plain scalar dimensionless number.") if not np.isfinite(self.smoothness) or self.smoothness <= 0: raise ValueError("smoothness must be finite and positive.")