Tutorial 11 — Dual-frequency radar signatures across hydrometeor classes#

Reference: Honeyager, R., 2013. Investigating the use of the T-matrix method as a proxy for the discrete dipole approximation, M.S. thesis, Florida State University.

Honeyager’s thesis argues that the T-matrix applied to a size-matched spheroid, with a dielectric built from an ice / air mixing formula at the right volume fraction, reproduces the single-scattering properties of a geometrically-complex hydrometeor (bullet rosettes, plates, dendrites, aggregates) computed with DDA — provided the size parameter χ = 2π r / λ and the effective density are right.

That framing is directly useful for dual-frequency radar work: the whole zoo of ice habits collapses, to first order, onto a two-parameter family (effective density ρ_eff, axis ratio) that rustmatrix already supports via refractive.mi(wl, rho) and the axis_ratio keyword. This notebook walks through four representative hydrometeor classes:

class

ρ_eff [g/cm³]

axis ratio

rain

1.00 (water)

Thurai 2007

low-density aggregate

0.10

0.70

graupel

0.50

0.90

high-density ice

0.90

1.00

and shows how each class signs itself into single-particle σ_b(D), bulk DWR vs. D₀, and the spectral DWR profile sDWR(v).

import numpy as np
import matplotlib.pyplot as plt

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

CLASSES = {
    'rain':     dict(rho=1.00, axis_ratio=None),   # Thurai below
    'low-ρ agg':dict(rho=0.10, axis_ratio=0.70),
    'graupel':  dict(rho=0.50, axis_ratio=0.90),
    'high-ρ ice':dict(rho=0.90, axis_ratio=1.00),
}

def refractive_index(wl, cls):
    return m_w_10C[wl] if cls == 'rain' else mi(wl, CLASSES[cls]['rho'])

def class_axis_ratio(cls, D_mm):
    ar = CLASSES[cls]['axis_ratio']
    return (1.0 / dsr_thurai_2007(D_mm)) if ar is None else ar

COLORS = {'rain': 'C0', 'low-ρ agg': 'C2',
          'graupel': 'C1', 'high-ρ ice': 'C3'}

Single-particle σ_b(D) at X and W#

For each class, tabulate the single-particle backscatter cross-section at vertical incidence across 0.2–8 mm equivalent diameter. The key effects to watch:

  • Rayleigh regime — all classes follow σ_b ∝ D⁶ at small D, but offset vertically by |K(ρ)|² differences. Low-ρ ice sits ~30 dB below water at equal D.

  • Non-Rayleigh onset at W-band — the first Mie minimum appears once χ = πD/λ ≈ 1, i.e. D ≈ 1 mm at W-band. That happens for all four classes because χ is set by size, not ρ.

  • Mie oscillation amplitudethis is where ρ matters. Denser classes (rain, high-ρ ice) show sharp Mie minima/maxima; low-density aggregates barely oscillate because their refractive index contrast with air is small.

def sigma_b(wl, cls, D_mm):
    s = Scatterer(radius=D_mm/2, wavelength=wl,
                  m=refractive_index(wl, cls),
                  Kw_sqr=K_w_sqr[wl],
                  axis_ratio=class_axis_ratio(cls, D_mm),
                  ddelt=1e-4, ndgs=2)
    s.set_geometry(geom_vert_back)
    return radar.radar_xsect(s)

D_grid = np.linspace(0.2, 8.0, 100)
sigma = {cls: {b: np.array([sigma_b(wl, cls, D) for D in D_grid])
               for b, wl in [('X', wl_X), ('W', wl_W)]}
         for cls in CLASSES}

fig, (axX, axW) = plt.subplots(1, 2, figsize=(11, 4), sharey=True)
for cls in CLASSES:
    axX.loglog(D_grid, sigma[cls]['X'], color=COLORS[cls], label=cls)
    axW.loglog(D_grid, sigma[cls]['W'], color=COLORS[cls], label=cls)
axX.set_title('σ_b(D) at X-band (9 GHz)')
axW.set_title('σ_b(D) at W-band (94 GHz)')
for ax in (axX, axW):
    ax.set_xlabel('D [mm]')
    ax.grid(True, which='both', alpha=0.3)
    ax.legend(fontsize=9)
