Radar Rainfall vs Rain Gauges Explained: Why They Disagree

On this page

Problem statement

Put a radar rainfall estimate next to the rain gauges under it and they disagree. Sometimes by a few percent, sometimes by a factor of two at one gauge and the opposite at its neighbour. It is tempting to call one of them truth and the other error. Neither is truth: they measure different things, at different places, over different times. Most of the disagreement can be traced to those differences before anything is wrong with either instrument.

This page takes the MRMS radar-only estimate for Hurricane Beryl's rain around Houston in July 2024 and the 80-odd volunteer and official gauges under it. It separates the disagreement into its parts: timing, distance from the radar, the scale mismatch between a gauge and a grid cell, and the gauges' own scatter.

Quick answer

Compare radar and gauges over the same window, at more than one scale, and look at how the ratio changes with distance from the radar:

for label, g in (("one day (CoCoRaHS dated 9 July)", DAY), ("storm total (7-10 July)", STORM)):
    print(f"{label}: n = {len(g)}, r = {np.corrcoef(g.gauge, g.radar)[0, 1]:.2f}, "
          f"median radar/gauge {(g.radar / g.gauge).median():.2f}")
    for lo, hi in ((0, 40), (40, 80), (80, 150)):
        m = g.km.between(lo, hi, inclusive="left")
        print(f"   {lo:3d}-{hi:3d} km from KHGX: {m.sum():2d} gauges, radar/gauge {g.radar[m].sum() / g.gauge[m].sum():.2f}")
one day (CoCoRaHS dated 9 July): n = 83, r = 0.75, median radar/gauge 0.95
     0- 40 km from KHGX: 19 gauges, radar/gauge 1.38
    40- 80 km from KHGX: 24 gauges, radar/gauge 0.98
    80-150 km from KHGX: 40 gauges, radar/gauge 0.80
storm total (7-10 July): n = 75, r = 0.74, median radar/gauge 1.08
     0- 40 km from KHGX: 19 gauges, radar/gauge 1.16
    40- 80 km from KHGX: 25 gauges, radar/gauge 1.13
    80-150 km from KHGX: 31 gauges, radar/gauge 1.05
Two scatter plots of MRMS radar-only rainfall against gauge rainfall, coloured by distance from the KHGX radar. For one day, 83 CoCoRaHS gauges scatter widely with r of 0.75, including a cluster of gauges near the radar reading under 25 mm where the radar shows 30 to 55 mm. For the 7 to 10 July storm total, 75 gauges lie closer to the one-to-one line with r of 0.74. Two scatter plots of MRMS radar-only rainfall against gauge rainfall, coloured by distance from the KHGX radar. For one day, 83 CoCoRaHS gauges scatter widely with r of 0.75, including a cluster of gauges near the radar reading under 25 mm where the radar shows 30 to 55 mm. For the 7 to 10 July storm total, 75 gauges lie closer to the one-to-one line with r of 0.74.
Radar against gauges for a single day and for the whole storm. Data: NOAA MRMS radar-only QPE; NOAA GHCN-Daily.

Different quantities, aligned windows, gauges that disagree

1. They measure different things

A gauge catches the rain falling into an opening about 20 cm across at the ground. A radar estimate is a reflectivity measured in a beam a kilometre or more deep. That beam sits hundreds of metres to kilometres up and is converted to rain by an assumed drop-size relationship. It is then mapped to a 1 km cell, about 30 million times the gauge's catchment area. Rain varies inside that cell:

spread = []
for la, lo in zip(STORM.LATITUDE, STORM.LONGITUDE):
    i = int(np.argmin(np.abs(RO_STORM.latitude.values - la)))
    j = int(np.argmin(np.abs(RO_STORM.longitude.values - lo % 360)))
    w = RO_STORM.values[i - 1:i + 2, j - 1:j + 2]
    spread.append(np.nanmax(w) - np.nanmin(w))
print(f"3 x 3 km around each gauge, radar storm totals vary by a median {np.median(spread):.1f} mm "
      f"({np.median(np.array(spread) / STORM.radar):.0%} of the cell value)")
3 x 3 km around each gauge, radar storm totals vary by a median 23.9 mm (15% of the cell value)

2. Line up the time windows

Most CoCoRaHS observers read their gauge at about 7 am and date the reading that morning, but not all. The radar estimate here is a 24-hour accumulation ending 12 UTC (7 am in Houston), so it matches only those who read on time:

