How to Build Rainfall IDF Curves in Python from a Gauge Record

On this page

Problem statement

Intensity-duration-frequency (IDF) curves answer the design question behind every culvert, storm drain and detention pond: how hard does it rain, for how long, how often? Building them from a gauge looks simple: annual maxima for each duration, a distribution fit, and read-off. But a real hourly record has flagged values, multi-hour accumulations, decades with gaps, and clock-hour totals that miss the true peak hour. Each one moves the curve, and some move it a long way.

This page builds IDF curves for Conroe, Texas, north of Houston, from the NCEI Hourly Precipitation Data record (1947–2026). It compares them with NOAA Atlas 14, the official US values, and places Hurricane Beryl (2024), Hurricane Harvey (2017) and the October 1994 flood on the curves.

Quick answer

Clean the hourly series, take annual maxima of sliding sums for each duration from years complete enough to trust, fit a GEV per duration, and compare with Atlas 14:

rows = []
for D in DURATIONS:
    am = annual_max(D)
    shape, loc, scale = genextreme.fit(am, -0.1, loc=np.median(am), scale=am.std() / 2)
    rows.append({"duration_h": D, "years": len(am), **{f"{T}-yr": genextreme.ppf(1 - 1 / T, shape, loc, scale) for T in (2, 10, 100)},
                 **{f"A14 {T}-yr": ATLAS14.loc[D, T] for T in (2, 10, 100)}})
IDF = pd.DataFrame(rows).set_index("duration_h")
print(IDF.round(0).astype(int).to_string())
            years  2-yr  10-yr  100-yr  A14 2-yr  A14 10-yr  A14 100-yr
duration_h                                                             
1              57    47     70      95        50         78         118
2              57    62     97     147        62        103         172
3              59    69    113     197        69        120         212
6              58    83    143     284        81        148         282
12             56    98    173     322        93        176         345
24             55   108    195     364       106        204         409
Log-log chart of rainfall intensity against duration from 1 to 24 hours. Solid curves for the 2, 10 and 100-year return periods from the Conroe gauge fall from about 47, 70 and 95 mm per hour at 1 hour to 4.5, 8 and 15 mm per hour at 24 hours; dashed Atlas 14 curves lie on top at 2 and 10 years and slightly above at 100 years. Markers show Beryl near the 2-year curve, Harvey between 10 and 100 years at long durations, and the October 1994 flood on or above the 100-year curve. Log-log chart of rainfall intensity against duration from 1 to 24 hours. Solid curves for the 2, 10 and 100-year return periods from the Conroe gauge fall from about 47, 70 and 95 mm per hour at 1 hour to 4.5, 8 and 15 mm per hour at 24 hours; dashed Atlas 14 curves lie on top at 2 and 10 years and slightly above at 100 years. Markers show Beryl near the 2-year curve, Harvey between 10 and 100 years at long durations, and the October 1994 flood on or above the 100-year curve.
IDF curves from the Conroe gauge (solid) against NOAA Atlas 14 (dashed), with three storms. Data: NOAA NCEI Hourly Precipitation Data; NOAA Atlas 14 Volume 11.

Clean the record, trust the right years, sliding maxima, a fit

1. Clean the hourly record

HPD stores hundredths of an inch, one row per day with 24 hourly values, each with a measurement flag and a quality flag. Treat βˆ’9999 as missing; drop values that failed quality control; and drop multi-hour accumulations (flags a, ., A), whose total cannot be placed in a single hour:

qf_counts = pd.Series(QF.ravel()).value_counts()
mf_counts = pd.Series(MF.ravel()).value_counts()
print("quality flags:", qf_counts.drop("", errors="ignore").to_dict())
print("accumulation flags:", {k: int(mf_counts.get(k, 0)) for k in ("a", ".", "A")}, "| missing (-9999):", int((VAL < 0).sum()))
print(f"{len(rain):,} hours from {rain.index.min():%Y-%m-%d} to {rain.index.max():%Y-%m-%d}; {rain.notna().mean():.1%} usable")
quality flags: {'M': 3679, 'D': 1310, 'A': 169, 'R': 102, 'q': 47, 'G': 2}
accumulation flags: {'a': 165, '.': 1609, 'A': 169} | missing (-9999): 8310
694,032 hours from 1947-07-01 to 2026-09-01; 83.8% usable

2. Decide which years to trust

An annual maximum from a year with half its hours missing may simply have missed the storm. The usual rule keeps years with at least 90% of hours:

share = rain.notna().groupby(rain.index.year).mean()
print(f"{(share >= 0.9).sum()} of {len(share)} years are at least 90% complete")
print("2017 (Harvey):", f"{share[2017]:.1%}", "| 2024 (Beryl):", f"{share[2024]:.1%}")
39 of 80 years are at least 90% complete
2017 (Harvey): 79.8% | 2024 (Beryl): 79.5%

