Batch Validating Delineated Areas Against USGS Gauge Metadata

A delineation is validated one gauge at a time in most projects, which finds the worst error and misses every systematic one. Running the check across every gauge in the domain at once costs the same and answers a different question: not “is this catchment right” but “is this pipeline right”. This guide covers that batch check, as part of the outlet point mapping and validation topic within watershed delineation and catchment synchronization.

Prerequisites

  • A conditioned DEM with a flow accumulation grid, or a delineated catchment layer.
  • dataretrieval for the gauge metadata, geopandas, rasterio, numpy.
  • The domain’s bounding box, to enumerate the gauges inside it.

Core Technique: Residuals Across the Whole Domain

The residual for one gauge is the delineated area minus the published area, as a share of the published value. Individually a residual is a fact about one catchment. Collectively the residuals have a shape, and the shape is the diagnosis.

Three Residual Patterns, Three Different Causes Residuals scattered symmetrically around zero indicate normal delineation noise. Residuals clustered at a constant negative offset indicate a conditioning or extent problem. Residuals whose magnitude grows with basin area indicate a projection or unit error. scattered around zero normal — accept the layer inspect only outliers constant negative offset one problem, not many — conditioning or clipped extent grows with basin area a scale error — projection or a unit conversion

Annotated Code Example

python
import logging

import geopandas as gpd
import numpy as np
import pandas as pd
import rasterio
import dataretrieval.nwis as nwis

log = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")

SQMI_TO_SQKM = 2.58999


def gauges_in_domain(bounds_4326, min_area_km2: float = 5.0) -> gpd.GeoDataFrame:
    """
    Every streamflow gauge inside a bounding box, with its published area.

    Filters out very small basins, where a few boundary cells dominate the
    residual and the comparison says more about the DEM than the pipeline.
    """
    minx, miny, maxx, maxy = bounds_4326
    sites, _ = nwis.get_info(bBox=f"{minx:.6f},{miny:.6f},{maxx:.6f},{maxy:.6f}",
                             siteType="ST", hasDataTypeCd="dv")
    if sites.empty:
        raise ValueError("no stream gauges in this bounding box")

    df = sites.dropna(subset=["drain_area_va"]).copy()
    df["published_km2"] = df["drain_area_va"].astype(float) * SQMI_TO_SQKM

    # Contributing drainage area, where published, excludes closed basins
    # upstream. Where it differs from the total, it is the number a DEM
    # delineation with depressions removed should be compared against.
    if "contrib_drain_area_va" in df.columns:
        contrib = pd.to_numeric(df["contrib_drain_area_va"], errors="coerce")
        n_contrib = int(contrib.notna().sum())
        if n_contrib:
            log.info("%d site(s) publish a separate contributing area — using "
                     "it where present", n_contrib)
            df["published_km2"] = np.where(
                contrib.notna(), contrib * SQMI_TO_SQKM, df["published_km2"])

    df = df[df["published_km2"] >= min_area_km2]
    gdf = gpd.GeoDataFrame(
        df, geometry=gpd.points_from_xy(df["dec_long_va"], df["dec_lat_va"]),
        crs=4326,
    )
    log.info("%d gauges with a published area ≥ %.1f km² in the domain",
             len(gdf), min_area_km2)
    return gdf[["site_no", "station_nm", "published_km2", "geometry"]]


def batch_validate(
    facc_path: str,
    gauges: gpd.GeoDataFrame,
    snap_radius_m: float = 150.0,
) -> pd.DataFrame:
    """
    Compare the delineated area at every gauge against its published area.

    Snapping is done by taking the maximum accumulation within the search
    radius, which is the cheap equivalent of a full snap and adequate for a
    batch check.
    """
    with rasterio.open(facc_path) as src:
        acc = src.read(1).astype("float64")
        transform, crs = src.transform, src.crs
        cell_km2 = (abs(src.transform.a) ** 2) / 1e6
        h, w = acc.shape
        radius_cells = int(round(snap_radius_m / abs(src.transform.a)))

    pts = gauges.to_crs(crs)
    rows = []
    for _, g in pts.iterrows():
        r, c = rasterio.transform.rowcol(transform, g.geometry.x, g.geometry.y)
        if not (0 <= r < h and 0 <= c < w):
            log.warning("%s falls outside the raster", g.site_no)
            continue
        r0, r1 = max(0, r - radius_cells), min(h, r + radius_cells + 1)
        c0, c1 = max(0, c - radius_cells), min(w, c + radius_cells + 1)
        window = acc[r0:r1, c0:c1]
        if not np.isfinite(window).any():
            continue
        delineated = float(np.nanmax(window)) * cell_km2
        published = float(g.published_km2)
        rows.append({
            "site_no": g.site_no,
            "station_nm": g.station_nm,
            "published_km2": published,
            "delineated_km2": delineated,
            "residual_pct": 100.0 * (delineated - published) / published,
        })

    df = pd.DataFrame(rows)
    if df.empty:
        raise ValueError("no gauges could be evaluated")

    median = float(df["residual_pct"].median())
    mad = float(np.median(np.abs(df["residual_pct"] - median)))
    # Flag outliers relative to the median, not to zero: if the whole layer is
    # shifted, an outlier is a gauge that departs from the SHIFT.
    df["is_outlier"] = np.abs(df["residual_pct"] - median) > max(5.0, 4.0 * mad)

    log.info("%d gauges evaluated: median residual %+.2f %%, MAD %.2f %%",
             len(df), median, mad)
    if abs(median) > 3.0:
        log.error("A median residual of %+.2f %% across %d gauges is a "
                  "SYSTEMATIC problem — check the DEM extent, the conditioning "
                  "and the projection before looking at individual gauges",
                  median, len(df))
    n_out = int(df["is_outlier"].sum())
    if n_out:
        log.warning("%d gauge(s) depart from the population — likely snapped to "
                    "the wrong branch:", n_out)
        for _, r in df[df["is_outlier"]].iterrows():
            log.warning("  %s  %.1f vs %.1f km² (%+.1f %%)  %s",
                        r.site_no, r.delineated_km2, r.published_km2,
                        r.residual_pct, r.station_nm[:40])

    # A residual that grows with basin area is a scale error, not a
    # delineation one — test it explicitly rather than reading it off a plot.
    if len(df) >= 8:
        corr = float(np.corrcoef(np.log10(df["published_km2"]),
                                 df["residual_pct"])[0, 1])
        if abs(corr) > 0.6:
            log.error("Residual correlates with basin size (r = %.2f) — this is "
                      "a projection or unit error, not a delineation error", corr)
        else:
            log.info("No size dependence in the residuals (r = %.2f)", corr)
    return df.sort_values("residual_pct")