low = DAY[DAY.gauge < 0.5 * DAY.radar]
print(f"{len(low)} of {len(DAY)} gauges caught less than half the radar estimate on 9 July; "
      f"{(low.km < 40).sum()} of them within 40 km of KHGX")
same = STORM[STORM.STATION.isin(low.STATION)]
print(f"{len(same)} of them reported every day of the storm; their storm totals: median radar/gauge "
      f"{(same.radar / same.gauge).median():.2f}")
15 of 83 gauges caught less than half the radar estimate on 9 July; 9 of them within 40 km of KHGX
9 of them reported every day of the storm; their storm totals: median radar/gauge 1.07

Of those 15, the 9 that reported every day of the storm agree with the radar once the whole storm is summed (median ratio 1.07). They had logged part of the rain on 8 July. The near-radar bias in the quick answer shrinks from 1.38 to 1.16 for the same reason.

Typical radar/gauge mismatch factor as the comparison is made fairer. how much is timing?one day, all CoCoRaHS1.37Γ—one day, misdates out1.29Γ—storm total, all networks1.17Γ—00.7Γ—1.4Γ—exp(median |log ratio|), MRMS radar-only against gauges spatialworkflow.io
How much of the disagreement was timing.

3. Gauges disagree with each other too

If two gauges a couple of kilometres apart differ, a radar cell cannot match both. Compare close pairs:

xy = np.c_[Transformer.from_crs(4326, 32615, always_xy=True).transform(STORM.LONGITUDE.values, STORM.LATITUDE.values)]
pairs = np.array(sorted(cKDTree(xy).query_pairs(3000)))
g_diff = np.abs(STORM.gauge.values[pairs[:, 0]] - STORM.gauge.values[pairs[:, 1]])
r_diff = np.abs(STORM.radar.values[pairs[:, 0]] - STORM.radar.values[pairs[:, 1]])
mean = STORM.gauge.values[pairs].mean(axis=1)
print(f"{len(pairs)} gauge pairs within 3 km: they differ by a median {np.median(g_diff):.1f} mm "
      f"({np.median(g_diff / mean):.0%}); the radar at the same pairs by {np.median(r_diff):.1f} mm")
11 gauge pairs within 3 km: they differ by a median 14.0 mm (9%); the radar at the same pairs by 8.7 mm

Neighbouring gauges disagree with each other more than the radar field varies across the same distance. Some of that is real small-scale variation; some is gauge error β€” siting, wind, and a volunteer's reading.

4. Look at distance from the radar

The remaining radar-only bias falls with distance from KHGX, from 1.16 within 40 km to 1.05 beyond 80 km. Close to the radar the beam is low enough to catch clutter. Farther out it is higher and wider, and on the single day the ratio fell to 0.80 beyond 80 km. Both effects depend on range, so a radar-gauge ratio should always be examined against it.

5. Do not validate against the gauges you calibrated with

MRMS's multi-sensor product is adjusted to gauges every hour. Comparing it with the same gauges measures the adjustment, not the radar:

for name, field in (("radar-only", "radar"), ("multi-sensor (gauge-adjusted)", "ms")):
    print(f"{name:30s}: r = {np.corrcoef(STORM.gauge, STORM[field])[0, 1]:.2f}, median ratio {(STORM[field] / STORM.gauge).median():.2f}")
radar-only                    : r = 0.74, median ratio 1.08
multi-sensor (gauge-adjusted) : r = 0.76, median ratio 0.96
MRMS radar-only over gauge totals by distance from the KHGX radar, for one day and for the storm. radar/gauge by distance from KHGX0–40 km40–80 km80–150 kmone day!1.380.98!0.80storm total1.161.131.0519, 24–25 and 31–40 gauges per band spatialworkflow.io
The radar-only ratio against range, before and after summing the storm.

How much each step explains

The data used on this page

MRMS RadarOnly and MultiSensor Pass 2 QPE from the NOAA MRMS archive on AWS: 24 h ending 12 UTC 9 July for the single day, and 24 h ending 12 UTC 7 July plus 72 h ending 12 UTC 10 July for the storm; GHCN-Daily precipitation for 7–10 July 2024 from the NCEI daily-summaries service. KHGX is at 29.4719Β° N, 95.0787Β° W.

import gzip
import shutil
from pathlib import Path