axX.set_ylabel(r'$\sigma_b$ [mm²]')
fig.tight_layout();
../_images/1f4e6a55f3e8c5c525789404c3841406035ce899bcdc5a5d44133022cf29a0b9.png

DWR(D) — single-particle dual-wavelength ratio#

Re-plotting as DWR(D) = 10·log₁₀(σ_b^X / σ_b^W) collapses the key information: the size where each class first walks out of Rayleigh. This is Honeyager’s thesis Table 2.2 idea generalised to arbitrary habit — the χ parameter that sets Rayleigh validity is hidden inside ρ_eff via the refractive index contrast.

# Equivalent reflectivity normalisation: Ze = λ⁴ / (π⁵ |K_w|²) · σ_b.
# In the Rayleigh regime Ze_X = Ze_W, so DWR_{X-W} = 0 dB; any positive
# excursion is a direct signature of non-Rayleigh scattering at W-band.
def single_Ze(sig, wl, Kw2):
    return wl**4 / (np.pi**5 * Kw2) * sig

fig, ax = plt.subplots(figsize=(8, 4.5))
dwr_curves_1p = {}
for cls in CLASSES:
    zeX = single_Ze(sigma[cls]['X'], wl_X, K_w_sqr[wl_X])
    zeW = single_Ze(sigma[cls]['W'], wl_W, K_w_sqr[wl_W])
    dwr = 10 * np.log10(zeX / zeW)
    dwr_curves_1p[cls] = dwr
    ax.plot(D_grid, dwr, color=COLORS[cls], lw=1.8, label=cls)
ax.axhline(0.0, color='k', ls=':', alpha=0.5, label='Rayleigh (DWR=0)')
ax.set_xlabel('diameter D [mm]')
ax.set_ylabel('single-particle DWR$_{X-W}$ [dB]')
ax.set_title('Where does each habit walk out of Rayleigh?')
ax.set_xlim(0, 8); ax.grid(True, alpha=0.3); ax.legend(fontsize=9)
fig.tight_layout();

print('Approximate D at which single-particle DWR crosses +3 dB:')
for cls, dwr in dwr_curves_1p.items():
    above = np.where(dwr > 3.0)[0]
    label = f'{D_grid[above[0]]:.2f} mm' if len(above) else '> 8 mm (stays Rayleigh)'
    print(f'  {cls:<12}  {label}')
Approximate D at which single-particle DWR crosses +3 dB:
  rain          1.07 mm
  low-ρ agg     0.91 mm
  graupel       1.07 mm
  high-ρ ice    1.15 mm
../_images/a08d3fe72beae33e0336a8167c3994b2c95d3640efa542bf6905ea16e0ea3ee0.png

Bulk DWR vs. median-volume diameter D₀#

The single-particle curves fold through a PSD in the bulk radar measurement. Hold the habit fixed, sweep the exponential PSD slope Λ (so D₀ = 3.67/Λ varies), and plot the resulting bulk DWR. The curve tells you how sensitive each habit’s dual-frequency signature is to the population-average particle size.

def bulk_Zh(wl, cls, N0, lam, Dmax):
    s = Scatterer(wavelength=wl, m=refractive_index(wl, cls),
                  Kw_sqr=K_w_sqr[wl], ddelt=1e-4, ndgs=2)
    integ = PSDIntegrator()
    integ.D_max = Dmax; integ.num_points = 128
    integ.geometries = (geom_vert_back,)
    if CLASSES[cls]['axis_ratio'] is None:
        integ.axis_ratio_func = lambda D: 1.0 / dsr_thurai_2007(D)
    else:
        s.axis_ratio = CLASSES[cls]['axis_ratio']
    s.psd_integrator = integ
    s.psd_integrator.init_scatter_table(s)
    s.psd = ExponentialPSD(N0=N0, Lambda=lam, D_max=Dmax)
    s.set_geometry(geom_vert_back)
    return radar.refl(s)

