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.
Annotated Code Example
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.
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.
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.
Related Topics
- Hydro-Conditioning for Culverts and Road Crossings — the parent topic covering the conditioning this validates
- Breaching Road Embankments and Culverts with WhiteboxTools — the conditioning run that produces the surface under test
- Burning Stream Networks into DEMs with the AGREE Method — why a burned surface needs the independent check specifically
- Snapping Stream Gauge Locations to NHD Flowlines — separating a snapping error from a conditioning error