"""Full directional output sampling and independent solid-angle quadrature."""
from dataclasses import dataclass
from typing import cast
import numpy as np
from .directions import Directions
from .grid import Grid
from .results import BornResult
from .units import Quantity, _integer, validate_units, ureg
[docs]
@dataclass(frozen=True, kw_only=True, init=False)
class AngularSampling:
"""Configure directional plots and their solid-angle normalization.
Parameters
----------
angles : Quantity, optional
One-dimensional output polar angles theta, from 0 to pi, with 1 to
181 entries. Angular values require explicit units. Cannot be combined
with start, end or n_points. Omit to generate an evenly spaced range.
start, end : Quantity, optional
Inclusive range endpoints between 0 and pi. Angular values require explicit units.
Defaults are 0 and pi; descending ranges are supported.
n_points : int, optional
Number of evenly spaced output angles, from 1 to 181. Default is 121.
A single point samples start.
polar_samples : int, optional
Gauss-Legendre integration nodes in cos(theta), from 16 to 128.
Default is 32. These are independent of the output angles.
azimuth_samples : int, optional
Uniform azimuth directions in [0, 2*pi), from 4 to 32. Default is 8.
Used for both directional output and solid-angle integration.
Notes
-----
Intensities are retained at every output (theta, phi). One-dimensional
curves select sampled meridians; azimuth averages require an explicit
azimuth_average() call. 3D phase surfaces use directional data.
Integration uses ``dOmega = dphi * d(cos(theta))``. Each Born order's
phase density is its intensity divided by its integrated coefficient.
Refine both quadratures independently of output plot resolution.
"""
angles: Quantity
polar_samples: int = 32
azimuth_samples: int = 8
def __init__(
self,
*,
angles: Quantity | None = None,
start: Quantity | None = None,
end: Quantity | None = None,
n_points: int | None = None,
polar_samples: int = 32,
azimuth_samples: int = 8,
) -> None:
"""Initialize an angular output grid and its integration quadrature.
Parameters
----------
angles : Quantity, optional
Explicit one-dimensional polar angles between 0 and pi. Requires
explicit angular units and cannot be combined with range settings.
start, end : Quantity, optional
Inclusive endpoints for an evenly spaced polar-angle range.
Defaults are 0 and pi radians.
n_points : int, optional
Number of output polar angles, from 1 to 181; defaults to 121.
polar_samples : int, optional
Number of Gauss-Legendre nodes, from 16 to 128; defaults to 32.
azimuth_samples : int, optional
Number of azimuth directions, from 4 to 32; defaults to 8.
Returns
-------
None
Initializes the immutable sampling configuration.
Raises
------
ValueError
If the angle quantities, angle range, output count, or quadrature
sample counts are invalid.
"""
if angles is not None:
if any(value is not None for value in (start, end, n_points)):
raise ValueError("Supply angles or start/end/n_points, not both.")
else:
start = 0 * ureg.radian if start is None else start
validate_units(
start,
unit="radian",
name="start",
scalar=True,
)
end = np.pi * ureg.radian if end is None else end
validate_units(
end,
unit="radian",
name="end",
scalar=True,
)
invalid_endpoints = not (
np.isfinite(start)
and np.isfinite(end)
and 0 <= start <= np.pi * ureg.radian
and 0 <= end <= np.pi * ureg.radian
)
if invalid_endpoints:
raise ValueError("start and end must be finite angles between 0 and pi.")
count = _integer(value=121 if n_points is None else n_points, name="n_points", low=1, high=181)
angles = cast(Quantity, np.linspace(start, end, count))
validate_units(
angles,
unit="radian",
name="angles",
)
angles = angles.copy()
invalid_angles = (
angles.ndim != 1
or not 1 <= angles.size <= 181
or np.any(~np.isfinite(angles))
or np.any((angles < 0) | (angles > np.pi * ureg.radian))
)
if invalid_angles:
raise ValueError("angles must contain 1–181 finite angles between 0 and pi.")
angles.magnitude.setflags(write=False)
object.__setattr__(self, "angles", angles)
for name, value, low, high in (
("polar_samples", polar_samples, 16, 128),
("azimuth_samples", azimuth_samples, 4, 32),
):
object.__setattr__(self, name, _integer(value=value, name=name, low=low, high=high))
def __repr__(self) -> str:
"""Return a compact summary of the angle range and quadrature sizes.
Returns
-------
str
The number and range of output angles, in degrees, and the
integration sample counts.
"""
angles = self.angles.to("degree").magnitude
low, high = angles.min(), angles.max()
return (
f"AngularSampling(angles={len(angles)} in [{low:g}, {high:g}] deg, "
f"polar_samples={self.polar_samples}, azimuth_samples={self.azimuth_samples})"
)
@property
def azimuths(self) -> Quantity:
"""Return uniform output and integration azimuths.
Returns
-------
Quantity
Azimuth directions spanning one full turn, in radians.
"""
return np.arange(self.azimuth_samples) * 2 * np.pi / self.azimuth_samples * ureg.radian
@property
def directions(self) -> Directions:
"""Return unit directions for output and integration grids.
Returns
-------
Directions
Directions with output polar angles first, followed by the
Gauss-Legendre integration nodes, for each azimuth.
"""
cosine, _ = np.polynomial.legendre.leggauss(self.polar_samples)
theta = np.concatenate([self.angles.to("radian").magnitude, np.arccos(cosine)]) * ureg.radian
return Directions.from_angles(
polar_angles=theta[:, None],
azimuth_angles=self.azimuths[None, :],
)
@property
def metadata(self) -> dict[str, list[int | float] | int]:
"""Return fresh SI sampling settings for reproducible result archives.
Returns
-------
dict[str, list[int | float] | int]
Output angles in radians and the polar and azimuthal sample counts.
"""
return {
"angles_rad": self.angles.to("radian").magnitude.tolist(),
"polar_samples": self.polar_samples,
"azimuth_samples": self.azimuth_samples,
}
[docs]
def check_work(self, *, grid: Grid, order: int, realizations: int = 1) -> None:
"""Reject synchronous workloads above 100 million voxel-direction-orders.
Parameters
----------
grid : Grid
Spatial grid used to estimate the number of voxel-direction pairs.
order : int
Highest cumulative Born order in the requested calculation.
realizations : int, optional
Number of independent volumes; defaults to 1.
Returns
-------
None
Returns normally when the estimated workload is within the limit.
Raises
------
ValueError
If the estimate exceeds 100 million voxel-direction-orders.
"""
directions = (len(self.angles) + self.polar_samples) * self.azimuth_samples
if realizations * np.prod(grid.shape) * directions * order > 100_000_000:
raise ValueError("Requested scattering is too large; reduce grid, angles, order, or realizations.")
[docs]
def summarize(self, *, result: BornResult) -> dict[str, np.ndarray]:
"""Reduce numeric Born data while retaining all directional intensities.
Parameters
----------
result : BornResult
Numeric Born amplitudes, cumulative differential data, and
isolated-term differential data for one volume.
Returns
-------
dict[str, numpy.ndarray]
Numeric arrays with these keys: ``mean`` and ``terms`` have shape
(order, output angle); ``directional_terms`` and
``directional_differential`` have shape (order, output angle,
azimuth); ``directional_amplitudes`` adds incident polarization
and vector-component axes of sizes two and three; ``integrals``
has shape (order, 3), for total, first-moment, and reduced
coefficients.
Notes
-----
Output amplitudes retain (order, theta, phi, incident polarization,
vector component). Integrated coefficients are finite-sample cross
sections divided by voxel-box volume, not intrinsic material values.
"""
count = len(self.angles)
order = len(result.differential)
shape = (order, count + self.polar_samples, self.azimuth_samples)
directional = result.differential.reshape(shape)
averaged = directional.mean(axis=-1)
directional_terms = result.term_differential.reshape(shape)
terms = directional_terms.mean(axis=-1)
cosine, weights = np.polynomial.legendre.leggauss(self.polar_samples)
quadrature = averaged[:, count:]
mu = 2 * np.pi * np.sum(quadrature * weights, axis=-1)
moment = 2 * np.pi * np.sum(quadrature * weights * cosine, axis=-1)
reduced = 2 * np.pi * np.sum(quadrature * weights * (1 - cosine), axis=-1)
return {
"mean": averaged[:, :count],
"terms": terms[:, :count],
"directional_terms": directional_terms[:, :count],
"directional_differential": directional[:, :count],
"directional_amplitudes": result.amplitudes.reshape(*shape, 2, 3)[:, :count],
"integrals": np.stack([mu, moment, reduced], axis=-1),
}
@classmethod
def _resolve(cls, *, sampling: "AngularSampling | None" = None) -> "AngularSampling":
"""Return the supplied configuration or construct the default.
Parameters
----------
sampling : AngularSampling, optional
Configuration to validate and return. If omitted, construct the
default angular sampling configuration.
Returns
-------
AngularSampling
The validated supplied configuration or a default instance.
Raises
------
TypeError
If ``sampling`` is not an AngularSampling instance.
"""
if sampling is None:
return cls()
if not isinstance(sampling, cls):
raise TypeError("sampling must be an AngularSampling.")
return sampling