rustmatrix 2.1.1

Rust-backed T-matrix scattering for nonspherical particles (port of pytmatrix)
Documentation
"""Tutorial 13 — Sensitivity of Doppler moments to wind and turbulence.

Physics question
----------------
A vertically pointing radar observing rain sees a Doppler spectrum that
is broadened by two distinct mechanisms:

* **Turbulence**. Eddies at scales smaller than the resolution volume
  rearrange the drop velocities and convolve the fall-speed spectrum
  with a (near-)Gaussian kernel of width σ_t.
* **Horizontal wind through a finite beam**. Any pixel off the beam
  boresight contributes a velocity component ``u_h · sin θ · cos(φ−φ_w)``
  to the line-of-sight. Integrated across the beam pattern, a
  horizontal wind ``u_h`` and one-way half-power beamwidth ``θ_b``
  produces a deterministic spectral broadening of width
  ``σ_beam = |u_h| · θ_b / (2 √(2 ln 2))`` (Doviak & Zrnić 1993, §5.3).

Both widths add in quadrature: ``σ_eff² = σ_t² + σ_beam²``. Their
effect on the first moment (mean Doppler velocity) is small — they
both broaden the spectrum symmetrically about its mean. Their effect
on the second moment (spectral width) is the quadrature sum with the
intrinsic fall-speed spread of the DSD.

This tutorial sweeps

* ``u_h`` ∈ {0, 5, 10, 20} m/s  (magnitude-only; no height variation)
* ``θ_b`` ∈ {1°, 3°, 5°}        (one-way HPBW)

at fixed ``σ_t² = 0.5 m²/s²`` (σ_t ≈ 0.707 m/s) for a W-band
vertically pointing radar looking at a Marshall–Palmer rain DSD. We
tabulate the first and second moments of ``sZ_h`` alongside the
closed-form σ_beam prediction. The resulting 4 × 3 matrix is a direct
map from wind+beam geometry to spectral-width bias — exactly the term
an operational Doppler moment estimator has to model or ignore.

Scene assumption
----------------
Horizontal wind is a **constant-magnitude, height-independent** shear
across the resolution volume — the regime where the Doviak–Zrnić
closed-form applies exactly. Spatially varying Z, w, and u_h are
taken up in Tutorial 14.
"""

from __future__ import annotations

import numpy as np

from rustmatrix import Scatterer, SpectralIntegrator, spectra
from rustmatrix.psd import ExponentialPSD, PSDIntegrator
from rustmatrix.refractive import m_w_10C
from rustmatrix.tmatrix_aux import (K_w_sqr, dsr_thurai_2007,
                                     geom_vert_back, wl_W)


# --- configuration ----------------------------------------------------------

BEAMWIDTHS_DEG = (1.0, 3.0, 5.0)    # one-way HPBW sweep
SIGMA_T_SQ = 0.5                    # [m²/s²] turbulent variance
SIGMA_T = float(np.sqrt(SIGMA_T_SQ))
U_H_LIST = (0.0, 5.0, 10.0, 20.0)   # [m/s] horizontal wind sweep
V_MIN, V_MAX, N_BINS = -1.0, 14.0, 1024
FWHM = 2.0 * np.sqrt(2.0 * np.log(2.0))


# --- scatterer setup --------------------------------------------------------

def build_rain_W() -> Scatterer:
    s = Scatterer(wavelength=wl_W, m=m_w_10C[wl_W],
                  Kw_sqr=K_w_sqr[wl_W], ddelt=1e-4, ndgs=2)
    integ = PSDIntegrator()
    integ.D_max = 5.0
    integ.num_points = 64
    integ.axis_ratio_func = lambda D: 1.0 / dsr_thurai_2007(D)
    integ.geometries = (geom_vert_back,)
    s.psd_integrator = integ
    s.psd_integrator.init_scatter_table(s)
    s.psd = ExponentialPSD(N0=8000.0, Lambda=2.2, D_max=5.0)
    return s


