Synchronizing Nested Catchment Boundaries Across DEM Resolutions

Delineate the same watershed from a 10 m DEM and again from a 1 m LiDAR surface and you get two versions of every divide that agree in the large but disagree along every ridge crest, and where the two disagree a nested child catchment can slip outside the parent it should sit within. This guide reconciles those two versions into one consistent hierarchy, as a focused reference under the Nested Catchment Delineation topic, part of the broader Watershed Delineation & Catchment Synchronization coverage on this site.

The disagreement is not error in the usual sense; it is the physical consequence of grid resolution. A coarse DEM averages elevation over a larger cell, rounding off the sharp ridge that a fine DEM resolves crisply, so the divide migrates by up to a cell width or two. The trade-offs behind that migration are examined in the spatial resolution tradeoffs topic; this page assumes you already have two delineations in hand and need them to tell a single, self-consistent story.


Where the two delineations diverge

The divergence concentrates along shared divides and shrinks with catchment size. Visualising the offset between a coarse and a fine boundary makes clear why nesting can break and where snapping has to act.

Divide Offset Between Coarse and Fine DEM Delineations A blocky stair-stepped boundary represents the 10 m DEM divide. A smooth curved boundary represents the 1 m DEM divide. The band between them is the symmetric-difference area of disagreement. An arrow shows the coarse boundary being snapped onto the fine reference divide within a tolerance. symmetric difference area of disagreement 10 m coarse divide 1 m fine divide snap

The band between the two lines is the symmetric-difference area, the concrete measure of how much the delineations disagree. Synchronisation means collapsing that band: choosing one divide as the reference and moving the other onto it within a tolerance, then reconciling the areas so that every reconciled child still fits inside its reconciled parent. Getting the nesting itself right in the first place is covered in delineating nested catchments from multiple pour points; this page is about making two independent delineations of that nesting agree.


Prerequisites

bash
conda create -n sync python=3.11 geopandas shapely pyproj numpy -c conda-forge
conda activate sync

Both catchment sets must be delineated, valid, and carry a shared outlet identifier so corresponding catchments can be paired. Work in a projected CRS with metre units. If the fine DEM was resampled from a coarser source, its divide accuracy is bounded by the original resolution, a caveat discussed in resampling DEMs without losing hydrologic connectivity.


Measuring divergence

Before changing any geometry, quantify the disagreement per matched pair. The Hausdorff distance captures the worst-case boundary offset, and the symmetric-difference area captures the total disagreement.

python
import logging
import geopandas as gpd

logger = logging.getLogger(__name__)