# --- Example usage ---
# gauges = gauges_in_domain((-84.5, 35.2, -83.1, 36.4))
# report = batch_validate("basin_acc.tif", gauges)
# report.to_csv("delineation_validation.csv", index=False)

Parameter Reference

Parameter Recommended Why
min_area_km2 5–10 Below that, boundary cells dominate the residual
snap_radius_m 100–200 Wide enough to reach the channel, narrow enough to avoid the wrong branch
Outlier rule Median ± 4 MAD Robust to a shifted population, which a mean-based rule is not
Acceptable median ±2 % Above it, look for a systematic cause first
Size-correlation threshold |r| > 0.6 Indicates a scale rather than a delineation problem

Worked Example: The Report That Localised the Problem

A first run over 38 gauges in a 4 000 km² domain:

Metric Value Action taken
Median residual −8.7 % Systematic — investigated first
MAD 1.9 % Tight, so one cause
Correlation with area −0.11 Not a scale error
Outliers 2 Deferred until the systematic cause was fixed

The tight, size-independent, one-sided offset pointed at the DEM extent, and it was: the mosaic had been clipped to the basin polygon rather than to the basin plus a margin, so every catchment lost the sliver of its headwaters that lay outside the clip. After re-clipping with a 5 km buffer:

Metric Value
Median residual −0.4 %
MAD 1.6 %
Outliers 2 — both genuine snapping errors, fixed individually
One Fix Moved Thirty-Six Gauges Before re-clipping, 38 gauge residuals cluster between minus 12 and minus 5 percent with two outliers far below. After re-clipping with a 5 kilometre buffer, the same gauges cluster within plus or minus 3 percent and the two outliers remain, now clearly individual problems. −60 % −30 % −12 % 0 +12 % +30 % residual: delineated minus published, as a share of published before re-clipping outliers after re-clipping with a 5 km buffer

Residual magnitude depends strongly on basin size, so a threshold that is strict for a large basin is unreasonably strict for a small one. Scaling the tolerance avoids flagging small catchments for being small.

Scale the Tolerance to the Basin A tolerance band that narrows with basin area: plus or minus 12 percent at 5 square kilometres, 6 percent at 25, 3 percent at 100 and 2 percent above 500. A fixed 2 percent threshold would flag most small basins for a reason that is purely geometric. 5 25 100 500 2 000 published basin area (km², log scale) +12 % 0 −12 % scaled tolerance band a flat ±2 % rule On a 5 km² basin at 10 m resolution, two boundary cells are already 0.4 %. A flat rule turns that geometry into a queue of false positives.

Gotchas and Edge Cases

  • Treating a systematic shift as many individual errors. The most expensive mistake available here. Read the median before reading any single row.
  • Square miles left unconverted. Produces a residual near −61 % on every gauge, which the size-correlation test will not catch because it is constant.
  • Total area used where contributing area is published. In arid and glaciated regions the two differ by the closed basins, sometimes by tens of percent.
  • Very small basins included. A 2 km² catchment on a 10 m DEM is 20 000 cells, and a boundary shifted by two cells is a percent. The noise swamps the signal.
  • Max-accumulation snapping across a confluence. Within a wide radius the maximum may sit on the main stem rather than the tributary the gauge is on. Narrow the radius or use the full two-stage snap.
  • Comparing against an unrevised published area. Some stations’ areas predate upstream diversions. The metadata usually says so; a single stubborn outlier on an old station is worth checking against the source rather than assuming your pipeline is wrong.