How to Analyse a Rain Gauge Network in Python: Gaps, Density and Areal Rainfall

On this page

Problem statement

A rain gauge measures one point, about 20 cm across. Hydrology, flood response and radar validation need rainfall over areas, so gauges are used as a network. Turning a network into an areal estimate takes four decisions: which gauges actually reported, over which time window, how far each gauge's measurement can be stretched, and how to weight the gauges into an average. Get any one wrong and the "county rainfall" is a number about the network rather than the rain.

This page takes every daily precipitation gauge in GHCN-Daily around Houston for the storm of 7–10 July 2024, which includes Hurricane Beryl. It measures what the network can and cannot say about Harris County, and checks the result against the MRMS radar-gauge analysis.

Quick answer

Keep gauges that reported every day of the storm, sum over the whole storm so different observation times cannot split it, and weight by Thiessen polygons:

storm = daily.dropna().sum(axis=1).rename("mm").reset_index()
g = gpd.GeoDataFrame(storm, geometry=gpd.points_from_xy(storm.LONGITUDE, storm.LATITUDE), crs=4326).to_crs(32615)
cells = gpd.GeoDataFrame(geometry=list(voronoi_diagram(MultiPoint(list(g.geometry)), envelope=HARRIS.buffer(50_000)).geoms), crs=32615)
cells = gpd.clip(gpd.sjoin(cells, g[["STATION", "mm", "geometry"]], predicate="contains"), HARRIS)
cells["km2"] = cells.area / 1e6
inside = g[g.within(HARRIS)]
print(f"{len(daily)} gauges, {len(storm)} complete; {len(inside)} inside Harris County ({HARRIS.area / 1e6:,.0f} km2)")
print(f"county mean: arithmetic {inside.mm.mean():.1f} mm, Thiessen {(cells.mm * cells.km2).sum() / cells.km2.sum():.1f} mm, "
      f"MRMS {float(MRMS_IN_HARRIS.mean()):.1f} mm")
181 gauges, 75 complete; 20 inside Harris County (4,589 km2)
county mean: arithmetic 211.7 mm, Thiessen 198.8 mm, MRMS 188.9 mm
Two maps of Harris County, Texas, for the storm of 7 to 10 July 2024. Left, Thiessen polygons around 75 complete gauges, coloured from 125 to over 250 mm, with small polygons clustered in the centre and large ones at the edges. Right, the MRMS field for the same 96 hours with a band above 250 mm across the centre and south and under 125 mm in the north-east. Two maps of Harris County, Texas, for the storm of 7 to 10 July 2024. Left, Thiessen polygons around 75 complete gauges, coloured from 125 to over 250 mm, with small polygons clustered in the centre and large ones at the edges. Right, the MRMS field for the same 96 hours with a band above 250 mm across the centre and south and under 125 mm in the north-east.
Gauge storm totals as Thiessen polygons beside MRMS for the same 96 hours. Dots are CoCoRaHS, squares COOP, triangles ASOS. Data: NOAA GHCN-Daily; NOAA MRMS; US Census Bureau.

Who reported, storm sums, how far the network reaches

1. Find out who actually reported

GHCN-Daily mixes three networks around Houston: CoCoRaHS volunteers (station IDs beginning US1), the NWS Cooperative Observer Program (USC), and airport ASOS stations (USW). Count, per network, how many reported every day:

net = daily.index.get_level_values("STATION").str[:3].map({"US1": "CoCoRaHS", "USC": "COOP", "USW": "ASOS"})
summary = pd.DataFrame({"network": net, "days": daily.notna().sum(axis=1).values})
print(summary.groupby("network").days.agg(gauges="size", all_4_days=lambda d: (d == 4).sum(),
                                          missing_any=lambda d: (d < 4).sum()).to_string())
          gauges  all_4_days  missing_any
network                                  
ASOS          10           6            4
COOP          13          10            3
CoCoRaHS     158          59           99

Most gauges skipped at least one day around the storm, and nearly all of the gaps are among volunteers. A sum over the days a gauge did report would understate the storm wherever a report is missing, so incomplete gauges are left out here. The cost is density.

2. Sum over the storm, not over a day

The networks close their day at different times. ASOS stations use midnight-to-midnight local standard time; most CoCoRaHS and many COOP observers read the gauge at 7 am and date the reading that morning. The same rain lands on different dates:

