Radar Rainfall Too High: Hail and the Bright Band

On this page

Problem statement

Your radar rainfall estimate is far above what the gauges caught — not by a few percent but by a factor of two or ten, in a compact patch. Two physical causes produce this, and neither is a bug in your code. Hail is a strong reflector: a core of 65–75 dBZ turned into rain by a Z–R relationship becomes 200–1,000 mm/h, rain no storm produces. And the bright band — the layer where snow melts into rain — reflects more strongly than the snow above or the rain below, so wherever the beam passes through it, every gate looks wetter than the rain reaching the ground.

This page measures both in real radar data: the Denver hailstorm of 8 May 2017 from the KFTG radar, checked against 155 rain gauges, and the melting layer in Hurricane Beryl's rain from KHGX on 8 July 2024, checked against a radiosonde.

Quick answer

Cap reflectivity before converting it to rain, as NEXRAD's own precipitation processing does at 53 dBZ by default, and compare with gauges:

for name in ("nocap", "cap"):
    print(f"{name:5s}: radar/gauge {g[name].sum() / g.PRCP.sum():.2f}, mean bias {(g[name] - g.PRCP).mean():+.1f} mm, "
          f"r = {np.corrcoef(g[name], g.PRCP)[0, 1]:.2f}")
nocap: radar/gauge 1.75, mean bias +13.0 mm, r = 0.41
cap  : radar/gauge 0.93, mean bias -1.2 mm, r = 0.49
Two maps of the Denver metro storm total from the KFTG radar, 19 to 23 UTC on 8 May 2017, with gauge totals as coloured dots. Without a cap, a swath across Lakewood, Arvada and north-east Denver exceeds 120 to 250 mm while the dots inside it show 10 to 40 mm. With reflectivity capped at 53 dBZ the swath falls to 30 to 60 mm, close to the dots. Two maps of the Denver metro storm total from the KFTG radar, 19 to 23 UTC on 8 May 2017, with gauge totals as coloured dots. Without a cap, a swath across Lakewood, Arvada and north-east Denver exceeds 120 to 250 mm while the dots inside it show 10 to 40 mm. With reflectivity capped at 53 dBZ the swath falls to 30 to 60 mm, close to the dots.
Radar storm total for the 2017 Denver hailstorm without and with the 53 dBZ cap, against 155 CoCoRaHS and COOP gauges. Data: NOAA NEXRAD Level II KFTG; NOAA NCEI daily summaries.

Recognise hail, see what Z-R does with it, cap and accumulate

1. Recognise hail in the radar data

Hail has a dual-polarisation fingerprint: very high reflectivity, differential reflectivity near zero because tumbling stones look round on average, and a correlation coefficient a little below that of pure rain. Compare the hail core with heavy rain in the same scan:

sw = xd.io.open_nexradlevel2_datatree(str(FILES[HAIL_SCAN]), sweep=[0])["sweep_0"].ds
z, zdr, rho = sw.DBZH.values, sw.ZDR.values, sw.RHOHV.values
for label, m in (("40-50 dBZ (rain)", (z >= 40) & (z < 50)), (">= 55 dBZ (hail core)", z >= 55)):
    print(f"{label:22s}: {m.sum():5d} gates, median ZDR {np.nanmedian(zdr[m]):5.2f} dB, "
          f"median rho_hv {np.nanmedian(rho[m]):.3f}, max Z {np.nanmax(z[m]):.0f}")
40-50 dBZ (rain)      :  8340 gates, median ZDR  0.88 dB, median rho_hv 0.988, max Z 50
>= 55 dBZ (hail core) :  2457 gates, median ZDR  0.00 dB, median rho_hv 0.982, max Z 76

Rain at 40–50 dBZ has large, flattened drops and a ZDR well above zero. The hail core is far more reflective, yet its ZDR is lower: the extra reflectivity is not coming from more rain.