def measure_divergence(coarse: gpd.GeoDataFrame, fine: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
    """Pair catchments by outlet_id and quantify boundary and area disagreement."""
    merged = coarse.merge(
        fine, on="outlet_id", suffixes=("_coarse", "_fine")
    )
    logger.info("Paired %d catchments by outlet_id", len(merged))

    rows = []
    for _, pair in merged.iterrows():
        g_coarse = pair.geometry_coarse
        g_fine = pair.geometry_fine
        sym_diff = g_coarse.symmetric_difference(g_fine).area
        hausdorff = g_coarse.hausdorff_distance(g_fine)
        rel_area = sym_diff / g_fine.area if g_fine.area else float("nan")
        rows.append({
            "outlet_id": pair.outlet_id,
            "area_coarse": g_coarse.area,
            "area_fine": g_fine.area,
            "sym_diff_area": sym_diff,
            "hausdorff_m": hausdorff,
            "rel_disagreement": rel_area,
        })
        logger.info(
            "Outlet %s: Hausdorff=%.1f m, symmetric diff=%.1f m^2 (%.2f%% of fine area)",
            pair.outlet_id, hausdorff, sym_diff, rel_area * 100
        )
    return gpd.GeoDataFrame(rows)

The relative disagreement column is the one to watch. A 200 m^2 symmetric difference is negligible on a 50 km^2 basin but alarming on a 1 km^2 headwater catchment, so judging each pair against its own area, rather than an absolute threshold, is what keeps the review honest.


Snapping the coarse divide to the fine reference

With the fine divide chosen as reference, snap the coarse boundary onto it. Snapping the coarse geometry’s vertices to the fine geometry within a tolerance harmonises the shared divide without discarding the coarse topology.

python
import logging
import geopandas as gpd
from shapely.ops import snap

logger = logging.getLogger(__name__)


def synchronize(
    coarse: gpd.GeoDataFrame,
    fine: gpd.GeoDataFrame,
    tolerance_m: float = 30.0,
) -> gpd.GeoDataFrame:
    """Snap each coarse catchment onto its fine counterpart within tolerance_m."""
    paired = coarse.merge(fine[["outlet_id", "geometry"]], on="outlet_id", suffixes=("", "_fine"))
    logger.info("Synchronizing %d catchments with tolerance %.1f m", len(paired), tolerance_m)

    reconciled = []
    for _, pair in paired.iterrows():
        snapped = snap(pair.geometry, pair.geometry_fine, tolerance_m)
        # Guard against snapping introducing invalidity
        if not snapped.is_valid:
            snapped = snapped.buffer(0)
            logger.warning("Outlet %s required buffer(0) repair after snap", pair.outlet_id)
        moved = pair.geometry.symmetric_difference(snapped).area
        reconciled.append({"outlet_id": pair.outlet_id, "geometry": snapped, "area_m2": snapped.area})
        logger.info("Outlet %s snapped; %.1f m^2 of boundary reconciled", pair.outlet_id, moved)

    return gpd.GeoDataFrame(reconciled, crs=coarse.crs)


def enforce_nesting(catchments: gpd.GeoDataFrame, parent_col: str = "parent_id") -> gpd.GeoDataFrame:
    """Clip every child to its parent so snapping cannot break containment."""
    out = catchments.set_index("outlet_id")
    for child_id, child in out.iterrows():
        parent_id = child[parent_col]
        if parent_id == -1 or parent_id not in out.index:
            continue
        parent_geom = out.loc[parent_id, "geometry"]
        if not parent_geom.contains(child.geometry):
            clipped = child.geometry.intersection(parent_geom)
            out.loc[child_id, "geometry"] = clipped
            logger.warning("Child %s clipped back inside parent %s", child_id, parent_id)
    return out.reset_index()


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
    coarse_gdf = gpd.read_file("catch_10m.gpkg").to_crs(5070)
    fine_gdf = gpd.read_file("catch_1m.gpkg").to_crs(5070)
    logger.info("Loaded %d coarse and %d fine catchments", len(coarse_gdf), len(fine_gdf))
    divergence = measure_divergence(coarse_gdf, fine_gdf)
    reconciled = synchronize(coarse_gdf, fine_gdf)
    reconciled["parent_id"] = coarse_gdf.set_index("outlet_id")["parent_id"].reindex(reconciled.outlet_id).values
    final = enforce_nesting(reconciled)
    final.to_file("catchments_synced.gpkg", driver="GPKG")

The enforce_nesting clip is the safeguard that keeps synchronisation from silently breaking the hierarchy. Snapping moves boundaries independently, so a child divide can end up crossing outside its parent; intersecting each child with its parent guarantees strict containment survives the reconciliation.


Worked example: reading a divergence report

Consider four catchments delineated at both 10 m and 1 m. The largest, a 63.4 km^2 basin, shows a Hausdorff distance of 22 m and a symmetric-difference area of 0.18 km^2, which is 0.28% of its area. A mid-sized 8.9 km^2 catchment shows a Hausdorff distance of 28 m but a symmetric difference of 0.21 km^2, now 2.4% of its area. The smallest, a 0.7 km^2 headwater catchment, shows a Hausdorff distance of only 18 m yet a symmetric difference of 0.09 km^2, a striking 12.9% of its area.

The absolute boundary offset is roughly constant across all three, around 20 to 28 m, which is about two to three cells of the coarse DEM — exactly the migration you expect from resolution alone. What changes dramatically is the relative impact: the same 20 m offset is negligible on a 63 km^2 basin and dominant on a 0.7 km^2 one. This is why the acceptance test scales to each catchment’s own area. Under a flat 5% rule the headwater catchment would be flagged as an error when nothing is actually wrong; under a per-catchment relative rule it is correctly accepted, while a genuinely mislocated divide, which would push the Hausdorff distance well beyond a few cells, still stands out. After snapping the coarse boundaries onto the fine reference at a 30 m tolerance, all four symmetric differences drop below 0.02 km^2, and the enforce_nesting clip confirms every child still sits strictly inside its parent.

Reconciliation parameter reference

Parameter Typical value Effect
snap tolerance_m one to two coarse cell widths larger tolerance pulls more of the coarse divide onto the fine one but risks over-snapping distinct divides
reference resolution finer DEM assumes the fine surface is more accurate; reconsider under heavy canopy noise
relative disagreement flag 2–5% of catchment area per-catchment acceptance bound scaled to size
nesting enforcement intersection with parent guarantees child stays inside parent after snapping

Gotchas & edge cases

  • Snapping with too large a tolerance welds separate divides. A tolerance wider than the spacing between two adjacent ridges can pull a boundary onto the wrong reference divide. Keep the tolerance to roughly the coarse cell size and inspect any catchment whose reconciled area jumps.
  • The fine DEM is not automatically the truth. LiDAR bare-earth errors under dense canopy or over buildings can make a 1 m divide locally worse than a 10 m one. Where the fine surface is noisy, smooth it or fall back to the coarse divide as reference for that reach.
  • Area reconciliation must respect nesting order. Reconcile parents before children, or clip children into freshly reconciled parents, so a child is never checked against a parent boundary that is about to move.
  • Outlet pairing by proximity is fragile. Match coarse and fine catchments by a shared outlet identifier, not by nearest centroid; centroids shift with boundary changes and can mispair adjacent small catchments.

Frequently Asked Questions

Which resolution should be treated as the reference divide?

Treat the finer DEM as the reference because it resolves the divide more accurately, provided it is hydrologically conditioned and free of vegetation or building artifacts. Snap the coarse boundary onto the fine one. The exception is when the fine DEM is noisier than it is accurate, for example bare-earth errors in dense canopy, in which case a smoothed fine divide or the coarse divide may be the better reference.

How much area difference between resolutions is acceptable?

Expect the fine and coarse catchment areas to differ by a fraction of a percent for large basins and by several percent for small headwater catchments, because a fixed boundary uncertainty is a larger share of a small area. Set the acceptance tolerance per catchment relative to its own area rather than as one absolute number, and flag any pair whose symmetric difference exceeds that relative bound.

Why do nested catchments stop nesting after independent delineation at two resolutions?

Each resolution places the shared downstream divide slightly differently, so a fine child catchment can poke outside its coarse parent along the reconciled edge. Synchronizing the shared boundary to a single reference divide restores nesting, and an explicit containment clip guarantees the child never extends beyond its parent after snapping.