Source code for eradiate.scenes.atmosphere._particle_dist

"""
Particle distributions.

Particle distributions define how the particle number fraction varies with
altitude. The particle layer is split into a number of divisions
(sub-layers) wherein the particle number fraction is evaluated.

Notes
-----
Particle distributions are not normalized. The parent caller is responsible
for normalizing returned values.
"""

import typing as t
from abc import ABC, abstractmethod

import attrs
import numpy as np
import scipy.interpolate

from ..._factory import Factory
from ...attrs import define, documented

particle_distribution_factory = Factory()
particle_distribution_factory.register_lazy_batch(
    [
        ("UniformParticleDistribution", "uniform", {}),
        ("ExponentialParticleDistribution", "exponential", {}),
        ("GaussianParticleDistribution", "gaussian", {}),
        ("ArrayParticleDistribution", "array", {}),
        ("InterpolatorParticleDistribution", "interpolator", {}),
    ],
    cls_prefix="eradiate.scenes.atmosphere._particle_dist",
)


[docs] @define class ParticleDistribution(ABC): """ Abstract base class for particle distributions used to define particle layers. In practice, particle distributions are callables with the signature ``f(x: np.typing.ArrayLike) -> np.ndarray`` and are evaluated over the interval [0, 1]. """ @abstractmethod def __call__(self, x: np.typing.ArrayLike) -> np.ndarray: pass
[docs] @define class UniformParticleDistribution(ParticleDistribution): r""" Uniform particle distribution [``uniform``]. Returns values given by the uniform PDF .. math:: f : x \mapsto \left\{ \begin{array}{ll} \frac{1}{b - a} & \mathrm{if} \ x \in [a, b] \\ 0 & \mathrm{otherwise} \end{array} \right. where :math:`a = \mathtt{bounds[0]}` and :math:`b = \mathtt{bounds[1]}`. """ bounds: np.ndarray = documented( attrs.field( default=[0.0, 1.0], converter=lambda x: np.atleast_1d(np.squeeze(x)) ), type="array", init_type="array-like, optional", default="[0, 1]", doc="Bounds of the distribution's interval.", ) @bounds.validator def _bounds_validator(self, attribute, value): if len(value) != 2: raise ValueError( f"while validating '{attribute.name}': passed array must have " "exactly 2 elements" ) if value[1] <= value[0]: raise ValueError( f"while validating '{attribute.name}': bounds must be sorted in " "ascending order " ) def __call__(self, x: np.typing.ArrayLike) -> np.ndarray: return np.where( np.logical_or(x < self.bounds[0], x > self.bounds[1]), np.zeros_like(x), np.full_like(x, 1.0 / (self.bounds[1] - self.bounds[0])), )
[docs] @define(init=False) class ExponentialParticleDistribution(ParticleDistribution): r""" Exponential particle distribution [``exponential``]. Returns values given by the normalized exponential PDF .. math:: f : x \mapsto \dfrac{\lambda e^{-\lambda x}}{1 - e^{-\lambda}} where :math:`\lambda = \mathtt{rate}`. Parameters ---------- rate : float, optional, default: 5.0 Decay rate :math:`\lambda`. The default value ensures a 99.3% decay on the [0, 1] interval. Mutually exclusive with `scale`. scale : float, optional Scale :math:`\beta = 1 / \lambda`. Mutually exclusive with `rate`. Fields ------ rate : float Decay rate :math:`\lambda`. """ rate: float = attrs.field(default=5.0, converter=float) @rate.validator def _rate_validator(self, attribute, value): if value <= 0: raise ValueError( f"while validating {attribute.name}: rate must be strictly positive" ) def __init__(self, rate: float = None, scale: float = None): if rate is None and scale is None: self.__attrs_init__() elif scale is None and rate is not None: self.__attrs_init__(rate) elif scale is not None and rate is None: self.__attrs_init__(1.0 / scale) else: raise ValueError( "while initializing ExponentialParticleDistribution: " "setting both rate and scale is not allowed" ) def __call__(self, x: np.typing.ArrayLike) -> np.ndarray: return np.where( np.logical_or(x < 0.0, x > 1.0), np.zeros_like(x), np.exp(-x * self.rate) * self.rate / (1.0 - np.exp(-self.rate)), )
[docs] @define class GaussianParticleDistribution(ParticleDistribution): r""" Gaussian particle distribution [``gaussian``]. Returns values given by the Gaussian PDF .. math:: f : x \mapsto \frac{1}{2 \pi \cdot \sigma} \exp \left[ -\frac{1}{2} \left( \frac{x - \mu}{\sigma} \right)^2 \right] where :math:`\mu = \mathtt{mean}` and :math:`\sigma = \mathtt{std}`. """ mean: float = documented( attrs.field(default=0.5, converter=float), type="float", init_type="float, optional", default="0.5", doc="Mean of the Gaussian PDF. The default value places the mean in " "the middle of the particle layer (at :math:`x = 0.5`).", ) std: float = documented( attrs.field(default=0.5 / 3, converter=float), type="float", init_type="float, optional", default="1/6", doc="Standard deviation of the Gaussian PDF. The default value is " "such that the integral of the Gaussian PDF over the " r":math:`[\mu - 0.5, \mu + 0.5]` interval is about 99.7% (3σ).", ) def __call__(self, x: np.typing.ArrayLike) -> np.ndarray: return np.where( np.logical_or(x < 0.0, x > 1.0), np.zeros_like(x), np.exp(-0.5 * np.square((x - self.mean) / self.std)) / (self.std * np.sqrt(2.0 * np.pi)), )
[docs] @define class ArrayParticleDistribution(ParticleDistribution): """ Particle distribution specified by an array of values [``array``]. """ values: np.typing.ArrayLike = documented( attrs.field(converter=np.array, kw_only=True), type="ndarray", init_type="array-like", doc="An array of particle fraction values.", ) @values.validator def _values_validator(self, attribute, value): if value.ndim != 1: raise ValueError( f"while validating {attribute.name}: only 1D arrays are allowed" ) if len(value) < 2: raise ValueError( f"while validating {attribute.name}: array must have at least 2 " "elements" ) coords: np.ndarray = documented( attrs.field( default=attrs.Factory( lambda x: np.arange( 0.5 / len(x.values), 1, 1 / len(x.values), ), takes_self=True, ), converter=np.array, ), type="ndarray", init_type="array-like, optional", doc="Coordinates to which passed values are mapped. This array must " "have the same shape as ``values``. The default value positions values " "at the centers of a regular grid with nodes defined " "by :code:`np.linspace(0, 1, len(values))`.", ) @coords.validator def _coords_validator(self, attribute, value): if value.shape != self.values.shape: raise ValueError( f"while validating '{attribute.name}': coordinate and value " "array shapes must be the same" ) method: str = documented( attrs.field( default="linear", converter=str, validator=attrs.validators.in_( { "linear", "nearest", "nearest-up", "zero", "slinear", "quadratic", "cubic", "previous", "next", } ), ), type="str", init_type='{ "linear", "nearest", "nearest-up", "zero", "slinear", ' '"quadratic", "cubic", "previous", "next" }', default='"linear"', doc="Interpolation method. See :class:`scipy.interpolate.interp1d` " "(*kind*) for more information.", ) extrapolate: str = documented( attrs.field( default="zero", converter=str, validator=attrs.validators.in_({"zero", "nearest", "method", "nan"}), ), type="str", init_type='{ "zero", "nearest", "method", "nan" }', default='"zero"', doc="Extrapolation method used when evaluation is requested outside of " r":math:`[\mathtt{coords[0]}, \mathtt{coords[-1]}]`. " "See :class:`scipy.interpolate.interp1d` (*fill_value*) for more " "information. Settings map as follows:\n" "\n" ".. list-table::\n\n" " * - ``ArrayParticleDistribution`` (`extrapolate`)\n" " - ``interp1d`` (`fill_value`)\n" ' * - ``"zero"``\n' " - ``0.0``\n" ' * - ``"nearest"``\n' " - ``(values[0], values[-1])``\n" ' * - ``"method"``\n' " - ``extrapolate``\n" ' * - ``"nan"``\n' " - ``np.nan``\n", ) def __call__(self, x: np.typing.ArrayLike) -> np.ndarray: if self.extrapolate == "zero": fill_value = 0.0 elif self.extrapolate == "nearest": fill_value = (self.values[0], self.values[-1]) elif self.extrapolate == "method": fill_value = "extrapolate" else: fill_value = np.nan # TODO: This is unsafe, values of x outside of [0, 1] should return 0 f = scipy.interpolate.interp1d( self.coords, self.values, kind=self.method, bounds_error=False, fill_value=fill_value, ) return f(x)
[docs] @define class InterpolatorParticleDistribution(ParticleDistribution): """ A flexible particle distribution which redirects its calls to an encapsulated callable [``interpolator``]. """ interpolator: t.Callable[[np.typing.ArrayLike], np.ndarray] = documented( attrs.field(validator=attrs.validators.is_callable, kw_only=True), type="callable", doc="A callable with signature " ":code:`f(x: np.typing.ArrayLike) -> np.ndarray`. Typically, " "a :class:`scipy.interpolate.interp1d`.", ) def __call__(self, x: np.typing.ArrayLike) -> np.ndarray: # TODO: This is unsafe, values of x outside of [0, 1] should return 0 return self.interpolator(x)