Source code for bornsim.directions
"""Validated observation directions for numerical scattering."""
from dataclasses import dataclass
from collections.abc import Sequence
import numpy as np
from numpy.typing import NDArray
from .units import Quantity, _dimensionless, validate_units, ureg
[docs]
@dataclass(frozen=True, kw_only=True, init=False, eq=False, repr=False)
class Directions:
"""Define an ordered collection of Cartesian unit observation vectors.
Parameters
----------
vectors : array_like or Quantity
Explicit dimensionless vectors of shape (n, 3), with 1 to 16384 rows.
Every vector must be finite and have unit length. Vectors are copied
into an immutable array; they are never normalized automatically.
Notes
-----
Coordinates use the global x, y, z axes. The incident wave propagates
along positive z. Vector order is preserved in scattered amplitudes.
Use ``vectors`` to access the NumPy array and :meth:`from_angles` to
construct vectors from explicitly unit-bearing spherical coordinates.
"""
vectors: NDArray[np.float64]
def __init__(self, *, vectors: NDArray[np.float64] | Sequence[Sequence[float]] | Quantity) -> None:
coordinate_values = vectors.magnitude if isinstance(vectors, Quantity) else vectors
if np.iscomplexobj(coordinate_values):
raise ValueError("directions.vectors must contain real Cartesian coordinates.")
vectors = _dimensionless(value=vectors, name="directions.vectors")
if vectors.ndim != 2 or vectors.shape[1] != 3 or not 1 <= len(vectors) <= 16384:
raise ValueError("directions.vectors must contain 1–16384 unit 3-vectors.")
invalid_vectors = not np.all(np.isfinite(vectors)) or not np.allclose(
np.linalg.norm(vectors, axis=1), 1, atol=1e-10, rtol=0
)
if invalid_vectors:
raise ValueError("directions.vectors must be finite unit vectors.")
# Immutable backing storage prevents changing a cached solver configuration.
immutable_vectors = np.frombuffer(vectors.tobytes(), dtype=np.float64).reshape(vectors.shape)
object.__setattr__(self, "vectors", immutable_vectors)
[docs]
@classmethod
def from_angles(cls, *, polar_angles: Quantity, azimuth_angles: Quantity) -> "Directions":
"""Convert paired or broadcastable spherical angles into unit vectors.
Polar angles are measured from positive z and lie in [0, pi]. Azimuth
angles are measured from positive x toward positive y and are periodic.
Both inputs require explicit angular units. Broadcast coordinates are
flattened in row-major order, preserving polar/azimuth grid ordering.
"""
validate_units(
polar_angles,
unit="radian",
name="polar_angles",
)
validate_units(
azimuth_angles,
unit="radian",
name="azimuth_angles",
)
invalid_polar_angles = np.any(~np.isfinite(polar_angles)) or np.any(
(polar_angles < 0) | (polar_angles > np.pi * ureg.radian)
)
if invalid_polar_angles:
raise ValueError("polar_angles must be finite and between zero and pi.")
if np.any(~np.isfinite(azimuth_angles)):
raise ValueError("azimuth_angles must be finite.")
# Trigonometric quantities become dimensionless Cartesian coordinates.
cartesian_components = np.broadcast_arrays(
(np.sin(polar_angles) * np.cos(azimuth_angles)).magnitude,
(np.sin(polar_angles) * np.sin(azimuth_angles)).magnitude,
np.cos(polar_angles).magnitude,
)
return cls(vectors=np.stack(cartesian_components, axis=-1).reshape(-1, 3))
def __len__(self) -> int:
return len(self.vectors)
def __repr__(self) -> str:
return f"Directions(n_vectors={len(self)})"