import numpy as np
import pandas as pd
import requests
import xarray as xr
from pyproj import Geod, Transformer
from scipy.spatial import cKDTree

Path("data").mkdir(exist_ok=True)


def fetch(url, name):
    if not Path("data", name).exists():
        Path("data", name).write_bytes(requests.get(url, timeout=600).content)
    return Path("data", name)


def mrms(product, stamp):
    name = f"MRMS_{product}_00.00_{stamp}.grib2"
    gz = fetch(f"https://noaa-mrms-pds.s3.amazonaws.com/CONUS/{product}_00.00/{stamp[:8]}/{name}.gz", name + ".gz")
    if not Path("data", name).exists():
        with gzip.open(gz) as src, open(Path("data", name), "wb") as dst:
            shutil.copyfileobj(src, dst)
    q = xr.open_dataset(Path("data", name), engine="cfgrib", decode_timedelta=True, backend_kwargs={"indexpath": ""})["unknown"]
    return q.where(q >= 0)


inv = pd.read_fwf(fetch("https://www.ncei.noaa.gov/pub/data/ghcn/daily/ghcnd-inventory.txt", "ghcnd-inventory.txt"),
                  widths=[11, 9, 10, 5, 5, 5], names=["id", "lat", "lon", "el", "first", "last"])
ids = inv[(inv.el == "PRCP") & (inv["last"] >= 2024) & inv.lat.between(28.8, 30.4) & inv.lon.between(-96.3, -94.6)].id.unique()
if not Path("data/gauges.csv").exists():
    parts = []
    for i in range(0, len(ids), 90):
        text = requests.get("https://www.ncei.noaa.gov/access/services/data/v1", timeout=300, params={
            "dataset": "daily-summaries", "dataTypes": "PRCP", "stations": ",".join(ids[i:i + 90]),
            "startDate": "2024-07-07", "endDate": "2024-07-10", "format": "csv", "includeStationLocation": 1,
            "units": "metric"}).text
        parts.append(text if not parts else text.split("\n", 1)[1])
    Path("data/gauges.csv").write_text("".join(parts))
raw = pd.read_csv("data/gauges.csv")

RO_DAY = mrms("RadarOnly_QPE_24H", "20240709-120000")
RO_STORM = mrms("RadarOnly_QPE_24H", "20240707-120000") + mrms("RadarOnly_QPE_72H", "20240710-120000")
MS_STORM = mrms("MultiSensor_QPE_24H_Pass2", "20240707-120000") + mrms("MultiSensor_QPE_72H_Pass2", "20240710-120000")


def sample(g, field):
    return [float(field.sel(latitude=la, longitude=lo % 360, method="nearest")) for la, lo in zip(g.LATITUDE, g.LONGITUDE)]


def km_from_radar(g):
    return Geod(ellps="WGS84").inv(np.full(len(g), -95.0787), np.full(len(g), 29.4719), g.LONGITUDE.values, g.LATITUDE.values)[2] / 1000


DAY = raw[(raw.DATE == "2024-07-09") & raw.STATION.str.startswith("US1")].dropna(subset=["PRCP"]).rename(columns={"PRCP": "gauge"})
DAY = DAY[DAY.gauge > 0].reset_index(drop=True)
DAY["radar"], DAY["km"] = sample(DAY, RO_DAY), km_from_radar(DAY)
STORM = raw.pivot_table(index=["STATION", "LATITUDE", "LONGITUDE"], columns="DATE", values="PRCP").dropna()
STORM = STORM.sum(axis=1).rename("gauge").reset_index()
STORM["radar"], STORM["ms"], STORM["km"] = sample(STORM, RO_STORM), sample(STORM, MS_STORM), km_from_radar(STORM)
print(f"{len(DAY)} CoCoRaHS gauges with rain on 9 July; {len(STORM)} gauges complete for 7-10 July")
83 CoCoRaHS gauges with rain on 9 July; 75 gauges complete for 7-10 July

Example 1: how much each step explains

steps = {"one day, all CoCoRaHS": DAY,
         "one day, gauges >= half the radar": DAY[DAY.gauge >= 0.5 * DAY.radar],
         "storm total, all networks": STORM}
for name, g in steps.items():
    err = np.abs(np.log(g.radar / g.gauge))
    print(f"{name:36s}: n = {len(g):3d}, median |log ratio| {np.median(err):.2f} (= typical factor {np.exp(np.median(err)):.2f})")