iah = daily.xs("USW00012960", level="STATION").iloc[0]
coco = daily.loc[daily.index.get_level_values("STATION").str.startswith("US1")].dropna()
print("IAH (ASOS):", iah.to_dict())
print("CoCoRaHS median by date:", coco.median().round(1).to_dict())
IAH (ASOS): {'2024-07-07': 8.1, '2024-07-08': 119.9, '2024-07-09': 0.0, '2024-07-10': 0.0}
CoCoRaHS median by date: {'2024-07-07': 1.0, '2024-07-08': 75.4, '2024-07-09': 64.3, '2024-07-10': 0.0}

At the airport Beryl's rain is all on 8 July. The volunteers' median splits it, 75 mm on 8 July and 64 mm on 9 July: Beryl's rain began overnight, so a 7 am reading on the 8th caught the first part and the next morning's reading the rest. A four-day storm total does not care where each network draws its line, and its MRMS counterpart — 24 hours ending 12 UTC 7 July plus 72 hours ending 12 UTC 10 July — covers the same 96 hours as the 7 am observers.

Where each gauge network puts Beryl’s rain, because their days end at different times. same storm, different dates7 July8 July9 JulyIAH (ASOS, midnight)8.1119.90.0CoCoRaHS median (≈ 7am)1.075.464.3mm per reported day; MRMS matched as 12 UTC 6 July to 12 UTC 10 July spatialworkflow.io
The same storm lands on different dates in different networks.

3. Measure how far the network has to stretch

Within a county, how far is any point from its nearest complete gauge?

xmin, ymin, xmax, ymax = HARRIS.bounds
xx, yy = np.meshgrid(np.arange(xmin, xmax, 500), np.arange(ymin, ymax, 500))
grid = gpd.GeoSeries(gpd.points_from_xy(xx.ravel(), yy.ravel()), crs=32615)
grid = grid[grid.within(HARRIS)]
dist, _ = cKDTree(np.c_[g.geometry.x, g.geometry.y]).query(np.c_[grid.x, grid.y])
print(f"{len(inside)} complete gauges inside: one per {HARRIS.area / 1e6 / len(inside):.0f} km2")
print(f"distance to the nearest gauge: median {np.median(dist) / 1000:.1f} km, 90th percentile "
      f"{np.percentile(dist, 90) / 1000:.1f} km, max {dist.max() / 1000:.1f} km; "
      f"{(dist > 5000).mean():.0%} of the county is more than 5 km from one")
20 complete gauges inside: one per 229 km2
distance to the nearest gauge: median 8.0 km, 90th percentile 15.3 km, max 23.9 km; 73% of the county is more than 5 km from one

Convective rain cells are a few kilometres across. Over most of the county, the nearest gauge is farther away than that.

4. Weight by area: arithmetic mean against Thiessen polygons

An arithmetic mean gives each gauge the same weight, so a cluster of volunteers in one suburb counts many times. Thiessen polygons give each gauge the area closer to it than to any other gauge:

print(f"{len(cells)} polygons cover the county; the largest {cells.km2.max():.0f} km2, the smallest {cells.km2.min():.2f} km2")
print(f"the 5 largest polygons carry {cells.km2.nlargest(5).sum() / cells.km2.sum():.0%} of the weight")
print(cells.nlargest(5, "km2")[["STATION", "mm", "km2"]].round(1).to_string(index=False))
35 polygons cover the county; the largest 524 km2, the smallest 0.08 km2
the 5 largest polygons carry 45% of the weight
    STATION    mm   km2
US1TXHRR251 208.8 524.2
USC00410586 222.5 450.6
USW00012960 128.0 395.8
US1TXHRR093 235.4 377.5
US1TXHRR306 151.4 324.4

The weights are very uneven: a handful of isolated gauges carry much of the county. One broken gauge there would move the county total far more than the same fault in a dense suburb.

Harris County mean storm rainfall, 7–10 July 2024, by method. one county, five meansgauges, arithmetic211.7 mmgauges, Thiessen198.8 mmMRMS, every cell188.9 mmMRMS at gauges, mean211.9 mmMRMS at gauges, Thiessen188.5 mm0100 mm200 mmThe arithmetic bias comes from where the gauges are, not what they measure spatialworkflow.io
Five ways to get one county's rainfall. The arithmetic means are high because the gauges cluster in the wettest parts.

