Validating Hydro-Conditioning Against NHD Flowlines

Conditioning either fixed the drainage or moved it somewhere new, and the surface itself will not say which. This guide sets out the checks that distinguish the two, as part of the hydro-conditioning for culverts and road crossings topic within hydrology data preparation and DEM processing — with particular attention to the circularity that makes the most obvious check worthless on a burned surface.

Prerequisites

  • The conditioned DEM, and the original for comparison.
  • NHD flowlines, or an equivalent independently mapped network, clipped to the area.
  • Stream gauge metadata with published drainage areas — this is the only genuinely independent evidence most projects have.
  • geopandas, rasterio, numpy.

Core Technique: Three Checks with Different Independence

The three checks below differ in what they can prove, and the difference matters most when the conditioning included any burning.

How Independent Each Check Actually Is Network overlap against NHD is fully circular after a burn and only partially independent after breaching. Crossing continuity uses the road layer that guided the breaching, so it is partially circular. Gauge drainage area uses neither input and is fully independent. network overlap vs NHD after burning: circular after breaching: partial proves: the extraction ran and looks like a network crossing continuity uses the road layer that guided the breaching proves: each breach is complete, not half-cut gauge drainage area uses neither the roads nor the hydrography proves: the surface drains the area it should Run all three, but weight the conclusion by independence: a run that passes the first two and fails the third has produced a network that looks right and drains the wrong ground. Only the third survives a burned surface intact.

Annotated Code Example

python
import logging

import geopandas as gpd
import numpy as np
import rasterio
from rasterio.features import rasterize
from shapely.geometry import mapping

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


def validate_conditioning(
    facc_path: str,
    nhd_path: str,
    threshold_cells: int,
    gauges: gpd.GeoDataFrame | None = None,
    area_field: str = "drain_area_km2",
    buffer_cells: int = 2,
) -> dict:
    """
    Score a conditioned surface against NHD and, where available, against
    published gauge drainage areas.

    Parameters
    ----------
    facc_path       : Flow accumulation raster from the conditioned DEM.
    nhd_path        : Reference flowlines.
    threshold_cells : Calibrated channel-initiation threshold.
    gauges          : Optional gauge points carrying a published area field.
    area_field      : Column on `gauges` holding the published area in km².
    buffer_cells    : Spatial tolerance for the overlap comparison.

    Returns
    -------
    Dict with overlap metrics and, when gauges are supplied, per-gauge
    drainage-area residuals.
    """
    with rasterio.open(facc_path) as src:
        acc = src.read(1)
        transform, crs, shape = src.transform, src.crs, (src.height, src.width)
        cell_size = abs(src.transform.a)
        cell_area_km2 = (cell_size ** 2) / 1e6

    extracted = acc >= threshold_cells

    # --- Rasterize NHD with a tolerance buffer. The buffer absorbs the real
    # geometric difference between a generalised vector line and a network
    # that follows cell centres — typically one to two cells. ---
    nhd = gpd.read_file(nhd_path).to_crs(crs)
    buffered = nhd.geometry.buffer(buffer_cells * cell_size)
    reference = rasterize(
        ((mapping(g), 1) for g in buffered if g is not None and not g.is_empty),
        out_shape=shape, transform=transform, fill=0, dtype=np.uint8,
    ).astype(bool)

    tp = int((extracted & reference).sum())
    fp = int((extracted & ~reference).sum())
    fn = int((~extracted & reference).sum())
    precision = tp / (tp + fp) if tp + fp else 0.0
    recall = tp / (tp + fn) if tp + fn else 0.0
    f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0

    log.info("Network overlap: precision %.3f, recall %.3f, F1 %.3f "
             "(buffer %d cells)", precision, recall, f1, buffer_cells)

    result = {"precision": precision, "recall": recall, "f1": f1}

    # --- The independent check. Delineated area at each gauge against the
    # published area, which was derived from neither the DEM nor the NHD
    # geometry used above. ---
    if gauges is not None and len(gauges):
        residuals = []
        gauges = gauges.to_crs(crs)
        for _, row in gauges.iterrows():
            r, c = rasterio.transform.rowcol(transform, row.geometry.x, row.geometry.y)
            if not (0 <= r < shape[0] and 0 <= c < shape[1]):
                log.warning("Gauge %s falls outside the raster", row.get("site_no", "?"))
                continue
            delineated_km2 = float(acc[r, c]) * cell_area_km2
            published_km2 = float(row[area_field])
            if published_km2 <= 0:
                continue
            pct = 100.0 * (delineated_km2 - published_km2) / published_km2
            residuals.append({
                "site": row.get("site_no", "?"),
                "delineated_km2": delineated_km2,
                "published_km2": published_km2,
                "residual_pct": pct,
            })
            log.info("gauge %s: delineated %.1f km², published %.1f km² (%+.1f %%)",
                     row.get("site_no", "?"), delineated_km2, published_km2, pct)

        if residuals:
            pcts = np.array([r["residual_pct"] for r in residuals])
            median, spread = float(np.median(pcts)), float(np.percentile(np.abs(pcts), 90))
            result.update({
                "gauges": residuals,
                "median_residual_pct": median,
                "p90_abs_residual_pct": spread,
            })
            # A consistent one-sided offset is a systematic problem, not a set
            # of individual snapping errors.
            if abs(median) > 5.0:
                log.error("Median residual %+.1f %% across %d gauges — this is a "
                          "conditioning or extent problem, not per-gauge snapping",
                          median, len(residuals))
            else:
                log.info("Median residual %+.1f %%, p90 |residual| %.1f %% over %d gauges",
                         median, spread, len(residuals))

    return result