The 90% rule throws out both hurricane years, and most of the 2010s. But a missing hour can only make a year's true maximum larger, never smaller. So a year whose observed maximum already exceeds the median of the complete years carries real information and is kept. That is the rule annual_max applies:

for D in (1, 24):
    strict = annual_max(D, rule="strict")
    kept = annual_max(D)
    print(f"{D:2d} h: {len(strict)} years under the 90% rule, {len(kept)} with large incomplete years added; "
          f"added include 2017: {2017 in kept.index and 2017 not in strict.index}, 2024: {2024 in kept.index and 2024 not in strict.index}")
 1 h: 39 years under the 90% rule, 57 with large incomplete years added; added include 2017: True, 2024: True
24 h: 39 years under the 90% rule, 55 with large incomplete years added; added include 2017: True, 2024: True
Which years enter the annual maximum series at Conroe under two completeness rules, and what that does to the 100-year depth. which years count?90% rule+ large incompleteyearsAtlas 14years (24 h)3955Harvey 2017, Beryl2024droppedkept6 h, 100-yr241 mm284 mm282 mm24 h, 100-yr328 mm364 mm409 mm1 h, 100-yr101 mm95 mm118 mmNCEI HPD, Conroe TX, 1947–2026; GEV fits spatialworkflow.io
What the completeness rule does to the 100-year depth.

3. Take sliding, not calendar, maxima

A 24-hour maximum is the largest sum of any 24 consecutive hours, not the wettest calendar day; storms do not respect midnight:

r24 = rain.rolling(24, min_periods=24).sum()
day = rain.resample("D").sum(min_count=24)
top = annual_max(24).sort_values(ascending=False).head(5)
print(pd.DataFrame({"sliding 24 h": top, "wettest calendar day": [day[str(y)].max() for y in top.index],
                    "ending": [r24[str(y)].idxmax().strftime("%Y-%m-%d %H:00") for y in top.index]}).round(1).to_string())
      sliding 24 h  wettest calendar day            ending
1994         375.9                 142.2  1994-10-17 05:00
2017         310.6                 272.5  2017-08-27 14:00
2001         264.2                 259.1  2001-06-08 20:00
1961         253.5                 175.5  1961-09-12 04:00
1979         190.5                 119.4  1979-04-19 10:00

The October 1994 flood, not Harvey, holds this gauge's 24-hour record. The wettest calendar day is lower for every storm: by 2% in June 2001, by 12% for Harvey, and by much more in 1994 and 1961, whose storms ran across midnight. October 1994 also shows why completeness matters: only six hours of 17 October were recorded, and they held 233.7 mm, so that day has no complete calendar total at all.

4. Fit and read the curves

The quick answer fits a generalised extreme value (GEV) distribution to each duration's annual maxima. Convert depths to intensities to draw the classic IDF diagram:

intensity = IDF[[f"{T}-yr" for T in (2, 10, 100)]].div(IDF.index.to_series(), axis=0)
print(intensity.round(1).to_string())
print(f"100-yr / 2-yr depth ratio: 1 h {IDF.loc[1, '100-yr'] / IDF.loc[1, '2-yr']:.1f}, 24 h {IDF.loc[24, '100-yr'] / IDF.loc[24, '2-yr']:.1f}")
            2-yr  10-yr  100-yr
duration_h                     
1           47.5   69.7    94.8
2           31.1   48.6    73.7
3           23.0   37.8    65.8
6           13.8   23.8    47.3
12           8.1   14.4    26.8
24           4.5    8.1    15.2
100-yr / 2-yr depth ratio: 1 h 2.0, 24 h 3.4

The spread between frequent and rare storms widens with duration here: long, slow-moving tropical systems make the 24-hour tail heavy.

5. Check against Atlas 14

At 2 and 10 years, and at 6 hours for every return period, the gauge-only curves land within a few percent of Atlas 14. The 1-hour curve falls short by 6% at 2 years and 20% at 100 years, and the 24-hour 100-year value by 11%. There are two reasons. The record counts clock hours (00:00–01:00, 01:00–02:00), and the heaviest 60 minutes usually straddle two of them, so a clock-hour maximum is systematically below the true 60-minute maximum. Atlas 14 converts clock-hour maxima to true maxima. It also pools many stations in a region, so its estimates do not hang on one gauge's few extreme years.

How rare Beryl and Harvey were at Conroe

The data used on this page

NCEI Hourly Precipitation Data v2, station USC00411956 (Conroe, TX, a Fischer–Porter weighing gauge); NOAA Atlas 14 Volume 11 annual-maximum-series depths for the same coordinates, from the Precipitation Frequency Data Server.