Does the network see what MRMS sees

The data used on this page

GHCN-Daily precipitation, 7–10 July 2024, for every station in the GHCN-Daily inventory between 28.8–30.4°N and 94.6–96.3°W, from the NCEI daily-summaries service; MRMS MultiSensor QPE Pass 2, 24 h ending 12 UTC 7 July and 72 h ending 12 UTC 10 July 2024, from the NOAA MRMS archive on AWS; the US Census cartographic boundary file for counties, 2023.

import gzip
import shutil
from pathlib import Path

import geopandas as gpd
import numpy as np
import pandas as pd
import requests
import xarray as xr
from scipy.spatial import cKDTree
from shapely.geometry import MultiPoint
from shapely.ops import voronoi_diagram

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)


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")
daily = raw.pivot_table(index=["STATION", "LATITUDE", "LONGITUDE"], columns="DATE", values="PRCP")

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)

MRMS_96H = mrms("MultiSensor_QPE_24H_Pass2", "20240707-120000") + mrms("MultiSensor_QPE_72H_Pass2", "20240710-120000")
counties = gpd.read_file(fetch("https://www2.census.gov/geo/tiger/GENZ2023/shp/cb_2023_us_county_500k.zip", "cb_2023_us_county_500k.zip"))
harris = counties[(counties.STATEFP == "48") & (counties.NAME == "Harris")]
HARRIS = harris.to_crs(32615).geometry.iloc[0]
box = MRMS_96H.sel(latitude=slice(30.2, 29.4), longitude=slice(264.0, 265.1))
lon, lat = np.meshgrid(box.longitude.values - 360, box.latitude.values)
in_county = gpd.GeoSeries(gpd.points_from_xy(lon.ravel(), lat.ravel()), crs=4326).within(harris.geometry.iloc[0]).values
MRMS_IN_HARRIS = pd.Series(box.values.ravel()[in_county]).dropna()
print(f"{len(ids)} stations listed, {len(daily)} with data; MRMS: {len(MRMS_IN_HARRIS):,} cells in Harris County")
241 stations listed, 181 with data; MRMS: 4,283 cells in Harris County

Example 1: does the network see what MRMS sees?

Sample the MRMS field at the gauge locations and average it the same two ways. If that reproduces the full-field county mean, the network's layout is good enough for this storm, and any difference between gauges and MRMS is about measurement, not sampling:

gl = g.to_crs(4326)
g["mrms"] = MRMS_96H.sel(latitude=xr.DataArray(gl.geometry.y.values, dims="p"),
                         longitude=xr.DataArray(gl.geometry.x.values % 360, dims="p"), method="nearest").values
cells = cells.merge(g[["STATION", "mrms"]], on="STATION")
print(f"MRMS county mean from every cell: {MRMS_IN_HARRIS.mean():.1f} mm")
print(f"MRMS sampled at the gauges: arithmetic {g.loc[inside.index, 'mrms'].mean():.1f} mm, "
      f"Thiessen {(cells.mrms * cells.km2).sum() / cells.km2.sum():.1f} mm")
print(f"gauge against MRMS at the same points: r = {np.corrcoef(g.mm, g.mrms)[0, 1]:.2f}, "
      f"median gauge/MRMS {(g.mm / g.mrms).median():.2f}")
MRMS county mean from every cell: 188.9 mm
MRMS sampled at the gauges: arithmetic 211.9 mm, Thiessen 188.5 mm
gauge against MRMS at the same points: r = 0.76, median gauge/MRMS 1.04

The Thiessen weighting is what makes the network work. Sampled at the gauges, MRMS's arithmetic mean is 23 mm too high — the same bias as the gauges' own arithmetic mean in the quick answer — because volunteers cluster where it rained hardest. Weighted by area, the same samples land within half a millimetre of the full field. What is left between the gauges' 198.8 mm and MRMS's 188.9 mm is measurement: gauges caught about 4% more than MRMS at the same points.

From every GHCN-Daily gauge listed around Houston to the complete gauges inside Harris County. how many gauges are left?listed in the box241with data, 7–10 July18199 of 158 volunteers missed a day÷ 2.4all four days reported75one per 229 km²÷ 3.8inside Harris County20GHCN-Daily, storm of 7–10 July 2024 spatialworkflow.io
How the network shrinks once completeness is required.

