"""Python-side :class:`Scatterer` class.
Mirrors the API of :class:`pytmatrix.tmatrix.Scatterer` so downstream code
can use this module as a drop-in replacement. Orientation averaging is
provided by :mod:`rustmatrix.orientation` (pure Python; calls into the
Rust single-orientation evaluator in a loop). PSD integration is routed
through :mod:`rustmatrix.psd`, whose fast paths run inside Rust with the
GIL released and parallelise across diameters via rayon.
Notes
-----
The Rust extension caches the built T-matrix on the ``_handle`` attribute
and reuses it whenever the signature ``(radius, radius_type, wavelength,
m, axis_ratio, shape, ddelt, ndgs)`` is unchanged. Switching orientation
or scattering geometry costs only an amplitude-matrix rotation.
"""
from __future__ import annotations
import warnings
from dataclasses import dataclass, field
from typing import Optional, Tuple
import numpy as np
from . import _core
from . import orientation as _orientation
from .quadrature import get_points_and_weights as _qpw
[docs]
@dataclass
class Scatterer:
"""T-Matrix scattering from nonspherical particles (Rust backend).
API-compatible with :class:`pytmatrix.tmatrix.Scatterer`. Construct one
with the size / material / geometry attributes below, then call
:meth:`get_SZ` (or :meth:`get_S`, :meth:`get_Z`) to obtain the amplitude
and phase matrices.
Attributes
----------
radius : float
Particle "equivalent" radius in mm. Interpreted according to
:attr:`radius_type`. Default: 1.0.
radius_type : float
One of :data:`RADIUS_EQUAL_VOLUME` (default), :data:`RADIUS_EQUAL_AREA`,
or :data:`RADIUS_MAXIMUM`. Controls how ``radius`` is converted to
the Mishchenko-code equal-volume radius the solver expects.
wavelength : float
Incident-radiation wavelength in mm. Use the ``wl_*`` presets in
:mod:`rustmatrix.tmatrix_aux` for standard radar bands.
m : complex
Refractive index of the particle material. Use
:mod:`rustmatrix.refractive` for tabulated water/ice values.
axis_ratio : float
Horizontal over vertical axis ratio. ``axis_ratio > 1`` is oblate
(flattened raindrop); ``< 1`` is prolate (columnar ice); ``= 1`` is
a sphere.
shape : int
Particle shape code. :data:`SHAPE_SPHEROID` (-1, default),
:data:`SHAPE_CYLINDER` (-2), or :data:`SHAPE_CHEBYSHEV` (1).
ddelt : float
Convergence tolerance for the T-matrix solver. ``1e-3`` is usually
fine; tighten to ``1e-4`` for high-accuracy work. Default: 1e-3.
ndgs : int
Quadrature density factor. Increase for elongated particles or
large size parameters if the solver fails to converge. Default: 2.
alpha, beta : float
Particle Euler angles in degrees. For a single-orientation
evaluation both default to 0.
thet0, thet : float
Incident and scattering zenith angles in degrees (0 = north pole,
180 = south). Defaults: both 90 (horizontal propagation).
phi0, phi : float
Incident and scattering azimuth angles in degrees. Defaults:
``phi0 = 0``, ``phi = 180`` (backscatter).
Kw_sqr : float
|K_w|² dielectric factor used by :func:`radar.refl`. Standard
radar-band values are in :data:`tmatrix_aux.K_w_sqr`. Default: 0.93.
orient : callable
Orientation-averaging strategy from :mod:`rustmatrix.orientation`
(``orient_single``, ``orient_averaged_fixed``, or
``orient_averaged_adaptive``). Default: ``orient_single``.
or_pdf : callable
Orientation PDF returning weight given β in degrees. See
:func:`orientation.gaussian_pdf`, :func:`orientation.uniform_pdf`.
n_alpha, n_beta : int
Number of α / β samples for ``orient_averaged_fixed``. Defaults:
5 and 10.
psd_integrator : PSDIntegrator, optional
When set, :meth:`get_SZ` integrates S and Z against
:attr:`psd`. See :class:`rustmatrix.psd.PSDIntegrator`.
psd : PSD, optional
Particle-size distribution instance (e.g.
:class:`rustmatrix.psd.GammaPSD`).
suppress_warning : bool
Silence the ``DeprecationWarning`` emitted when legacy pytmatrix
kwargs (``axi``, ``lam``, ``eps``, ``rat``, ``np``, ``scatter``)
are used.
Examples
--------
>>> from rustmatrix import Scatterer
>>> s = Scatterer(radius=1.0, wavelength=33.3, m=complex(7.99, 2.21),
... axis_ratio=1.5, ddelt=1e-4, ndgs=2)
>>> s.set_geometry((90, 90, 0, 180, 0, 0)) # horizontal backscatter
>>> S, Z = s.get_SZ()
"""
radius: float = 1.0
radius_type: float = 1.0 # RADIUS_EQUAL_VOLUME
wavelength: float = 1.0
m: complex = 2.0 + 0.0j
axis_ratio: float = 1.0
shape: int = -1 # SHAPE_SPHEROID
ddelt: float = 1e-3
ndgs: int = 2
alpha: float = 0.0
beta: float = 0.0
thet0: float = 90.0
thet: float = 90.0
phi0: float = 0.0
phi: float = 180.0
Kw_sqr: float = 0.93
suppress_warning: bool = False
# Constants for convenience (mirror pytmatrix).
RADIUS_EQUAL_VOLUME = _core.RADIUS_EQUAL_VOLUME
RADIUS_EQUAL_AREA = _core.RADIUS_EQUAL_AREA
RADIUS_MAXIMUM = _core.RADIUS_MAXIMUM
SHAPE_SPHEROID = _core.SHAPE_SPHEROID
SHAPE_CYLINDER = _core.SHAPE_CYLINDER
SHAPE_CHEBYSHEV = _core.SHAPE_CHEBYSHEV
# Internal state (not part of the public API).
_handle: Optional[object] = field(default=None, repr=False)
_tm_signature: Tuple = field(default_factory=tuple, repr=False)
_scatter_signature: Tuple = field(default_factory=tuple, repr=False)
_orient_signature: Tuple = field(default_factory=tuple, repr=False)
_psd_signature: Tuple = field(default_factory=tuple, repr=False)
_S_single: Optional[np.ndarray] = field(default=None, repr=False)
_Z_single: Optional[np.ndarray] = field(default=None, repr=False)
_S: Optional[np.ndarray] = field(default=None, repr=False)
_Z: Optional[np.ndarray] = field(default=None, repr=False)
def __init__(self, **kwargs):
defaults = {
"radius": 1.0,
"radius_type": _core.RADIUS_EQUAL_VOLUME,
"wavelength": 1.0,
"m": complex(2, 0),
"axis_ratio": 1.0,
"shape": _core.SHAPE_SPHEROID,
"ddelt": 1e-3,
"ndgs": 2,
"alpha": 0.0,
"beta": 0.0,
"thet0": 90.0,
"thet": 90.0,
"phi0": 0.0,
"phi": 180.0,
"Kw_sqr": 0.93,
"suppress_warning": False,
# Orientation averaging defaults (match pytmatrix).
"orient": _orientation.orient_single,
"or_pdf": _orientation.gaussian_pdf(),
"n_alpha": 5,
"n_beta": 10,
# PSD integration (disabled by default).
"psd_integrator": None,
"psd": None,
}
deprecated = {
"axi": "radius",
"lam": "wavelength",
"eps": "axis_ratio",
"rat": "radius_type",
"np": "shape",
"scatter": "orient",
}
for key, new in deprecated.items():
if key in kwargs:
if not kwargs.get("suppress_warning", False):
warnings.warn(
f"'{key}' is deprecated; use '{new}'.",
DeprecationWarning,
stacklevel=2,
)
defaults[new] = kwargs.pop(key)
for k, v in kwargs.items():
defaults[k] = v
for k, v in defaults.items():
object.__setattr__(self, k, v)
object.__setattr__(self, "_handle", None)
object.__setattr__(self, "_tm_signature", ())
object.__setattr__(self, "_scatter_signature", ())
object.__setattr__(self, "_orient_signature", ())
object.__setattr__(self, "_psd_signature", ())
object.__setattr__(self, "_S_single", None)
object.__setattr__(self, "_Z_single", None)
object.__setattr__(self, "_S", None)
object.__setattr__(self, "_Z", None)
# ---------- convenience helpers ----------
[docs]
def set_geometry(self, geom):
"""Assign ``(thet0, thet, phi0, phi, alpha, beta)`` in one call.
``geom`` is a 6-tuple of degrees — use the ``geom_*`` presets from
:mod:`rustmatrix.tmatrix_aux` for the common radar cases.
"""
(self.thet0, self.thet, self.phi0, self.phi, self.alpha, self.beta) = geom
[docs]
def get_geometry(self):
"""Return the current ``(thet0, thet, phi0, phi, alpha, beta)`` tuple."""
return (self.thet0, self.thet, self.phi0, self.phi, self.alpha, self.beta)
[docs]
def equal_volume_from_maximum(self):
"""Equal-volume-sphere radius given ``self.radius`` as a maximum radius.
Only defined for :data:`SHAPE_SPHEROID` and :data:`SHAPE_CYLINDER`.
Used internally when :attr:`radius_type` is
:data:`RADIUS_MAXIMUM`.
"""
if self.shape == _core.SHAPE_SPHEROID:
if self.axis_ratio > 1.0:
return self.radius / self.axis_ratio ** (1.0 / 3.0)
return self.radius * self.axis_ratio ** (2.0 / 3.0)
if self.shape == _core.SHAPE_CYLINDER:
if self.axis_ratio > 1.0:
return self.radius * (1.5 / self.axis_ratio) ** (1.0 / 3.0)
return self.radius * (1.5 * self.axis_ratio ** 2) ** (1.0 / 3.0)
raise AttributeError("Unsupported shape for maximum radius.")
# ---------- core computation ----------
def _init_tmatrix(self):
if self.radius_type == _core.RADIUS_MAXIMUM:
radius_type = _core.RADIUS_EQUAL_VOLUME
radius = self.equal_volume_from_maximum()
else:
radius_type = self.radius_type
radius = self.radius
handle, nmax = _core.calctmat(
radius,
radius_type,
self.wavelength,
self.m.real,
self.m.imag,
self.axis_ratio,
int(self.shape),
self.ddelt,
int(self.ndgs),
)
object.__setattr__(self, "_handle", handle)
object.__setattr__(self, "nmax", nmax)
object.__setattr__(
self,
"_tm_signature",
(
self.radius,
self.radius_type,
self.wavelength,
self.m,
self.axis_ratio,
self.shape,
self.ddelt,
self.ndgs,
),
)
def _init_orient(self):
"""Build (beta_p, beta_w) quadrature against ``or_pdf`` if needed."""
if self.orient is _orientation.orient_averaged_fixed:
beta_p, beta_w = _qpw(self.or_pdf, 0, 180, self.n_beta)
object.__setattr__(self, "beta_p", beta_p)
object.__setattr__(self, "beta_w", beta_w)
object.__setattr__(
self,
"_orient_signature",
(self.orient, self.or_pdf, self.n_alpha, self.n_beta),
)
[docs]
def get_SZ_single(self, alpha=None, beta=None):
"""Amplitude and phase matrices at a single Euler orientation.
Parameters
----------
alpha, beta : float, optional
Euler angles in degrees. Default to ``self.alpha``, ``self.beta``.
Returns
-------
S : ndarray (2, 2) complex
Amplitude scattering matrix.
Z : ndarray (4, 4) float
Phase (Stokes) matrix.
Notes
-----
Cached on the handle; re-building the T-matrix only happens when the
size/material signature changes. Advancing only ``(alpha, beta)`` or
the scattering geometry reuses the existing T-matrix.
"""
if alpha is None:
alpha = self.alpha
if beta is None:
beta = self.beta
tm_outdated = self._tm_signature != (
self.radius,
self.radius_type,
self.wavelength,
self.m,
self.axis_ratio,
self.shape,
self.ddelt,
self.ndgs,
)
if tm_outdated or self._handle is None:
self._init_tmatrix()
sig = (self.thet0, self.thet, self.phi0, self.phi, alpha, beta)
scatter_outdated = self._scatter_signature != sig
if tm_outdated or scatter_outdated:
S, Z = _core.calcampl_py(
self._handle,
self.wavelength,
self.thet0,
self.thet,
self.phi0,
self.phi,
alpha,
beta,
)
object.__setattr__(self, "_S_single", np.asarray(S))
object.__setattr__(self, "_Z_single", np.asarray(Z))
object.__setattr__(self, "_scatter_signature", sig)
return self._S_single, self._Z_single
[docs]
def get_SZ_orient(self):
"""S and Z using ``self.orient`` (orientation-averaging dispatcher).
Dispatches to the callable in :attr:`orient` —
:func:`orientation.orient_single` by default.
"""
orient_outdated = self._orient_signature != (
self.orient,
self.or_pdf,
self.n_alpha,
self.n_beta,
)
if orient_outdated:
self._init_orient()
S, Z = self.orient(self)
object.__setattr__(self, "_S", np.asarray(S))
object.__setattr__(self, "_Z", np.asarray(Z))
return self._S, self._Z
[docs]
def get_SZ(self):
"""Amplitude + phase matrices, PSD-integrated if configured.
Returns
-------
S : ndarray (2, 2) complex
Z : ndarray (4, 4) float
Notes
-----
If :attr:`psd_integrator` is ``None`` this is equivalent to
:meth:`get_SZ_orient`. Otherwise it returns the N(D)-weighted
average over the scatter table built by
:meth:`PSDIntegrator.init_scatter_table`.
"""
if self.psd_integrator is None:
return self.get_SZ_orient()
scatter_sig = (
self.thet0,
self.thet,
self.phi0,
self.phi,
self.alpha,
self.beta,
self.orient,
)
psd_sig = (self.psd,)
if self._scatter_signature != scatter_sig or self._psd_signature != psd_sig:
S, Z = self.psd_integrator(self.psd, self.get_geometry())
object.__setattr__(self, "_S", np.asarray(S))
object.__setattr__(self, "_Z", np.asarray(Z))
object.__setattr__(self, "_scatter_signature", scatter_sig)
object.__setattr__(self, "_psd_signature", psd_sig)
return self._S, self._Z
[docs]
def get_S(self):
"""Amplitude matrix only — convenience wrapper around :meth:`get_SZ`."""
return self.get_SZ()[0]
[docs]
def get_Z(self):
"""Phase matrix only — convenience wrapper around :meth:`get_SZ`."""
return self.get_SZ()[1]
# pytmatrix compatibility alias
[docs]
class TMatrix(Scatterer):
def __init__(self, **kwargs):
if not kwargs.get("suppress_warning", False):
warnings.warn(
"'TMatrix' is deprecated; use 'Scatterer'.",
DeprecationWarning,
stacklevel=2,
)
super().__init__(**kwargs)