import re
from pathlib import Path

import numpy as np
import pandas as pd
import requests
from scipy.stats import genextreme

Path("data").mkdir(exist_ok=True)
HPD = Path("data/USC00411956.csv")
if not HPD.exists():
    HPD.write_bytes(requests.get("https://www.ncei.noaa.gov/data/coop-hourly-precipitation/v2/access/USC00411956.csv", timeout=600).content)
raw = pd.read_csv(HPD, dtype=str)
VAL = raw[[f"HR{h:02d}Val" for h in range(24)]].astype(float).values
QF = np.char.strip(raw[[f"HR{h:02d}QF" for h in range(24)]].fillna("").values.astype(str))
MF = np.char.strip(raw[[f"HR{h:02d}MF" for h in range(24)]].fillna("").values.astype(str))
bad = (VAL < 0) | (QF != "") | np.isin(MF, ["a", ".", "A"])
stamps = pd.to_datetime(raw.DATE).values[:, None] + np.arange(24) * np.timedelta64(1, "h")
rain = pd.Series(np.where(bad, np.nan, VAL * 0.254).ravel(), index=pd.DatetimeIndex(stamps.ravel())).sort_index()
rain = rain[~rain.index.duplicated()].asfreq("h")                     # mm per clock hour, local standard time
DURATIONS = (1, 2, 3, 6, 12, 24)


def annual_max(D, rule="kept"):
    share = rain.notna().groupby(rain.index.year).mean()
    sums = rain.rolling(D, min_periods=D).sum()
    am = sums.groupby(sums.index.year).max()
    keep = share >= 0.9
    if rule == "kept":
        keep |= (share >= 0.5) & (am > am[share >= 0.9].median())
    return am[keep].dropna()


pfds = requests.get("https://hdsc.nws.noaa.gov/cgi-bin/new/cgi_readH5.py", timeout=120, params={
    "lat": 30.3302, "lon": -95.4831, "type": "pf", "data": "depth", "units": "metric", "series": "ams"}).text
q = np.array(eval(re.search(r"quantiles = (\[\[.*?\]\]);", pfds, re.S).group(1)), float)
ATLAS14 = pd.DataFrame(q[4:10][:, [0, 2, 5]], index=list(DURATIONS), columns=[2, 10, 100])    # rows 60 min .. 24 h
print(f"HPD: {len(raw):,} days; Atlas 14 AMS depths for {len(ATLAS14)} durations, mm")
HPD: 24,594 days; Atlas 14 AMS depths for 6 durations, mm

Example 1: how rare were Beryl and Harvey at Conroe?

def ari(depth, D):
    p = genextreme.sf(depth, *genextreme.fit(annual_max(D), -0.1, loc=np.median(annual_max(D)), scale=annual_max(D).std() / 2))
    return 1 / p

for name, (a, b) in {"Beryl 2024": ("2024-07-07", "2024-07-10"), "Harvey 2017": ("2017-08-25", "2017-08-31"),
                     "October 1994": ("1994-10-15", "1994-10-20")}.items():
    w = rain[a:b]
    peaks = {D: w.rolling(D, min_periods=D).sum().max() for D in (1, 6, 24)}
    print(f"{name:13s} " + "  ".join(f"{D:2d} h {v:5.1f} mm (~{ari(v, D):,.0f} yr)" for D, v in peaks.items())
          + f"  | {int(w.isna().sum())} hours missing")
Beryl 2024     1 h  38.4 mm (~1 yr)   6 h 109.5 mm (~4 yr)  24 h 122.2 mm (~3 yr)  | 13 hours missing
Harvey 2017    1 h  56.6 mm (~4 yr)   6 h 163.3 mm (~16 yr)  24 h 310.6 mm (~54 yr)  | 0 hours missing
October 1994   1 h  91.4 mm (~72 yr)   6 h 302.3 mm (~124 yr)  24 h 375.9 mm (~114 yr)  | 29 hours missing

At Conroe, Beryl was an ordinary storm of the kind expected every three or four years β€” its worst rain fell south, over Houston. Harvey's 24 hours rank about 1-in-50 here. The October 1994 flood exceeds 100 years at 6 and 24 hours, and even its total is a lower bound, with 29 hours missing.