Example 2: how many gauges does a county mean need?

rng = np.random.default_rng(42)
field = MRMS_IN_HARRIS.values
for n in sorted({5, 10, len(inside), 50, 100}):
    errors = [field[rng.choice(len(field), n, replace=False)].mean() - field.mean() for _ in range(500)]
    print(f"{n:3d} randomly placed gauges: county-mean error sd {np.std(errors):4.1f} mm "
          f"({np.std(errors) / field.mean():.1%})")
  5 randomly placed gauges: county-mean error sd 20.0 mm (10.6%)
 10 randomly placed gauges: county-mean error sd 13.6 mm (7.2%)
 20 randomly placed gauges: county-mean error sd  9.9 mm (5.2%)
 50 randomly placed gauges: county-mean error sd  5.9 mm (3.1%)
100 randomly placed gauges: county-mean error sd  4.0 mm (2.1%)

The real network of 20 complete gauges, Thiessen-weighted, came within 0.4 mm of MRMS's own mean — closer than the 9.9 mm typical of 20 random gauges, partly by luck. Randomly placed gauges are an idealisation — real ones cluster where people live — but the scaling holds: halving the error takes four times the gauges.

A sample of a field that varies at every scale

A gauge network is a sample of a field that varies at every scale. Three things decide how well it represents an area. Completeness: a missing report is a hole, not a zero. Alignment: networks with different observation times split the same storm across different days, so totals must be built over windows that every network covers whole. Geometry: the spacing of the gauges against the size of the rain cells, and how the gauges are weighted.

What a daily gauge network can and cannot tell you about rainfall over an area. 20 complete gauges in 4,589 km²can✓county storm total, area-weighted✓absolute calibration for radar✓long records at fixed pointscannot✓cells smaller than 8 km spacing✓rain on the next street✓a total where reports are missingMedian distance to the nearest complete gauge: 8.0 km spatialworkflow.io
What 20 complete gauges in 4,589 square kilometres can and cannot say.

For a whole-county storm total, this network does well — but only when weighted by area. Sampled at the gauges, MRMS gives 188.5 mm by Thiessen polygons against 188.9 mm over every cell; the arithmetic mean of the same samples gives 211.9 mm, because the gauges cluster in the wetter parts of the county. What the network cannot do is resolve the field. Over much of Harris County the nearest complete gauge is several kilometres away, so a local downpour between gauges goes unseen. That is why rainfall products such as MRMS combine the gauges' absolute accuracy with radar's spatial detail. It is also why one gauge's total at one address says little about the next street.

Thiessen polygons, edges and multi-day reports

Common errors when turning a gauge network into areal rainfall. network errors and their fixesErrorFixsumming days a gauge did reportcomplete gauges only, or fill andflagcomparing ASOS and CoCoRaHS bydatesum over the whole stormarithmetic mean of clusteredgaugesThiessen or interpolationedge polygons from inside gaugesonlyinclude neighbours, then clipThe first three are measured on this page spatialworkflow.io
Network errors and their fixes.
  • Thiessen polygons ignore everything but distance; inverse-distance weighting or kriging smooth the field instead of stepping it.
  • Polygons at the edge come from gauges outside the area; include neighbours before clipping, as here.
  • Gauge undercatch grows with wind speed and is worst for unshielded gauges, so a hurricane's gauge totals lean low.
  • CoCoRaHS multi-day reports (after a weekend) arrive as one accumulated value; GHCN flags them.
  • Harris County Flood Control District runs a much denser real-time gauge network that is not in GHCN-Daily.
  • Coordinates of volunteer gauges are often rounded; snap carefully before matching to a radar grid.

FAQ

What is the Thiessen polygon method for rainfall?

Each gauge is assigned the area closer to it than to any other gauge, and the areal rainfall is the area-weighted mean of the gauge values.

Why do neighbouring rain gauges disagree on which day it rained?

Networks end their day at different times: ASOS at local midnight, most CoCoRaHS and COOP observers at about 7 am. Sum over the whole storm to compare them.

How dense should a rain gauge network be?

It depends on the use. For a county-mean storm total a few dozen gauges can be enough; to resolve convective cells you need spacing of a few kilometres, which only radar provides everywhere.

Should I include gauges with missing days?

Not in a sum: a missing day is not a dry day. Use only complete gauges, or fill gaps from a radar-gauge product and say so.