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

  • dataretrieval and pandas.
  • 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.

What Each Service Retains of the Same Event A storm hydrograph peaking at 96 cubic metres per second. The instantaneous series follows it exactly. The daily mean series flattens it to 44. The annual peak series records the single instantaneous maximum of 96 for that water year. annual peak: 96 m³/s daily mean: 44 m³/s instantaneous series day 1 day 3 day 5 day 7 one storm, three products discharge Annual maxima taken from daily means would record 44, understating this flood by 54 %.

Annotated Code Example

python
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
What the Screening Actually Removes A 62-year record shown as a bar. The first 18 years precede a dam and are natural. A regulated era follows. Two gaps of several months appear in the 1990s, and the final two years are provisional. Only the natural, approved, gap-free portion is usable for frequency analysis. natural, approved regulated — dam completed 1980 provisional gaps 1962 1980 1997 2022 2024 62 years published — 18 usable for a natural-flow frequency analysis An unscreened fit over all 62 years mixes two populations and produces a curve that describes neither the pre-dam river nor the regulated one. the published record

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.

Which Gaps Matter for Which Statistic A gap in the dry season removes the low-flow minimum for that year but barely moves the annual volume. A gap in the wet season removes the annual peak and a large share of the volume. A gap spanning both invalidates the year for every statistic. gap falls in low-flow statistic annual volume annual peak the dry season year lost −2 % unaffected the wet season unaffected −34 % year lost spanning both year lost year lost year lost Report the gaps beside the statistic — a count of missing days is not enough to read this table.

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.