# Hold N0 fixed, sweep Λ so D0 = 3.67/Λ spans 0.4–3 mm
Lambdas = np.linspace(1.2, 9.0, 10)   # mm⁻¹
D0s = 3.67 / Lambdas
DMAX = 8.0; N0 = 8e3

dwr_curves = {}
for cls in CLASSES:
    dwr = []
    for lam in Lambdas:
        zX = bulk_Zh(wl_X, cls, N0, lam, DMAX)
        zW = bulk_Zh(wl_W, cls, N0, lam, DMAX)
        dwr.append(10 * np.log10(zX / zW))
    dwr_curves[cls] = np.asarray(dwr)

fig, ax = plt.subplots(figsize=(8, 4.5))
for cls, d in dwr_curves.items():
    ax.plot(D0s, d, color=COLORS[cls], lw=1.8, marker='o',
            markersize=4, label=cls)
ax.axhline(0.0, color='k', ls=':', alpha=0.5)
ax.set_xlabel('median-volume diameter $D_0$ [mm]')
ax.set_ylabel('bulk DWR$_{X-W}$ [dB]')
ax.set_title('Bulk DWR vs. PSD size — one line per hydrometeor class')
ax.grid(True, alpha=0.3); ax.legend(fontsize=9)
fig.tight_layout();
../_images/9320dc89121c61bac39a6c7dd7a12c2a1e9c10d9b39612c3ee527c9a11ce4749.png

Dual-frequency Doppler spectra — aggregate vs. graupel#

Now pull the spectral version together. Two bulk populations that can’t be told apart by reflectivity alone — a low-density aggregate PSD and a graupel PSD tuned to give the same X-band Z_h — show very different sDWR(v) profiles. Aggregates have a mostly-monotonic rise because their σ_b(D) curve stays smooth; graupel’s spectrum carries the Mie-resonance structure of its single-particle σ_b(D) through into velocity space, since every drop size maps deterministically to its own fall speed.

def build(cls, N0, lam, Dmax, wl):
    s = Scatterer(wavelength=wl, m=refractive_index(wl, cls),
                  Kw_sqr=K_w_sqr[wl], ddelt=1e-4, ndgs=2)
    integ = PSDIntegrator()
    integ.D_max = Dmax; integ.num_points = 256
    integ.geometries = (geom_vert_back,)
    if CLASSES[cls]['axis_ratio'] is None:
        integ.axis_ratio_func = lambda D: 1.0 / dsr_thurai_2007(D)
    else:
        s.axis_ratio = CLASSES[cls]['axis_ratio']
    s.psd_integrator = integ
    s.psd_integrator.init_scatter_table(s)
    s.psd = ExponentialPSD(N0=N0, Lambda=lam, D_max=Dmax)
    return s

# PSDs chosen so both give ~moderate snowfall X-band reflectivity.
# Aggregate: low density, broader PSD to Dmax=6 mm.
# Graupel:   medium density, narrower PSD (smaller max D).
agg_X = build('low-ρ agg', N0=2e4, lam=2.0, Dmax=6.0, wl=wl_X)
agg_W = build('low-ρ agg', N0=2e4, lam=2.0, Dmax=6.0, wl=wl_W)
gra_X = build('graupel',   N0=5e3, lam=3.0, Dmax=4.0, wl=wl_X)
gra_W = build('graupel',   N0=5e3, lam=3.0, Dmax=4.0, wl=wl_W)

fall_agg = spectra.fall_speed.locatelli_hobbs_1974_aggregates
fall_gra = spectra.fall_speed.locatelli_hobbs_1974_graupel_hex
turb = spectra.GaussianTurbulence(0.2)
V_MIN, V_MAX = -0.5, 4.0

def run(sc, fall):
    return SpectralIntegrator(
        sc, fall_speed=fall, turbulence=turb,
        v_min=V_MIN, v_max=V_MAX, n_bins=1024,
        geometry_backscatter=geom_vert_back,
    ).run()

r_agg_X = run(agg_X, fall_agg); r_agg_W = run(agg_W, fall_agg)
r_gra_X = run(gra_X, fall_gra); r_gra_W = run(gra_W, fall_gra)

def dBZ(x):
    return 10 * np.log10(np.maximum(x, 1e-12))