one day, all CoCoRaHS               : n =  83, median |log ratio| 0.31 (= typical factor 1.37)
one day, gauges >= half the radar   : n =  68, median |log ratio| 0.25 (= typical factor 1.29)
storm total, all networks           : n =  75, median |log ratio| 0.15 (= typical factor 1.17)
What a rain gauge and a radar rainfall estimate each measure. not the same quantitygaugeβœ“a 20 cm opening at the groundβœ“accurate amount at one pointβœ“errors: wind, timing, observerβœ“neighbours within 3 km differ 9%radar cellβœ“1 kmΒ², from a beam aloftβœ“pattern everywhere in rangeβœ“errors: Z–R, beam height, clutterβœ“varies 15% across 3 Γ— 3 kmBeryl’s storm totals around Houston, July 2024 spatialworkflow.io
What each instrument measures.

Example 2: the worst disagreements, and what they are

STORM["ratio"] = STORM.radar / STORM.gauge
print(STORM.sort_values("ratio")[["STATION", "gauge", "radar", "km"]].iloc[[0, 1, -2, -1]].round(1).to_string(index=False))
    STATION  gauge  radar    km
US1TXGM0011  124.5   42.4 127.2
US1TXMTG004  170.2   95.7 125.2
USC00410569   72.4  190.0 102.8
USC00412786   63.8  202.7 119.1

Each outlier is worth a look at the raw record before it is blamed on either instrument: a gauge that missed a reading, a cell with clutter, a site in a wind-exposed spot.

Radar and gauges are complementary

Radar and gauges are complementary. A gauge gives an accurate amount at a point. Its errors are undercatch in wind, splash, blockage and observer timing, and it says nothing about the space between gauges. A radar gives the spatial pattern everywhere within range. Its errors come from the drop-size assumption, the beam's height, clutter and blockage, and it says little reliable about the absolute amount at any point. The best rainfall products, such as MRMS multi-sensor, use radar for the pattern and gauges for the amount.

Separating a radar-gauge disagreement into its parts before tuning anything. take a disagreement apartmatch windowsor sum the stormby rangeclutter near,height fargauge scatterthe floor: ~9%independentgaugesnot the adjustedonesthen: Z–R,biaswhat is leftIn Beryl, summing the storm took the typical mismatch from 1.37Γ— to 1.17Γ— spatialworkflow.io
Separate the parts before tuning anything.

A disagreement between the two is therefore a sum of several separate things: different time windows, different sampling volumes, range effects, and each instrument's own errors. In this storm, matching the windows removed most of the gross mismatches. The single-gauge scatter between close neighbours set a floor that no radar could get under. And the range dependence of the radar-only product remained visible even in storm totals. Separate those before tuning anything.

Self-validation, correlation and unstable ratios

Common errors in radar-gauge comparisons. comparison trapsTrapFixradar and gauge days misalignedmatch the window or sum the stormvalidating against adjustinggaugeshold out gauges, or radar-onlyone ratio for the whole domainlook at it against ranger as the only scorereport bias too: r hid it hereMRMS and GHCN-Daily, Houston, 7–10 July 2024 spatialworkflow.io
Radar-gauge comparison traps.
  • A gauge-adjusted product cannot be validated against its own gauges; hold some out, or use an independent network.
  • Correlation hides bias: r barely changed between the day and the storm, but the near-radar bias did.
  • Ratios are unstable at small totals; filter out light-rain gauges before computing them.
  • Point-to-cell comparisons favour smooth fields; for convective storms, compare areal means.
  • CoCoRaHS observation times are reported but often missing in GHCN; storm totals avoid needing them.
  • Distance from the radar is a proxy; beam height and blockage at each gauge are the real variables.

FAQ

Why does radar rainfall differ from rain gauges?

They sample different volumes (a 20 cm opening against a kilometre-wide beam up in the air), often over different time windows, and each has its own errors. Close gauges also disagree with each other.

Which is more accurate, radar or gauge rainfall?

A gauge is more accurate at its point; radar is better at the pattern between gauges. Multi-sensor products combine them.

How should I compare radar and gauges?

Match the time window exactly, compare totals rather than single days if observation times are uncertain, look at ratios against distance from the radar, and use gauges that were not used to adjust the radar.

How much do nearby rain gauges differ?

In Beryl's storm totals around Houston, gauges within 3 km of each other differed by a median of about 9%.