Two hourly bar charts at Conroe. Beryl, 7 to 9 July 2024: 123 mm, mostly between 02:00 and 11:00 on 8 July with a peak of 38 mm in one hour, and 13 missing hours on 7 July shaded grey. Harvey, 25 to 30 August 2017: 450 mm in two bursts on 26 and 27 August with hourly peaks above 50 mm and no missing hours. Two hourly bar charts at Conroe. Beryl, 7 to 9 July 2024: 123 mm, mostly between 02:00 and 11:00 on 8 July with a peak of 38 mm in one hour, and 13 missing hours on 7 July shaded grey. Harvey, 25 to 30 August 2017: 450 mm in two bursts on 26 and 27 August with hourly peaks above 50 mm and no missing hours.
Beryl and Harvey hour by hour at the Conroe gauge, local standard time. Data: NOAA NCEI Hourly Precipitation Data.

Example 2: what the completeness rule does to the 100-year value

for D in (1, 6, 24):
    out = []
    for rule in ("strict", "kept"):
        am = annual_max(D, rule)
        out.append(genextreme.ppf(0.99, *genextreme.fit(am, -0.1, loc=np.median(am), scale=am.std() / 2)))
    print(f"{D:2d} h 100-yr: {out[0]:5.0f} mm (90% rule) -> {out[1]:5.0f} mm (large incomplete years kept); Atlas 14 {ATLAS14.loc[D, 100]:.0f} mm")
 1 h 100-yr:   101 mm (90% rule) ->    95 mm (large incomplete years kept); Atlas 14 118 mm
 6 h 100-yr:   241 mm (90% rule) ->   284 mm (large incomplete years kept); Atlas 14 282 mm
24 h 100-yr:   328 mm (90% rule) ->   364 mm (large incomplete years kept); Atlas 14 409 mm

At 6 and 24 hours, throwing away incomplete years removes the years when the gauge recorded floods and lowers the 100-year estimate by 15% and 10%. At 1 hour the strict rule comes out slightly higher, because the years it drops were not extreme for one-hour bursts. A completeness rule is not neutral. Check what it removes.

What an IDF curve summarises

An IDF curve summarises the extreme-value distribution of rainfall depth at a set of durations. For each duration, the annual maximum series is fitted with a GEV. The return level for T years is the depth exceeded with probability 1/T in any year. Dividing by the duration gives intensity, and plotting intensity against duration on log axes gives the familiar falling curves, one per return period.

From an hourly gauge record to IDF curves. record to curvescleanflags, βˆ’9999,accumulationschoose yearscompleteness rulesliding maxima1–24 h windowsGEV perdurationwith a start valuecheckagainst Atlas 14The judgement is in the first two steps spatialworkflow.io
Five steps; the judgement is in the first two.

The arithmetic is quick. The judgement is in the data. Which values are real hourly totals? Which years can be trusted? Are the maxima sliding or fixed? Is one gauge enough? For Conroe, a careful single-gauge analysis gets close to Atlas 14 at 6–24 hours and falls short at 1 hour, for reasons that are understood. The official curves should still be the default for design. A local analysis like this one is how you check them, extend them to a new duration, or see how a recent storm changes the picture.

Clock-hour data, local time and fit starting points

Common errors when building IDF curves from a gauge, and what they do. what moves the curveErrorEffect at Conroedrop every year under 90%loses 2017 and 2024; 6 h 100-yrβˆ’15%calendar-day maximaHarvey βˆ’12%, 2001 βˆ’2%clock-hour 1 h totalspart of why 1 h is 6–20% lowkeep accumulation valuesmulti-hour totals as one hourNCEI HPD USC00411956 against NOAA Atlas 14 spatialworkflow.io
What moves an IDF curve.
  • Clock-hour data understate short durations; Atlas 14 applies conversion factors to reach true 60-minute and 24-hour maxima.
  • Hours are local standard time in HPD; a storm total split at midnight depends on that.
  • The GEV fit needs a starting point; scipy's default start can fail on heavy-tailed rainfall.
  • Thirty to sixty years of record gives wide uncertainty at 100 years; bootstrap it before quoting a single number.
  • Non-stationarity: Atlas 14 assumes a stable climate; newer studies adjust for trends.
  • Partial-duration series (all peaks above a threshold) give slightly different values from annual maxima at short return periods.

FAQ

What is an IDF curve?

A set of curves giving rainfall intensity against duration for several return periods, built from the annual maxima of rainfall at each duration.

How many years of data do I need for IDF curves?

At least 20–30 complete years for frequent return periods; estimates beyond the record length (e.g. 100-year from 50 years) carry wide uncertainty.

Why is my 1-hour IDF value lower than NOAA Atlas 14?

Clock-hour data miss the true peak 60 minutes, and Atlas 14 pools regional stations. At Conroe the single-gauge 1-hour values are 6% lower at 2 years and 20% lower at 100 years.

Should I drop incomplete years from an annual maximum series?

Not automatically. A year with gaps whose observed maximum is already large carries real information β€” the true maximum can only be larger.