def moments(v: np.ndarray, sZh: np.ndarray) -> tuple[float, float]:
    """Reflectivity-weighted mean (m/s) and spectral width (m/s)."""
    sZh = np.clip(sZh, 0.0, None)
    P = sZh.sum()
    if P == 0:
        return float("nan"), float("nan")
    mu = float((v * sZh).sum() / P)
    var = float(((v - mu) ** 2 * sZh).sum() / P)
    return mu, float(np.sqrt(max(var, 0.0)))


def run_case(rain: Scatterer, u_h: float, hpbw_rad: float):
    """Run one SpectralIntegrator configuration."""
    return SpectralIntegrator(
        rain,
        fall_speed=spectra.brandes_et_al_2002,
        turbulence=spectra.GaussianTurbulence(sigma_t=SIGMA_T),
        v_min=V_MIN, v_max=V_MAX, n_bins=N_BINS,
        u_h=u_h, beamwidth=hpbw_rad,
    ).run()


# --- main -------------------------------------------------------------------

def main() -> None:
    rain = build_rain_W()

    # Reference: turbulence only (no wind, no beam), for the DSD-intrinsic
    # spectral width σ_ref that we then quadrature-add σ_beam to.
    r_ref = run_case(rain, u_h=0.0, hpbw_rad=0.0)
    mu_ref, sig_ref = moments(r_ref.v, r_ref.sZ_h)

    print("Tutorial 13 — wind × turbulence sensitivity of Doppler moments")
    print("-" * 72)
    print(f"W-band Marshall-Palmer rain, σ_t² = {SIGMA_T_SQ:.2f} m²/s² "
          f"(σ_t = {SIGMA_T:.3f} m/s)")
    print(f"Reference (no wind, no beam): μ = {mu_ref:.3f} m/s, "
          f"σ_ref = {sig_ref:.3f} m/s")
    print()

    rows = []
    for theta_deg in BEAMWIDTHS_DEG:
        theta = np.deg2rad(theta_deg)
        header = (f"  θ_b = {theta_deg:.1f}°  "
                  f"{'u_h':>7}  {'σ_beam':>8}  {'σ_predicted':>12}  "
                  f"{'μ_obs':>8}  {'σ_obs':>8}  {'bias_μ':>8}")
        print(header)
        print("  " + "-" * (len(header) - 2))
        for u_h in U_H_LIST:
            r = run_case(rain, u_h=u_h, hpbw_rad=theta)
            mu, sig = moments(r.v, r.sZ_h)
            sig_beam = u_h * theta / FWHM
            sig_predicted = float(np.sqrt(sig_ref ** 2 + sig_beam ** 2))
            bias = mu - mu_ref
            rows.append((theta_deg, u_h, sig_beam, sig_predicted,
                         mu, sig, bias))
            print(f"             {u_h:7.2f}  {sig_beam:8.3f}  "
                  f"{sig_predicted:12.3f}  {mu:8.3f}  {sig:8.3f}  "
                  f"{bias:+8.4f}")
        print()

    print("Interpretation")
    print("-" * 72)
    print("* First moment (μ) is invariant under |u_h| for all beams tested:")
    print("  beam broadening is an *even* function of off-axis angle for a")
    print("  circularly symmetric beam, so it cannot bias μ. Any bias in")
    print("  the table above is grid-discretisation noise (< 1 cm/s).")
    print("* Second moment (σ) grows monotonically with u_h. For a fixed")
    print("  beam, the growth follows the quadrature sum σ = √(σ_ref² +")
    print("  σ_beam²), and σ_beam scales linearly with both u_h and θ_b.")
    print("* A narrow beam (1°) is largely insensitive to horizontal wind")
    print("  — σ_beam(20 m/s, 1°) ≈ 0.15 m/s is only 2 % of σ_ref. A 5°")
    print("  beam inflates σ by ≈ 10 % at u_h = 20 m/s and becomes the")
    print("  dominant extra-width term in turbulence-quiet conditions.")
    print("  Retrievals that invert σ for turbulence or drop-size")
    print("  dispersion must subtract σ_beam or risk attributing the")
    print("  extra width to the target microphysics.")


if __name__ == "__main__":
    main()