# --- Example usage ---
# gauges = gpd.read_file("usgs_gauges.gpkg")
# validate_conditioning(
#     facc_path="basin_conditioned_acc.tif",
#     nhd_path="nhd_flowlines.gpkg",
#     threshold_cells=500,
#     gauges=gauges, area_field="drain_area_km2",
# )

Worked Example: Reading the Report

A 10 m DEM over a 900 km² basin, conditioned by breaching, validated against 14 gauges:

Metric Before conditioning After conditioning
Network F1 vs NHD (2-cell buffer) 0.61 0.88
Median gauge residual −11.4 % −0.8 %
90th percentile absolute residual 24.0 % 3.6 %
Gauges outside ±5 % 11 of 14 1 of 14

The F1 improvement is encouraging but not decisive — breaching does not force agreement with NHD, so some of it is real, and some is the general improvement in drainage that any conditioning produces.

The gauge residuals are what settles it. A median that moved from −11 % to −0.8 % says the surface now drains close to the right area at fourteen independent locations. The one remaining outlier is worth inspecting individually; it is a single-site problem, not a systematic one.

The Residual Distribution Is the Verdict Fourteen gauge residuals plotted on a percentage axis. Before conditioning they cluster between minus 30 and minus 4 percent, a clear systematic offset. After conditioning they cluster within plus or minus 4 percent, with one outlier at minus 9. −30 % −20 % −10 % 0 +10 % +20 % delineated area minus published area, as a share of published before conditioning after conditioning one outlier — inspect individually a systematic shift like the upper row is one problem, not ten

Where the reference network sits at a different mapping scale from the extraction, precision falls for a reason that has nothing to do with conditioning. Comparing like with like is part of the check.

A Scale Mismatch Reads as a Conditioning Failure The same extracted network scored against three reference products. Against medium-resolution hydrography precision is 0.42 because the extraction contains headwaters the reference never mapped. Against high-resolution hydrography it is 0.81. Against a field-mapped network it is 0.88. medium-resolution precision 0.42 high-resolution 0.81 field-mapped subset 0.88 The extraction did not change between these three rows. The reference did, and a low precision against a coarse reference is a mapping-scale fact, not a defect. one extracted network, three references

Reporting the result so it can be re-checked

A validation result that cannot be reproduced is an assertion rather than evidence, and what makes it reproducible is a short set of recorded parameters: the accumulation threshold, the buffer width in cells, the reference product and its vintage, the gauge set used, and the conditioning steps applied. All six fit on one line of a run log and all six change the numbers.

The gauge set matters more than it looks. Adding three gauges to a fourteen-gauge comparison can move the median residual by a percentage point simply by changing which basins are represented, so a comparison against a different gauge set is not a comparison against a different pipeline. Freeze the set for a project, and record it.

What a passing check does not prove

Even a clean report leaves two things unestablished. It says nothing about the parts of the basin with no gauges and no mapped hydrography, which on a headwater-dominated catchment can be most of the area. And it says nothing about whether the conditioning was minimal — a surface that has been burned flat everywhere will pass a drainage-area comparison while being useless for any terrain analysis.

The complementary measurement is the count of cells the conditioning modified, taken from a difference raster against the original DEM. A run that changed tens of thousands of cells and reconciled fourteen gauges is a targeted repair. One that changed millions and reconciled the same fourteen gauges has replaced the terrain with something that happens to drain correctly, which is a different product and should be labelled as one.

Gotchas and Edge Cases

  • Buffer too wide. Beyond three cells, recall approaches 1.0 regardless of the network’s quality. Report the buffer alongside the score, always.
  • NHD at a different scale. The high-resolution and medium-resolution products differ substantially in headwater density. Comparing a 1 m extraction against medium-resolution NHD produces a low precision that is a scale mismatch, not a conditioning failure.
  • Gauge on the wrong branch. A single wild residual is usually a snapping problem rather than a conditioning one — check the snap before blaming the surface. See snapping stream gauge locations to NHD flowlines.
  • Published areas in mixed units. Some sources publish square miles. A uniform residual near −61 % is the square-mile-to-square-kilometre factor, not a hydrologic finding.
  • DEM clipped inside the basin. Every gauge comes out low by an amount proportional to how much of its catchment is missing. The residuals will be one-sided and correlated with basin size.
  • Validating only where you conditioned. Checking crossings you breached tells you the code ran. Sample gauges across the whole basin, including areas with no crossings, to catch conditioning that broke something elsewhere.