Three radar panels of the Denver hailstorm at 21:15 UTC: reflectivity with a core above 65 dBZ west of the radar outlined by the 55 dBZ contour; differential reflectivity near zero inside that contour and higher around it; correlation coefficient around 0.97 to 0.98 in the core. Three radar panels of the Denver hailstorm at 21:15 UTC: reflectivity with a core above 65 dBZ west of the radar outlined by the 55 dBZ contour; differential reflectivity near zero inside that contour and higher around it; correlation coefficient around 0.97 to 0.98 in the core.
The hail signature at 21:15 UTC on 8 May 2017: high reflectivity, ZDR near zero, ρhv slightly reduced. The black line is 55 dBZ. Data: NOAA NEXRAD Level II, KFTG.

2. See what a Z–R relationship does with a hail core

for dbz in (45, 53, 60, 65, 72.5):
    print(f"{dbz:5.1f} dBZ -> {(10 ** (dbz / 10) / 300) ** (1 / 1.4):6.0f} mm/h")
 45.0 dBZ ->     28 mm/h
 53.0 dBZ ->    104 mm/h
 60.0 dBZ ->    328 mm/h
 65.0 dBZ ->    747 mm/h
 72.5 dBZ ->   2566 mm/h

Every 5 dBZ above 53 more than doubles the rain rate. A single five-minute scan at 72.5 dBZ adds about 210 mm — more than four times what the wettest gauge in the metro, 48 mm, caught all day.

3. Accumulate with and without the cap and check against gauges

The quick answer compares the two storm totals at every gauge. The gauges under the hail core show where the difference comes from:

core = g[g.zmax >= 60].sort_values("nocap", ascending=False)
print(f"{len(core)} gauges under at least 60 dBZ: gauge {core.PRCP.mean():.1f} mm, "
      f"radar {core.nocap.mean():.1f} mm uncapped, {core.cap.mean():.1f} mm capped")
print(core.head(5)[["NAME", "PRCP", "nocap", "cap", "zmax"]].round(1).to_string(index=False))
31 gauges under at least 60 dBZ: gauge 23.2 mm, radar 93.3 mm uncapped, 32.4 mm capped
                          NAME  PRCP  nocap  cap  zmax
         ARVADA 2.1 SSE, CO US   9.1  218.0 23.5  72.5
         DENVER 6.1 WSW, CO US  33.0  191.9 36.0  67.0
         LAKEWOOD 1.2 E, CO US  27.2  145.8 26.6  66.5
  COMMERCE CITY 1.2 WNW, CO US  48.3  134.4 46.5  64.5
DENVER WATER DEPARTMENT, CO US  31.2  128.8 29.0  65.5

Without the cap the radar is four times the gauges under the core, and 24 times at the worst gauge. The cap removes most of that, but not all: even capped, these gauges get 40% more from the radar than they caught. The cap is a blunt instrument — it also clips genuinely heavy rain, which is why dual-pol rainfall algorithms switch to a KDP-based relationship in hail instead.

Radar storm total over gauge total at 155 Denver gauges, grouped by the strongest reflectivity over each gauge, with and without the 53 dBZ cap. the harder the core, the bigger the errorbelow 55 dBZ, uncapped0.52× (94 gauges)below 55 dBZ, capped0.51×55–60 dBZ, uncapped1.96× (30 gauges)55–60 dBZ, capped1.41×60+ dBZ, uncapped4.02× (31 gauges)60+ dBZ, capped1.40×01×2×3×4×KFTG 0.5°, Z = 300R¹·⁴, 19–23 UTC, 8 May 2017 spatialworkflow.io
Radar against gauges by how hard the core over each gauge was. The cap removes most, not all, of the hail error.

4. Find the bright band in a vertical profile

Melting snowflakes are large, wet and irregular: they reflect like big raindrops, they flatten into higher ZDR, and their mix of shapes lowers ρhv. Build a vertical profile from every tilt of one volume:

H, Z, RH, ZD = [], [], [], []
for angle, name in tilts.items():
    ds = vol[name].ds
    if "RHOHV" not in ds:
        continue
    r = ds.range.values / 1000
    near = (r > 20) & (r < 100)
    h = np.broadcast_to(beam_height(r, angle)[None, :], ds.DBZH.shape)[:, near]
    zz = ds.DBZH.values[:, near]
    ok = zz > 10
    H.append(h[ok]); Z.append(zz[ok]); RH.append(ds.RHOHV.values[:, near][ok]); ZD.append(ds.ZDR.values[:, near][ok])
H, Z, RH, ZD = map(np.concatenate, (H, Z, RH, ZD))
for lo in np.arange(2.5, 7.0, 0.5):
    m = (H >= lo) & (H < lo + 0.5)
    print(f"{lo:.1f}-{lo + 0.5:.1f} km: mean {10 * np.log10(np.mean(10 ** (Z[m] / 10))):4.1f} dBZ, "
          f"rho_hv < 0.95 in {np.mean(RH[m] < 0.95):4.0%}, median ZDR {np.nanmedian(ZD[m]):.2f}")
print(f"0 C at Lake Charles, 12 UTC: {zero_c:,.0f} m")
2.5-3.0 km: mean 34.8 dBZ, rho_hv < 0.95 in   0%, median ZDR 0.50
3.0-3.5 km: mean 34.7 dBZ, rho_hv < 0.95 in   0%, median ZDR 0.47
3.5-4.0 km: mean 34.4 dBZ, rho_hv < 0.95 in   4%, median ZDR 0.44
4.0-4.5 km: mean 35.3 dBZ, rho_hv < 0.95 in  13%, median ZDR 0.44
4.5-5.0 km: mean 35.7 dBZ, rho_hv < 0.95 in  25%, median ZDR 0.59
5.0-5.5 km: mean 33.4 dBZ, rho_hv < 0.95 in  22%, median ZDR 0.47
5.5-6.0 km: mean 27.7 dBZ, rho_hv < 0.95 in  10%, median ZDR 0.28
6.0-6.5 km: mean 23.2 dBZ, rho_hv < 0.95 in   2%, median ZDR 0.28
6.5-7.0 km: mean 20.3 dBZ, rho_hv < 0.95 in   1%, median ZDR 0.34
0 C at Lake Charles, 12 UTC: 4,653 m

All three fields agree on a layer at about 4.5–5 km, and the nearest radiosonde, 190 km to the east, puts the freezing level at 4,653 m — inside it. In Beryl's tropical rain the bright band is weak — a dBZ or so above the rain below it — because the rain is dominated by warm-cloud processes, but the ρhv signature is unmistakable.

5. Find where the lowest beam crosses it

for edge in (4.25, 5.25):
    r = brentq(lambda rr: beam_height(rr, 0.48) - edge, 1, 400)
    print(f"0.48 deg beam centre reaches {edge} km at {r:.0f} km range")
0.48 deg beam centre reaches 4.25 km at 207 km range
0.48 deg beam centre reaches 5.25 km at 236 km range

Beyond that range, the lowest tilt samples melting snow and then snow, not rain. Rainfall estimates there need a vertical-profile correction, or should come from a closer radar.

Three vertical profile charts from the KHGX 09:01 UTC volume: mean reflectivity falls slowly with height, bumps to about 36 dBZ at 4.5 to 5 km and then drops steeply; the share of gates with correlation coefficient below 0.95 is near zero except a peak of about 27 percent at 4.75 to 5 km; median ZDR peaks at about 0.65 dB in the same layer. Three vertical profile charts from the KHGX 09:01 UTC volume: mean reflectivity falls slowly with height, bumps to about 36 dBZ at 4.5 to 5 km and then drops steeply; the share of gates with correlation coefficient below 0.95 is near zero except a peak of about 27 percent at 4.75 to 5 km; median ZDR peaks at about 0.65 dB in the same layer.
Beryl's melting layer as the radar saw it, from all 14 tilts at 20–100 km. The shaded band is 4.25–5.25 km; the Lake Charles sounding put 0 °C at 4,653 m. Data: NOAA NEXRAD Level II, KHGX.