print(f'{"population":<15} {"Z_h X [dBZ]":>12} {"Z_h W [dBZ]":>12} '
      f'{"DWR [dB]":>10}')
for name, rX, rW in [('low-ρ agg', r_agg_X, r_agg_W),
                      ('graupel',   r_gra_X, r_gra_W)]:
    zX = 10 * np.log10(np.trapezoid(rX.sZ_h, rX.v))
    zW = 10 * np.log10(np.trapezoid(rW.sZ_h, rW.v))
    print(f'{name:<15} {zX:>12.2f} {zW:>12.2f} {zX - zW:>10.2f}')

fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(8, 6.5), sharex=True)
ax1.plot(r_agg_X.v, dBZ(r_agg_X.sZ_h), 'C2-', label='agg, X-band')
ax1.plot(r_agg_W.v, dBZ(r_agg_W.sZ_h), 'C2--', label='agg, W-band')
ax1.plot(r_gra_X.v, dBZ(r_gra_X.sZ_h), 'C1-', label='graupel, X-band')
ax1.plot(r_gra_W.v, dBZ(r_gra_W.sZ_h), 'C1--', label='graupel, W-band')
ax1.set_ylabel('sZ_h [dBZ / (m/s)]')
ax1.set_title('Dual-frequency Doppler spectra')
ax1.legend(fontsize=9); ax1.grid(True, alpha=0.3)

sdwr_agg = dBZ(r_agg_X.sZ_h) - dBZ(r_agg_W.sZ_h)
sdwr_gra = dBZ(r_gra_X.sZ_h) - dBZ(r_gra_W.sZ_h)
mask_agg = (r_agg_X.sZ_h > 1e-8) & (r_agg_W.sZ_h > 1e-10)
mask_gra = (r_gra_X.sZ_h > 1e-8) & (r_gra_W.sZ_h > 1e-10)
ax2.plot(r_agg_X.v[mask_agg], sdwr_agg[mask_agg], 'C2-',
         lw=1.6, label='low-ρ agg')
ax2.plot(r_gra_X.v[mask_gra], sdwr_gra[mask_gra], 'C1-',
         lw=1.6, label='graupel')
ax2.axhline(0.0, color='k', ls=':', alpha=0.5)
ax2.set_xlabel('Doppler velocity v [m/s]')
ax2.set_ylabel('sDWR$_{X-W}$ [dB]')
ax2.set_title('Spectral DWR — aggregates smooth, graupel resonant')
ax2.set_xlim(V_MIN, V_MAX)
ax2.legend(fontsize=9); ax2.grid(True, alpha=0.3)
fig.tight_layout();
population       Z_h X [dBZ]  Z_h W [dBZ]   DWR [dB]
low-ρ agg              23.25         3.42      19.83
graupel                19.30         9.06      10.25
../_images/e0d8e63a28bfee2a9830c6c9a830ce64ddd91c352744ad769204e12b57678f85.png

Takeaway#

Following Honeyager 2013’s framing, we’ve used a single T-matrix spheroid code path with four choices of (ρ_eff, axis ratio) to span four physically distinct hydrometeor classes. Two observations pop out that matter for interpreting dual-frequency radar data:

  • The size at which DWR departs from zero is set by χ = πD/λ (≈ 1 mm at W-band), not by ρ_eff. But the amplitude and shape of the DWR(D) curve beyond that threshold is a direct fingerprint of ρ_eff: dense classes oscillate strongly, low-ρ aggregates rise smoothly and slowly.

  • Bulk DWR sweeps out very different trajectories with D₀, so a single (Z_h, DWR) measurement can be mapped to a habit-dependent D₀ estimate provided you commit to a class — this is exactly the lookup-table strategy that papers like Mason et al. 2019 and Tridon et al. 2019 build their retrievals on.

  • Spectral DWR resolves habit ambiguity that bulk DWR can’t: graupel’s Mie resonances appear as wiggles in sDWR(v), while low-ρ aggregates produce a smooth monotonic rise.

Swapping (ρ_eff, axis_ratio) for any other habit class (dendrites, rimed aggregates, hail) is one CLASSES dict entry away.