Converting NOAA Atlas 14 IDF Data into Model Forcing
The depth-duration-frequency table is the entry point to every design storm, and the step from the published values to a model input carries three quiet traps: the series type, the units, and the confidence limits that get discarded on the way. This guide covers the retrieval and conversion, as part of the precipitation forcing and design storms topic within rainfall-runoff modeling and hydrologic simulation.
Prerequisites
- A catchment polygon in a projected CRS, so the centroid is area-weighted correctly.
requestsandpandas.- A decision on the series type — partial duration or annual maximum — taken from the applicable design guidance rather than from the default.
Core Technique: Parse Once, Structure Properly
The point precipitation service returns a compact CSV whose rows are durations and whose columns are return periods, with the estimate and both confidence bounds in separate blocks. The useful representation for a model pipeline is the transpose: a tidy frame of duration, return period, estimate, lower and upper.
Annotated Code Example
import io
import logging
import geopandas as gpd
import pandas as pd
import requests
log = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
PFDS_URL = "https://hdsc.nws.noaa.gov/cgi-bin/hdsc/new/fe_text_mean.csv"
IN_TO_MM = 25.4
# Duration labels as the service returns them, mapped to minutes.
DURATION_MINUTES = {
"5-min": 5, "10-min": 10, "15-min": 15, "30-min": 30, "60-min": 60,
"2-hr": 120, "3-hr": 180, "6-hr": 360, "12-hr": 720, "24-hr": 1440,
"2-day": 2880, "3-day": 4320, "4-day": 5760, "7-day": 10080,
}
def fetch_ddf(
lat: float,
lon: float,
series: str = "pds",
units: str = "english",
timeout: int = 30,
) -> pd.DataFrame:
"""
Retrieve depth-duration-frequency depths for one point, tidied to mm.
Parameters
----------
lat, lon : Point location in decimal degrees, WGS84.
series : "pds" for partial duration, "ams" for annual maximum.
units : Service units; the response is converted to mm regardless.
Returns
-------
Tidy frame: duration_min, return_period_yr, estimate_mm, lower_mm, upper_mm.
"""
params = {"lat": lat, "lon": lon, "type": "pf", "data": "depth",
"units": units, "series": series}
log.info("Requesting %s depths at (%.5f, %.5f)", series.upper(), lat, lon)
resp = requests.get(PFDS_URL, params=params, timeout=timeout)
resp.raise_for_status()
text = resp.text
if "PRECIPITATION FREQUENCY ESTIMATES" not in text.upper():
raise ValueError("unexpected response — the point may be outside the "
"published coverage")
# --- The response is three labelled blocks with the same shape. Split on
# the block headers rather than on fixed line numbers, which change
# between regional volumes. ---
blocks = {}
current, buf = None, []
for line in text.splitlines():
upper = line.upper()
if "PRECIPITATION FREQUENCY ESTIMATES" in upper:
current, buf = "estimate", []
elif "UPPER BOUND" in upper:
blocks[current] = buf
current, buf = "upper", []
elif "LOWER BOUND" in upper:
blocks[current] = buf
current, buf = "lower", []
elif current and line.strip() and line[0].isdigit() or (
current and line.strip().split(":")[0] in DURATION_MINUTES):
buf.append(line)
if current:
blocks[current] = buf
frames = []
for name, lines in blocks.items():
if not lines:
continue
df = pd.read_csv(io.StringIO("\n".join(lines)), header=None)
df = df.rename(columns={0: "duration"})
df["duration"] = df["duration"].astype(str).str.strip().str.rstrip(":")
long = df.melt(id_vars="duration", var_name="rp_index", value_name=name)
frames.append(long.set_index(["duration", "rp_index"]))
merged = pd.concat(frames, axis=1).reset_index()
# Return periods are in a fixed order in every regional volume.
rp_order = [1, 2, 5, 10, 25, 50, 100, 200, 500, 1000]
merged["return_period_yr"] = merged["rp_index"].map(
{i + 1: rp for i, rp in enumerate(rp_order)}
)
merged["duration_min"] = merged["duration"].map(DURATION_MINUTES)
merged = merged.dropna(subset=["duration_min", "return_period_yr"])
# --- Convert to millimetres. The service returns inches under
# units="english"; a pipeline that mixes the two produces depths off by
# a factor of 25.4, which is large enough to notice and small enough
# to be mistaken for a very wet region. ---
scale = IN_TO_MM if units == "english" else 1.0
for col in ("estimate", "lower", "upper"):
if col in merged:
merged[f"{col}_mm"] = merged[col].astype(float) * scale
out = merged[["duration_min", "return_period_yr",
"estimate_mm", "lower_mm", "upper_mm"]].copy()
out = out.sort_values(["return_period_yr", "duration_min"]).reset_index(drop=True)
row = out[(out.return_period_yr == 100) & (out.duration_min == 1440)]
if len(row):
r = row.iloc[0]
spread = 100.0 * (r.upper_mm - r.lower_mm) / r.estimate_mm
log.info("100-yr 24-hr: %.1f mm [%.1f, %.1f] — a %.0f %% spread",
r.estimate_mm, r.lower_mm, r.upper_mm, spread)
log.info("Retrieved %d duration/return-period pairs", len(out))
return out
def basin_centroid(catchment_path: str) -> tuple[float, float]:
"""Area-weighted centroid of a catchment, returned as WGS84 lat/lon."""
gdf = gpd.read_file(catchment_path)
if gdf.crs is None or gdf.crs.is_geographic:
raise ValueError("compute the centroid in a projected CRS — a centroid "
"in degrees is not the area-weighted centre")
pt = gdf.geometry.union_all().centroid
lonlat = gpd.GeoSeries([pt], crs=gdf.crs).to_crs(4326).iloc[0]
log.info("Catchment centroid: %.5f, %.5f", lonlat.y, lonlat.x)
return float(lonlat.y), float(lonlat.x)
# --- Example usage ---
# lat, lon = basin_centroid("catchment.gpkg")
# ddf = fetch_ddf(lat, lon, series="pds")
# design = ddf[ddf.return_period_yr == 100].set_index("duration_min")["estimate_mm"]
# print(design.round(1))
Parameter Reference
| Parameter | Options | Effect |
|---|---|---|
series |
pds / ams |
Partial duration is ~10 % higher at a 2-year return period; they converge above 10 years |
units |
english / metric |
The service default is inches; failing to convert gives depths 25.4× too small |
| Centroid CRS | Projected | A centroid computed in degrees is not the area-weighted centre |
| Bounds | Retrieved and kept | A 100-year depth routinely carries a ±20 % published interval |
Worked Example: Carrying the Uncertainty Through
Running the same model three times — at the lower bound, the estimate and the upper bound — costs almost nothing and changes the conversation about the result.
| Forcing | 24-hr depth | Modelled peak | Relative to central |
|---|---|---|---|
| Lower bound | 129.4 mm | 41.2 m³/s | −18 % |
| Central estimate | 152.0 mm | 50.4 m³/s | — |
| Upper bound | 180.6 mm | 62.9 m³/s | +25 % |
Depth-duration curves are close to straight in log-log space and distinctly curved in linear space, so the interpolation choice matters most exactly where the published table is sparsest.
Gotchas and Edge Cases
- Units left in inches. A 152 mm storm becomes a 6 mm storm; the model runs and produces almost no runoff, which is often misdiagnosed as a curve number problem.
- Annual maximum where partial duration is specified. Roughly 10 % low at a 2-year return period, converging to nothing above 10 years. State the series in the report.
- Centroid in degrees. A geographic centroid is not the area-weighted centre and can sit several kilometres from it on an elongated basin.
- One lookup for an orographic basin. Where depths vary strongly across the basin, sample several points or use the gridded product.
- Bounds discarded on retrieval. Recovering them later means a second request; keeping them costs two columns.
- Interpolating between published durations linearly. Depth-duration curves are close to linear in log-log space and distinctly not in linear space. Interpolate in logs.
- Assuming coverage. The published volumes do not cover every jurisdiction, and a request outside coverage returns a page rather than data. Check the response before parsing.
Related Topics
- Precipitation Forcing and Design Storms — the parent topic: areal reduction, distribution methods and validation
- Building SCS Design Storm Hyetographs in Python — turning the 24-hour depth into a time series
- Retrieving and Gridding PRISM Precipitation in Python — observed forcing, for calibration rather than design
- Flood Frequency and Streamflow Statistics — the gauged route to the same design number