How much of the overestimate is the hail core

The data used on this page

NOAA NEXRAD Level II from the Unidata archive on AWS: KFTG (Denver), every volume from 19:00 to 23:00 UTC on 8 May 2017, and KHGX, 09:01 UTC on 8 July 2024. Daily precipitation for 9 May 2017 (the 24 hours ending that morning) from the NCEI daily-summaries service — mostly CoCoRaHS volunteer gauges. The Lake Charles (72240) radiosonde from the University of Wyoming sounding archive.

import datetime as dt
import io
import re
from pathlib import Path

import numpy as np
import pandas as pd
import requests
import xradar as xd
from pyproj import Geod
from scipy.optimize import brentq

Path("data/kftg").mkdir(parents=True, exist_ok=True)
L2 = "https://unidata-nexrad-level2.s3.amazonaws.com/"
FILES = []
for hour in ("19", "20", "21", "22"):
    listing = requests.get(L2, params={"list-type": 2, "prefix": f"2017/05/08/KFTG/KFTG20170508_{hour}"}, timeout=60).text
    for key in (k for k in re.findall(r"<Key>([^<]+)</Key>", listing) if k.endswith("_V06")):
        path = Path("data/kftg") / key.split("/")[-1]
        if not path.exists():
            path.write_bytes(requests.get(L2 + key, timeout=300).content)
        FILES.append(path)
HAIL_SCAN = [p.name for p in FILES].index("KFTG20170508_211531_V06")

API = "https://www.ncei.noaa.gov/access/services"
found = requests.get(f"{API}/search/v1/data", params={"dataset": "daily-summaries", "bbox": "39.95,-105.3,39.5,-104.75",
                     "startDate": "2017-05-09T00:00:00", "endDate": "2017-05-09T23:59:59", "dataTypes": "PRCP", "limit": 1000}, timeout=120).json()
ids = sorted({s["id"] for res in found["results"] for s in res["stations"]})
g = pd.concat(pd.read_csv(io.StringIO(requests.get(f"{API}/data/v1", params={
    "dataset": "daily-summaries", "stations": ",".join(ids[i:i + 100]), "startDate": "2017-05-09", "endDate": "2017-05-09",
    "dataTypes": "PRCP", "format": "csv", "includeStationLocation": "1", "includeStationName": "true", "units": "metric"},
    timeout=120).text)) for i in range(0, len(ids), 100)).dropna(subset=["PRCP"]).reset_index(drop=True)

# storm total at each gauge from the 0.5 deg tilt, Z = 300 R^1.4, with and without the 53 dBZ cap
az, _, dist = Geod(ellps="WGS84").inv(np.full(len(g), -104.5458), np.full(len(g), 39.7866), g.LONGITUDE.values, g.LATITUDE.values)
times = [dt.datetime.strptime(p.name[4:19], "%Y%m%d_%H%M%S") for p in FILES]
hours = np.diff([*times, times[-1] + dt.timedelta(minutes=5)]) / dt.timedelta(hours=1)
rate = lambda dbz: np.where(dbz > 5, (10 ** (dbz / 10) / 300) ** (1 / 1.4), 0.0)
g["cap"] = g["nocap"] = 0.0
g["zmax"] = -30.0
for path, h in zip(FILES, hours):
    s0 = xd.io.open_nexradlevel2_datatree(str(path), sweep=[0])["sweep_0"].ds
    i = np.array([np.argmin(np.abs((s0.azimuth.values - a + 180) % 360 - 180)) for a in az % 360])
    j = np.searchsorted(s0.range.values, dist).clip(0, s0.sizes["range"] - 1)
    zg = np.nan_to_num(s0.DBZH.values[i, j], nan=-30)
    g["zmax"] = np.maximum(g.zmax, zg)
    g["cap"] += rate(np.minimum(zg, 53)) * h
    g["nocap"] += rate(zg) * h

