Source code for PackLab.scattering.data

from dataclasses import dataclass
from typing import Any

from TypedUnit import ureg
from numpy.typing import NDArray
import numpy as np


[docs] @dataclass(slots=True) class ScatteringData: """Far-field scattering data for a single particle diameter. The quantity-bearing fields intentionally use ``Any`` because TypedUnit/Pint quantities are generic at runtime and may wrap scalar or array values. """ S1: Any S2: Any k: Any Csca: Any phi: Any
[docs] def plot(self, *, tight_layout: bool = True): """Plot the magnitudes of the two scattering amplitudes.""" import matplotlib.pyplot as plt phi = self.phi.to("radian").magnitude if hasattr(self.phi, "to") else self.phi s1 = np.abs(self.S1.magnitude if hasattr(self.S1, "magnitude") else self.S1) s2 = np.abs(self.S2.magnitude if hasattr(self.S2, "magnitude") else self.S2) figure, axes = plt.subplots() axes.plot(phi, s1, label="|S1|") axes.plot(phi, s2, label="|S2|") axes.set_xlabel("polar angle (rad)") axes.set_ylabel("amplitude") axes.legend() if tight_layout: figure.tight_layout() return figure
# Convention note: # In typical scattering convention, the polar scattering angle is in [0, pi] # and the azimuthal rotation around the incident axis is in [0, 2*pi). # In this class, `self.phi` is used with sin(self.phi) weights, so it behaves like a polar angle.
[docs] class ScatteringDataset(list): """ Container for multi size scattering data and mixture level post processing. This class stores :class:`ScatteringData` instances, one for each diameter. It also provides mixture formulas for a number density distribution over diameters and an inter particle correlation term H(wavenumber). Attributes ---------- e_theta, e_phi : NDArray, shape (2,) Unit basis vectors for the polarization basis used in `get_F_matrix`. This code treats the scattering amplitude as a 2 component vector in a (theta, phi) polarization basis. k Optical wavenumber in the surrounding medium (1/length). phi Polar scattering angle grid in radians. Expected range is [0, pi]. S1, S2 Arrays assembled by `process()` from each per diameter element. Csca Per diameter scattering cross section assembled by `process()`. Notes ----- Derived arrays are refreshed automatically by the mixture methods. Calling :meth:`process` explicitly remains useful when inspecting those arrays. """ e_theta = np.array([1.0, 0.0]) e_phi = np.array([0.0, 1.0])
[docs] def process(self): """ Stack per diameter fields into dense arrays. After calling this method, the object provides: * `self.S1`: ndarray, shape (N, Pphi) * `self.S2`: ndarray, shape (N, Pphi) * `self.Csca`: Pint Quantity, shape (N,), units meter**2 where N is the number of diameters (len(self)) and Pphi is the number of angular samples in each per diameter result. Returns ------- None """ if not self: raise ValueError("ScatteringDataset must contain at least one ScatteringData item.") if not hasattr(self, "phi"): raise ValueError("ScatteringDataset.phi must be set before processing.") phi = self._validated_phi() expected_shape = phi.shape for index, data in enumerate(self): if not isinstance(data, ScatteringData): raise TypeError("ScatteringDataset items must be ScatteringData instances.") if np.asarray(data.S1.magnitude).shape != expected_shape: raise ValueError(f"S1 for species {index} must match the phi grid shape {expected_shape}.") if np.asarray(data.S2.magnitude).shape != expected_shape: raise ValueError(f"S2 for species {index} must match the phi grid shape {expected_shape}.") self.S1 = np.asarray([d.S1.magnitude for d in self]) self.S2 = np.asarray([d.S2.magnitude for d in self]) self.Csca = np.asarray([d.Csca.to("meter**2").magnitude for d in self]) * ureg.meter ** 2
def _validated_phi(self) -> np.ndarray: """Return the polar-angle grid in radians after validating it.""" try: phi = np.asarray(self.phi.to("radian").magnitude, dtype=float) except AttributeError as error: raise TypeError("phi must be a unit-bearing angle quantity.") from error if phi.ndim != 1 or phi.size < 2: raise ValueError("phi must be a one-dimensional angle grid with at least two points.") if not np.all(np.isfinite(phi)) or np.any(np.diff(phi) <= 0): raise ValueError("phi must be finite and strictly increasing.") if phi[0] < 0.0 or phi[-1] > np.pi: raise ValueError("phi must lie within the polar-angle interval [0, pi].") return phi def _validate_mixture_inputs( self, densities: Any, H: Any, wavenumber: Any ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """Validate and normalize inputs shared by mixture calculations.""" self.process() if not hasattr(self, "k"): raise ValueError("ScatteringDataset.k must be set before mixture calculations.") try: k = self.k.to("1 / meter") density_values = np.asarray(densities.to("1 / meter**3").magnitude, dtype=float) wavenumber_values = np.asarray(wavenumber.to("1 / meter").magnitude, dtype=float) except AttributeError as error: raise TypeError("densities and wavenumber must be unit-bearing quantities.") from error if not np.isfinite(k.magnitude) or k.magnitude <= 0: raise ValueError("k must be finite and positive.") if density_values.ndim != 1 or density_values.size != len(self): raise ValueError("densities must be one-dimensional with one value per species.") if not np.all(np.isfinite(density_values)) or np.any(density_values < 0): raise ValueError("densities must be finite and non-negative.") if wavenumber_values.ndim != 1 or wavenumber_values.size < 2: raise ValueError("wavenumber must be a one-dimensional grid with at least two points.") if not np.all(np.isfinite(wavenumber_values)) or np.any(np.diff(wavenumber_values) <= 0): raise ValueError("wavenumber must be finite and strictly increasing.") H_values = np.asarray(H) expected_shape = (len(self), len(self), wavenumber_values.size) if H_values.shape != expected_shape: raise ValueError(f"H must have shape {expected_shape}; got {H_values.shape}.") if not np.all(np.isfinite(H_values)): raise ValueError("H must contain only finite values.") return density_values, H_values, wavenumber_values
[docs] def get_alpha_beta_factor(self, densities: NDArray) -> tuple[NDArray, NDArray]: """ Build number density factors for mixture sums. Parameters ---------- densities : NDArray, shape (N,) Species number densities n_alpha. Units should be 1/length**3. N must match the number of size classes stored in this object. Returns ------- n_alpha : NDArray, shape (N,) Same array as input (returned for convenience). sqrt_alpha_beta : NDArray, shape (N, N) Matrix with entries sqrt(n_alpha * n_beta). This is commonly used in symmetric mixture formulations to weight cross correlations. Notes ----- This uses the outer product and then takes a square root: sqrt_alpha_beta[a, b] = sqrt(n_alpha[a] * n_alpha[b]) """ n_alpha = densities n_alpha_beta = np.einsum("i,j -> ij", n_alpha, n_alpha) sqrt_alpha_beta = np.sqrt(n_alpha_beta) return n_alpha, sqrt_alpha_beta
[docs] def get_F_matrix(self, theta_points: int = 100) -> tuple: """ Construct the vector scattering amplitude tensor F for each size, angle, and azimuth. This builds a 2 component vector amplitude in the (e_theta, e_phi) basis using the convention in your code: F(θ_azimuth, φ_polar) = (i / k) * [ e_theta * S2(φ) * cos(θ) - e_phi * S1(φ) * sin(θ) ] Parameters ---------- theta_points Number of azimuthal sampling points in [0, 2*pi]. This is a rotation around the incident axis. Returns ------- F : NDArray, complex, shape (2, N, Pphi, Ptheta) Vector amplitude tensor. Index meaning: * first axis (size 2): polarization component (theta, phi) * second axis: size class index (N) * third axis: polar scattering angle samples (Pphi), from `self.phi` * fourth axis: azimuthal angle samples (Ptheta), generated internally theta : NDArray, shape (Ptheta,) Azimuthal angle grid in radians spanning [0, 2*pi]. Notes ----- The name `theta` here refers to an azimuthal angle. The polar scattering angle is stored in `self.phi`. """ if theta_points < 2: raise ValueError("theta_points must be at least 2.") self.process() theta = np.linspace(0, 2 * np.pi, theta_points) _cos = np.cos(theta) _sin = np.sin(theta) prefactor = 1j / self.k term_0 = np.einsum("i, jk, l -> ijkl", self.e_theta, self.S2, _cos) term_1 = np.einsum("i, jk, l -> ijkl", self.e_phi, self.S1, _sin) F = prefactor * (term_0 - term_1) return F, theta
[docs] def get_mu_independant(self, densities: NDArray): """ Compute the independent scattering contribution to the attenuation coefficient. Parameters ---------- densities : NDArray, shape (N,) Species number densities n_alpha. Units should be 1/length**3. Returns ------- mu_independant_scattering Scalar attenuation coefficient contribution with units 1/length. Notes ----- This implements a standard independent scattering approximation term: mu_s,ind = sum_alpha n_alpha * Csca_alpha The supplied density vector is validated against the number of species. """ self.process() try: n_alpha = densities.to("1 / meter**3") except AttributeError as error: raise TypeError("densities must be a unit-bearing quantity.") from error if np.asarray(n_alpha.magnitude).shape != (len(self),): raise ValueError("densities must contain one value per species.") if not np.all(np.isfinite(n_alpha.magnitude)) or np.any(n_alpha.magnitude < 0): raise ValueError("densities must be finite and non-negative.") mu_independant_scattering = np.einsum("i,i -> ", self.Csca, n_alpha).to("1/meter") return mu_independant_scattering
[docs] def get_mu_dependant(self, densities: NDArray, H: NDArray, wavenumber: NDArray, theta_points: int = 150): """ Compute the dependent scattering contribution using inter particle correlations H(wavenumber). Parameters ---------- densities : NDArray, shape (N,) Species number densities n_alpha. Units should be 1/length**3. H : NDArray, shape (N, N, Pp) Mixture total correlation function in reciprocal space, evaluated on the `wavenumber` grid. This should correspond to the same ordering as size classes. wavenumber : NDArray, shape (Pp,) Reciprocal space radial grid (1/length). Must cover the range needed to evaluate H at wavenumber = 2k sin(φ/2) for φ in [0, pi]. theta_points Number of azimuthal samples for the integral over the rotation angle. Returns ------- mu_dependant_scattering Scalar attenuation coefficient contribution. Units depend on the exact normalization conventions of F and H used in the integrand, but it is intended to be in 1/length. Notes ----- The core integrand is: term[p_index, theta_index] = sum_{a,b} sqrt(n_a n_b) F_a(wavenumber,theta) F*_b(wavenumber,theta) H_ab(wavenumber) then integrated over polar angle (self.phi) and azimuth (theta). """ self._validate_mixture_inputs(densities, H, wavenumber) n_alpha, sqrt_alpha_beta = self.get_alpha_beta_factor(densities=densities) F, theta = self.get_F_matrix(theta_points=theta_points) phi, interpolated_H = self.get_interpolated_H(H=H, wavenumber=wavenumber) term = np.einsum("ab,iapt,ibpt,abp -> pt", sqrt_alpha_beta, F, np.conj(F), interpolated_H) val = np.trapezoid(term.T * np.sin(phi), x=phi, axis=1) mu_dependant_scattering = np.trapezoid(val, x=theta) return mu_dependant_scattering
[docs] def get_mu(self, densities: NDArray, H: NDArray, wavenumber: NDArray, theta_points: int = 150) -> NDArray: """ Compute total scattering attenuation coefficient mu_s. Parameters ---------- densities : NDArray, shape (N,) Species number densities. H : NDArray, shape (N, N, Pp) Correlation function in wavenumber space. wavenumber : NDArray, shape (Pp,) wavenumber space grid (1/length). theta_points Azimuthal sampling count. Returns ------- mu Total scattering attenuation coefficient (intended units 1/length), computed as: mu = mu_independant + mu_dependant """ mu_independant = self.get_mu_independant(densities=densities) mu_dependant = self.get_mu_dependant(densities=densities, H=H, wavenumber=wavenumber, theta_points=theta_points) return mu_independant + mu_dependant
[docs] def interpolate_last_axis_linear( self, H: np.ndarray, wavenumber: np.ndarray, evaluation_wavenumber: np.ndarray, ) -> np.ndarray: """ Linear interpolation of H(..., wavenumber) onto evaluation_wavenumber, along the last axis. Parameters ---------- H : ndarray, shape (..., Pp) Values sampled on grid wavenumber along the last axis. wavenumber : ndarray, shape (Pp,) Strictly increasing sample points. evaluation_wavenumber : ndarray, shape (Pq,) Query points. Returns ------- ndarray, shape (..., Pq) Interpolated values. """ H = np.asarray(H) wavenumber = np.asarray(wavenumber, dtype=float) evaluation_wavenumber = np.asarray(evaluation_wavenumber, dtype=float) if wavenumber.ndim != 1 or wavenumber.size < 2: raise ValueError("wavenumber must be a 1D grid with at least two points.") if not np.all(np.isfinite(wavenumber)) or np.any(np.diff(wavenumber) <= 0): raise ValueError("wavenumber must be finite and strictly increasing.") if not np.all(np.isfinite(evaluation_wavenumber)): raise ValueError("evaluation_wavenumber must contain only finite values.") if evaluation_wavenumber.min() < wavenumber[0] or evaluation_wavenumber.max() > wavenumber[-1]: raise ValueError("wavenumber does not cover the scattering wavevector range.") if H.shape[-1] != wavenumber.size: raise ValueError(f"H last axis ({H.shape[-1]}) must match wavenumber size ({wavenumber.size}).") H_2d = H.reshape(-1, wavenumber.size) # (M, Pp) out_2d = np.empty((H_2d.shape[0], evaluation_wavenumber.size), dtype=H_2d.dtype) for row in range(H_2d.shape[0]): out_2d[row] = np.interp(evaluation_wavenumber, wavenumber, H_2d[row]) return out_2d.reshape(H.shape[:-1] + (evaluation_wavenumber.size,))
[docs] def get_interpolated_H(self, H, wavenumber): """ Interpolate H(wavenumber) onto the scattering wavevector magnitude wavenumber = 2k sin(φ/2). Parameters ---------- H : array like, shape (N, N, Pp) Correlation tensor sampled on the input wavenumber grid. wavenumber : array like, shape (Pp,) Radial reciprocal space grid (1/length). Returns ------- interpolated_H : NDArray, shape (N, N, Pphi) H evaluated at the scattering wavenumber for φ in [0, pi], where: wavenumber(φ) = 2 k sin(φ/2) Notes ----- This method assumes: * `self.phi` spans [0, pi] and is the polar scattering angle * `wavenumber` is provided in increasing order """ self._validate_mixture_inputs( densities=np.ones(len(self)) / ureg.meter**3, H=H, wavenumber=wavenumber, ) phi_evaluate = self._validated_phi() evaluation_wavenumber = 2 * self.k * np.sin(phi_evaluate / 2) wavenumber_grid = wavenumber.to("1/meter").magnitude evaluation_wavenumber_magnitude = evaluation_wavenumber.to("1/meter").magnitude interpolated_H = self.interpolate_last_axis_linear( H, wavenumber_grid, evaluation_wavenumber_magnitude, ) return phi_evaluate, interpolated_H
[docs] def get_phase_function( self, densities: NDArray, H: NDArray, wavenumber: NDArray, theta_points: int = 150 ) -> tuple[NDArray, NDArray, NDArray]: """ Compute the mixture phase function including dependent scattering corrections. Parameters ---------- densities : NDArray, shape (N,) Species number densities n_alpha. H : NDArray, shape (N, N, Pp) Correlation tensor in reciprocal space on the wavenumber grid. wavenumber : NDArray, shape (Pp,) wavenumber space grid (1/length). theta_points Number of azimuthal samples. Returns ------- phi : ndarray, shape (Pphi,) Polar angle grid in radians. theta : ndarray, shape (Ptheta,) Azimuthal angle grid in radians. phase_function : NDArray, shape (Pphi, Ptheta) Phase function indexed first by polar angle, then by azimuth. Notes ----- The phase function is assembled as: * Independent term: sum of ``n |F|^2`` over species. * Dependent term: weighted cross terms involving ``F``, its conjugate, and the correlation tensor ``H``. """ self._validate_mixture_inputs(densities, H, wavenumber) n_alpha, sqrt_alpha_beta = self.get_alpha_beta_factor(densities=densities) phi, interpolated_H = self.get_interpolated_H(H=H, wavenumber=wavenumber) F, theta = self.get_F_matrix(theta_points=theta_points) phase_function: NDArray = ( np.einsum("a, jatp, jatp -> tp", n_alpha, F, np.conj(F)) + np.einsum("ab, japt, jbpt, abp -> pt", sqrt_alpha_beta, F, np.conj(F), interpolated_H) ) return phi, theta, phase_function