Retrieving USGS Streamflow with dataretrieval-python
Almost every calibration, validation and frequency analysis begins with the same step, and it has more sharp edges than its one-line API suggests. This guide covers the retrieval and the screening that has to follow it, as part of the flood frequency and streamflow statistics topic within rainfall-runoff modeling and hydrologic simulation.
Prerequisites
dataretrievalandpandas.- The site number as a zero-padded string. Passing it as an integer drops the leading zero that most eastern sites carry, and the request returns nothing.
- A decision about which service you need — the three below are not interchangeable.
Core Technique: Three Services, Three Purposes
| Service | Function | Resolution | Use it for |
|---|---|---|---|
| Daily values | get_dv |
One value per day | Water balance, low-flow statistics, long records |
| Instantaneous values | get_iv |
5–60 minutes | Event calibration, peak timing |
| Annual peaks | get_discharge_peaks |
One per water year | Flood frequency analysis |
The distinction that costs people most is between the daily series and the peak series. A daily mean is an average over 24 hours; on a small flashy basin the instantaneous peak can be twice it. Taking annual maxima from daily values and calling them annual peaks understates every flood quantile.
Annotated Code Example
import logging
import numpy as np
import pandas as pd
import dataretrieval.nwis as nwis
log = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
CFS_TO_CMS = 0.0283168
DISCHARGE = "00060" # discharge, cubic feet per second
DAILY_MEAN = "00003" # statistic code: daily mean
def fetch_daily_discharge(
site: str,
start: str,
end: str,
approved_only: bool = True,
) -> pd.Series:
"""
Daily mean discharge for one site, in m³/s, screened on approval flags.
Parameters
----------
site : Site number as a STRING — an int drops the leading zero.
start, end : ISO dates.
approved_only : Drop provisional values, which can change on review.
"""
if not isinstance(site, str):
raise TypeError("site must be a string; an int drops the leading zero "
"that most eastern sites carry")
df, meta = nwis.get_dv(sites=site, start=start, end=end,
parameterCd=DISCHARGE, statCd=DAILY_MEAN)
if df.empty:
raise ValueError(f"no daily discharge for {site} in {start}..{end}")
value_col = next(c for c in df.columns if c.endswith(f"{DISCHARGE}_{DAILY_MEAN}"))
flag_col = value_col + "_cd"
n_all = len(df)
if approved_only and flag_col in df.columns:
# A = approved, P = provisional. Provisional high flows in particular
# move on review, because the rating is extrapolated there.
approved = df[flag_col].astype(str).str.contains("A")
n_prov = int((~approved).sum())
if n_prov:
log.warning("Dropping %d provisional value(s) of %d (%.1f %%)",
n_prov, n_all, 100.0 * n_prov / n_all)
df = df[approved]
series = df[value_col].astype(float) * CFS_TO_CMS
series.index = pd.to_datetime(df.index).tz_localize(None)
series.name = "discharge_cms"
# --- Report the gaps explicitly. A metric computed over a series with a
# four-month hole is not wrong, but it is not what the caller thinks. ---
full = pd.date_range(series.index.min(), series.index.max(), freq="D")
missing = len(full) - len(series)
log.info("%s: %d days from %s to %s, %d missing (%.2f %%)",
site, len(series), series.index.min().date(),
series.index.max().date(), missing, 100.0 * missing / len(full))
if missing:
gaps = full.difference(series.index)
runs = _gap_runs(gaps)
longest = max((r[1] for r in runs), default=0)
log.warning("%d gap(s), longest %d consecutive day(s)", len(runs), longest)
return series
def fetch_annual_peaks(site: str) -> pd.DataFrame:
"""
The reviewed annual peak series, with qualification codes preserved.
Returns a frame indexed by water year with columns peak_cms and codes.
"""
df, _ = nwis.get_discharge_peaks(sites=site)
if df.empty:
raise ValueError(f"no annual peak series published for {site}")
out = pd.DataFrame({
"peak_cms": df["peak_va"].astype(float) * CFS_TO_CMS,
"codes": df.get("peak_cd", pd.Series(index=df.index, dtype=object)),
})
dates = pd.to_datetime(df["peak_dt"], errors="coerce")
# Water year runs October to September, so a December peak belongs to the
# following water year. Using calendar years splits winter events.
out["water_year"] = dates.dt.year + (dates.dt.month >= 10).astype(int)
out = out.dropna(subset=["water_year"]).set_index("water_year")
# Qualification codes 5, 6 and C flag peaks affected by regulation or
# by an urban or agricultural change — not natural floods.
regulated = out["codes"].astype(str).str.contains("[56C]", regex=True, na=False)
if regulated.any():
log.warning("%d of %d peaks are flagged as regulated or affected by "
"change — exclude them or fit the periods separately",
int(regulated.sum()), len(out))
out["regulated"] = regulated
log.info("%s: %d annual peaks, %d–%d, max %.1f m³/s in %d",
site, len(out), int(out.index.min()), int(out.index.max()),
float(out["peak_cms"].max()), int(out["peak_cms"].idxmax()))
return out
def _gap_runs(gaps: pd.DatetimeIndex):
"""Collapse a set of missing dates into (start, length) runs."""
if len(gaps) == 0:
return []
breaks = np.where(np.diff(gaps.values).astype("timedelta64[D]").astype(int) > 1)[0]
starts = np.concatenate([[0], breaks + 1])
ends = np.concatenate([breaks, [len(gaps) - 1]])
return [(gaps[s], int(e - s + 1)) for s, e in zip(starts, ends)]
# --- Example usage ---
# q = fetch_daily_discharge("03339000", "1990-10-01", "2024-09-30")
# peaks = fetch_annual_peaks("03339000")
# natural = peaks[~peaks.regulated]["peak_cms"]
Parameter Reference
| Code / argument | Meaning | Consequence of getting it wrong |
|---|---|---|
00060 |
Discharge, ft³/s | 00065 returns stage; the frame looks fine and the calibration is against the wrong variable |
00003 |
Daily mean statistic | 00001 is daily maximum, 00002 daily minimum |
Site as str |
Preserves leading zeros | An int returns an empty frame with no error |
Flag A / P |
Approved / provisional | Provisional high flows move on review |
| Peak codes 5, 6, C | Regulated or affected | Pooling them with natural peaks biases the frequency curve |
| Water year | Oct–Sep | Calendar years split winter events across two years |
Gaps are not all equally damaging. A gap in the dry season costs a low-flow statistic nothing and costs a water balance a little; a gap across the wet season invalidates both.
Caching retrievals so a run is reproducible
A pipeline that re-queries the service on every run is both slow and non-reproducible: the record grows, provisional values are revised, and a result computed last month cannot be recovered. Caching the raw response to disk, keyed on the site number, the parameter code, the date range and the retrieval date, fixes both problems at once. The cache becomes the record of what the service said when the analysis was done, which is exactly what a report needs to be defensible a year later.
The retrieval date belongs in the key rather than in a comment. Two runs a year apart against the same site and date range legitimately return different values for the provisional tail, and a cache that cannot distinguish them will silently serve whichever arrived first.
Assembling several sites without hiding the differences
A regional study usually needs many gauges, and the temptation is to concatenate them into one frame and move on. That loses the per-site record lengths, gaps and flags, which are precisely what decides whether a site belongs in the analysis.
A better shape is one frame per site plus a summary table carrying, for each site, the record length, the count and longest run of gaps, the share of provisional values, the count of regulated peaks and the published drainage area. That table is small, it makes site selection an explicit filter rather than an implicit one, and it is the thing a reviewer will ask for. Building it costs one pass over the retrievals that have already been made.
Gotchas and Edge Cases
- Site number as an integer. Silently returns nothing for any site whose number begins with zero.
- Wrong parameter code. Stage instead of discharge produces a series that is plausible in shape, wrong in magnitude and units, and calibrates to nonsense.
- Provisional data in a frequency analysis. High flows are exactly where provisional values change most on review.
- Daily maxima used as annual peaks. Understates every quantile, badly on small basins.
- Calendar years instead of water years. A December flood and the following March flood land in the same year, and one of them is dropped from the annual maximum series.
- Gaps ignored. A metric computed over a series with a missing wet season is not comparable with one that has it. Report the gaps alongside the metric.
- Time zone on instantaneous values. The service returns times in the site’s local zone; comparing against UTC-stamped forcing shifts the whole event.
Related Topics
- Flood Frequency and Streamflow Statistics — the parent topic: screening, distribution fitting and confidence limits
- Fitting Log-Pearson III Flood Frequency Curves in Python — what the screened peak series is for
- Computing 7Q10 and Low-Flow Statistics in Python — the daily series’ counterpart use
- Model Calibration & Objective Functions — the other consumer of this record