KEY = "2024/07/08/KHGX/KHGX20240708_090125_V06"
khgx = Path("data") / KEY.split("/")[-1]
if not khgx.exists():
    khgx.write_bytes(requests.get(L2 + KEY, timeout=300).content)
vol = xd.io.open_nexradlevel2_datatree(str(khgx))
tilts = {}
for name in sorted((c for c in vol.children if c.startswith("sweep")), key=lambda c: int(c.split("_")[1])):
    tilts.setdefault(round(float(vol[name].ds.sweep_fixed_angle), 2), name)


def beam_height(r_km, elev_deg, ke=4 / 3, a=6371.0):
    return np.sqrt(r_km**2 + (ke * a) ** 2 + 2 * r_km * ke * a * np.sin(np.radians(elev_deg))) - ke * a


snd = pd.read_csv(io.StringIO(requests.get("https://weather.uwyo.edu/wsgi/sounding",
                  params={"datetime": "2024-07-08 12:00:00", "id": "72240", "type": "TEXT:CSV"}, timeout=120).text))
snd.columns = [c.strip() for c in snd.columns]
t, hgt = snd["temperature_C"].values, snd["geopotential height_m"].values
k = np.nonzero((t[:-1] > 0) & (t[1:] <= 0))[0][0]
zero_c = hgt[k] + (hgt[k + 1] - hgt[k]) * t[k] / (t[k] - t[k + 1])
print(f"{len(FILES)} KFTG volumes {times[0]:%H:%M}-{times[-1]:%H:%M} UTC; {len(g)} gauges, "
      f"{g.PRCP.min():.0f}-{g.PRCP.max():.0f} mm; {len(tilts)} KHGX tilts")
52 KFTG volumes 19:01-22:59 UTC; 155 gauges, 0-48 mm; 14 KHGX tilts

Example 1: how much of the overestimate is the hail core?

for lo, hi in ((-30, 55), (55, 60), (60, 80)):
    m = (g.zmax >= lo) & (g.zmax < hi)
    print(f"max Z {lo:>3}-{hi} dBZ: {m.sum():3d} gauges, radar/gauge {g.nocap[m].sum() / g.PRCP[m].sum():.2f} uncapped, "
          f"{g.cap[m].sum() / g.PRCP[m].sum():.2f} capped")
print("gauges reporting 0 mm under 55 dBZ or more:", g[(g.PRCP == 0) & (g.zmax >= 55)][["NAME", "zmax"]].values.tolist())
max Z -30-55 dBZ:  94 gauges, radar/gauge 0.52 uncapped, 0.51 capped
max Z  55-60 dBZ:  30 gauges, radar/gauge 1.96 uncapped, 1.41 capped
max Z  60-80 dBZ:  31 gauges, radar/gauge 4.02 uncapped, 1.40 capped
gauges reporting 0 mm under 55 dBZ or more: [['DENVER MUSEUM, CO US', 61.5]]

The overall ratio of 0.93 in the quick answer is two errors cancelling. Under the hail the capped radar is still 40% high; away from it, at the 94 gauges that never saw 55 dBZ, the radar has only half the gauge total. Part of that is the window: a CoCoRaHS day runs from 7 am to 7 am, and this page accumulates only the four hours of the storm. Part may be the Z–R relationship. The page does not separate the two, which is a reminder to compare over matched windows before tuning anything.

Example 2: a dual-pol hail flag instead of a blanket cap

hail = (z >= 50) & (zdr < 1.0)
print(f"{hail.sum()} gates flagged at 21:15 UTC; of the {(z >= 55).sum()} gates at 55 dBZ or more, "
      f"{(hail & (z >= 55)).sum() / (z >= 55).sum():.0%} are flagged, and {(hail & (z < 53)).sum()} flagged gates are below the cap")
3290 gates flagged at 21:15 UTC; of the 2457 gates at 55 dBZ or more, 81% are flagged, and 818 flagged gates are below the cap

The flag and the cap disagree on a lot of gates. A fifth of the gates at 55 dBZ or more escape the flag because their ZDR is above 1 dB, which is large drops or melting hail coated in water. And 818 flagged gates sit below the cap, where hail mixed with rain is already being counted as rain. At the margins, where hail and very large drops mix, no single rule is clean, which is why operational classifiers weigh several fields.

Why a few hailstones dominate reflectivity

A Z–R relationship assumes the targets are raindrops with a typical size distribution. Reflectivity grows with the sixth power of diameter, so a few large targets dominate it. A hailstone of 2–5 cm is a very large target. Its reflectivity is no guide to how much water reaches the ground, and that water arrives as ice that bounces out of gauges or melts slowly. The Denver storm was the costliest hailstorm in Colorado's history. On the radar it was a 70+ dBZ core over Lakewood, Wheat Ridge and Arvada. In the gauges it was an ordinary 10–30 mm.

Why the melting layer reflects more strongly than the snow above or the rain below it. snow to rain through the bright banddry snowlarge, ice: weak echowet snowlarge, water-coatedbright bandZ ↑, ZDR ↑, ρhv ↓rainsmaller, falls fasterIn Beryl: 4.5–5 km; 0 °C at 4,653 m over Lake Charles spatialworkflow.io
Why the melting layer is bright.

The bright band is subtler. Above the 0 °C level, snow is a weak reflector because ice scatters about a fifth as strongly as water. As it falls through the melting layer, each flake gains a water coating while it is still large, and reflects like a giant raindrop. Then it collapses into a smaller drop and speeds up, and reflectivity drops again. A radar beam crossing that layer sees the peak. Close to the radar the lowest tilt is below the band; far away the beam rises through it, and a fixed Z–R relationship turns the bright band into a ring of exaggerated rain.

Gauge undercatch, gauge days and winter bright bands

The two causes of radar rainfall that is too high, how to recognise each, and the fix. too much rain: which cause, which fixSymptomFixcompact core above 55 dBZ, ZDR ≈ 0cap at 53 dBZ or switch to R(KDP)ring of excess rain at one rangeVPR correction or a closer radarρhv dip at one heightthat height is the melting layergauge shows little under hailcheck for undercatch beforeblaming radarDenver hail (KFTG, 2017) and Beryl’s melting layer (KHGX, 2024) spatialworkflow.io
Which cause, which fix.
  • Gauges undercatch hail: stones bounce out, and some observers do not melt and measure them. One gauge under 61.5 dBZ reported 0.0 mm.
  • The gauge day is longer than the radar window: CoCoRaHS totals run 7 am to 7 am local time; this page's radar covers 19–23 UTC, the storm itself.
  • Winter bright bands are lower and stronger, often at 1–2 km, and dominate cool-season radar errors.
  • KDP is immune to hail to first order, which is why dual-pol algorithms use R(KDP) in hail cores.
  • The 53 dBZ cap is a site parameter; some radars use a different value.
  • A sounding 190 km away gives the freezing level only approximately; the radar's own ρhv dip is local.

FAQ

Why is radar rainfall too high in hailstorms?

Hail reflects far more strongly than the rain it contains. A Z–R relationship turns 70 dBZ into hundreds of millimetres per hour. In the Denver 2017 storm the uncapped radar total was 218 mm at a gauge that caught 9 mm.

What is the 53 dBZ hail cap?

NEXRAD rainfall processing limits reflectivity to 53 dBZ before converting it to rain, so hail cores cannot produce absurd rates.

What is the radar bright band?

The layer where snow melts into rain. Wet, large melting flakes reflect strongly, raising reflectivity and ZDR and lowering ρhv. In Beryl it was at 4.5–5 km.

How do I correct for the bright band?

Use the lowest tilt that is below it, apply a vertical-profile-of-reflectivity correction, or take rainfall from a closer radar.