Outlet Point Mapping & Validation

Accurate outlet point mapping is the geometric foundation of any automated watershed modeling pipeline. An outlet point — also called a pour point — is not merely a coordinate pair; it is the hydrologic anchor that dictates drainage direction, catchment extent, and flow accumulation routing for every sub-basin downstream. When outlet locations are misaligned with the DEM pit-filling surface or placed on ephemeral or topologically disconnected stream segments, delineation errors compound rapidly: catchments violate mass conservation, misrepresent contributing areas, and fail regulatory calibration standards.

As part of Watershed Delineation & Catchment Synchronization, this workflow bridges raw coordinate ingestion and hydrologically sound catchment generation. It is a prerequisite for nested catchment delineation, where parent-child relationships collapse when any outlet is positioned outside a continuous flow path, and for basin partitioning strategies that depend on sub-basin boundary alignment with actual topographic divides.


Prerequisites & Environment Setup

Python stack

text
# conda (recommended — ensures GDAL/PROJ binary compatibility)
conda create -n hydro python=3.11
conda activate hydro
conda install -c conda-forge geopandas rasterio shapely scipy pyproj affine richdem

# or pip inside an activated venv
pip install geopandas rasterio shapely scipy pyproj affine
Library Minimum version Role
geopandas 0.14 vector I/O, CRS transforms, spatial join
rasterio 1.3 windowed raster reads, affine transforms
shapely 2.0 geometry construction, nearest-points
scipy 1.11 KD-tree proximity search
pyproj 3.6 explicit CRS definition and epoch handling
affine 2.4 row/col ↔ world-coordinate conversion

Input data specifications

Dataset Format CRS Notes
Hydrologically conditioned DEM GeoTIFF (float32) Projected (UTM / state plane) Sink-filled and optionally stream-burned
Flow accumulation raster GeoTIFF (float64) Same as DEM Cell values = upstream contributing cells
Flow direction raster GeoTIFF (uint8) Same as DEM D8 encoding: 1/2/4/8/16/32/64/128
Stream network GeoPackage or shapefile Same as DEM NHD or locally derived at calibrated threshold
Raw outlet coordinates CSV, GeoJSON, or shapefile Any (re-projected on load) Must include a stable unique outlet_id field

System resources: For DEMs covering a catchment area up to ~5 000 km² at 10 m resolution, the full in-memory approach below requires ≤8 GB RAM. Larger domains should use windowed reads scoped to the bounding box of each outlet batch. Continental-scale jobs benefit from a dask tile-splitting pre-pass before running per-outlet logic.


Algorithm Mechanics

Why raw coordinates do not align with the stream raster

Every outlet coordinate carries positional uncertainty from at least three sources: GPS drift (1–5 m for consumer devices, up to 50 m for legacy gauge records), digitisation error from manual heads-up digitising, and DEM resolution quantisation (a 30 m cell represents 900 m² of terrain). When a coordinate is converted to a raster row/col using the affine inverse transform, it lands on whichever cell contains the point — which is rarely the highest-accumulation cell in the vicinity.

The snapping problem is therefore: given a point P and a search radius r, find the cell within r whose accumulation value is locally maximal and whose flow direction connects to the outlet’s intended drainage basin.

Two-stage snapping geometry

Stage 1 — Euclidean nearest neighbour: use scipy.spatial.KDTree over all rasterised stream-network cell centroids above a minimum accumulation threshold. This narrows candidates from millions of cells to a small neighbourhood in O(log n) time.

Stage 2 — Flow-path refinement: from each candidate cell, trace the D8 flow direction downstream for up to max_trace_steps cells and confirm the path does not enter a no-data zone or loop back on itself. Accept the candidate with the highest accumulation that passes this check. If no candidate passes within the tolerance, execute the upstream-trace fallback described in Step 3 below.

The diagram below shows the decision logic for a single outlet point.

Two-Stage Outlet Point Snapping Decision Logic Decision flowchart: raw coordinate enters CRS check, then Euclidean KD-tree search, then flow-path validity test, branching to accept or upstream-trace fallback, ending at export. Raw outlet coordinate (lat/lon or projected) CRS Standardization reproject → DEM projected CRS Stage 1 — KD-tree search stream cells within tolerance radius Candidates found? Yes Stage 2 — Flow-path check trace D8 downstream; no sink/loop No Upstream-trace fallback Export validated outlet + audit record

Flow direction encoding

The D8 encoding used by most richdem and pysheds outputs assigns a power-of-two integer to each of the eight cardinal and diagonal directions:

Direction N NE E SE S SW W NW
Encoded value 64 128 1 2 4 8 16 32

Knowing the encoding lets you walk the flow grid programmatically: given a cell at (row, col) with direction value d, the downstream neighbour is at (row + dr[d], col + dc[d]) where dr and dc are direction lookup tables.


Step-by-Step Workflow

Step 1 — CRS standardization and spatial indexing

python
import logging
import geopandas as gpd
import rasterio
from pyproj import CRS

logger = logging.getLogger(__name__)

def load_and_align_inputs(outlet_path: str, stream_path: str, dem_path: str):
    """
    Load outlet points and stream network; reproject both to the DEM's CRS.
    Returns aligned GeoDataFrames and the open rasterio dataset for the DEM.
    """
    with rasterio.open(dem_path) as dem_ds:
        dem_crs = CRS.from_user_input(dem_ds.crs)
        logger.info("DEM CRS: %s  |  bounds: %s", dem_crs.to_epsg(), dem_ds.bounds)

    outlets = gpd.read_file(outlet_path)
    streams = gpd.read_file(stream_path)

    # Reproject to the DEM's projected CRS (required for metric distance calculations)
    outlets = outlets.to_crs(dem_crs)
    streams = streams.to_crs(dem_crs)
    logger.info("Loaded %d outlets and %d stream features; both reprojected to EPSG:%s",
                len(outlets), len(streams), dem_crs.to_epsg())

    # Build R-tree spatial index on stream geometries for fast proximity queries
    _ = streams.sindex  # geopandas builds this lazily; trigger it now
    logger.info("Stream spatial index built.")
    return outlets, streams

CRS alignment is non-negotiable. All vector and raster inputs must share a projected CRS before any distance or raster-sampling operation. Geographic coordinates (WGS 84) introduce metric distortion — a 1° longitude offset at 40°N represents ~85 km, not the same arc as at the equator.

Step 2 — Building the stream-cell KD-tree

Rather than querying against stream line geometries directly, rasterise the accumulation grid to extract cell centroids above the minimum threshold. This gives a dense, evenly spaced point cloud that the KD-tree handles efficiently.

python
import numpy as np
from scipy.spatial import KDTree
import rasterio
from rasterio.transform import xy

def build_stream_kdtree(acc_path: str, min_accumulation: float):
    """
    Extract centroids of all accumulation cells exceeding min_accumulation
    and index them in a KD-tree for fast nearest-neighbour lookup.
    Returns (kdtree, world_coords array of shape [N, 2]).
    """
    with rasterio.open(acc_path) as acc_ds:
        acc = acc_ds.read(1)
        transform = acc_ds.transform
        nodata = acc_ds.nodata

    # Mask out nodata and sub-threshold cells
    valid_mask = (acc >= min_accumulation)
    if nodata is not None:
        valid_mask &= (acc != nodata)

    rows, cols = np.where(valid_mask)
    # Convert row/col indices to projected world coordinates (cell centres)
    xs, ys = xy(transform, rows, cols)
    coords = np.column_stack([xs, ys])

    tree = KDTree(coords)
    logger.info(
        "KD-tree built from %d stream cells (accumulation >= %.0f).",
        len(coords), min_accumulation
    )
    return tree, coords

Step 3 — Hydrologic snapping with upstream-trace fallback

python
from shapely.geometry import Point

# D8 direction → (delta_row, delta_col) lookup
D8_OFFSETS = {1: (0, 1), 2: (1, 1), 4: (1, 0), 8: (1, -1),
              16: (0, -1), 32: (-1, -1), 64: (-1, 0), 128: (-1, 1)}

def snap_outlet(outlet_xy, tree: KDTree, stream_coords,
                acc_arr, fdir_arr, transform,
                tolerance_m: float, min_accumulation: float,
                max_trace: int = 200):
    """
    Snap a single outlet (x, y in projected metres) to the nearest valid
    stream cell. Falls back to upstream trace when no candidate is found
    within tolerance_m.

    Returns dict with snapped coords, accumulation, snap_distance, method.
    """
    result = {"original_xy": outlet_xy, "snapped_xy": None,
              "accumulation": None, "snap_distance_m": None, "method": None}

    # --- Stage 1: KD-tree nearest neighbour within tolerance ---
    idxs = tree.query_ball_point(outlet_xy, r=tolerance_m)
    if idxs:
        # Pick the candidate with the highest accumulation value
        candidates = stream_coords[idxs]
        # Sample accumulation at each candidate
        acc_vals = [acc_arr[int(round((transform[5] - y) / (-transform[4]))),
                             int(round((x - transform[2]) / transform[0]))]
                    for x, y in candidates]
        best_idx = int(np.argmax(acc_vals))
        snapped_xy = tuple(candidates[best_idx])
        snap_dist = float(np.linalg.norm(np.array(snapped_xy) - np.array(outlet_xy)))

        # --- Stage 2: flow-path validity check ---
        row = int(round((transform[5] - snapped_xy[1]) / (-transform[4])))
        col = int(round((snapped_xy[0] - transform[2]) / transform[0]))
        if _flow_path_is_valid(fdir_arr, row, col, max_trace):
            result.update(snapped_xy=snapped_xy, accumulation=float(acc_vals[best_idx]),
                          snap_distance_m=snap_dist, method="stage1_kdtree")
            logger.debug("Outlet snapped via KD-tree: dist=%.1f m, acc=%.0f",
                         snap_dist, acc_vals[best_idx])
            return result

    # --- Fallback: trace upstream from raw cell to first qualifying cell ---
    raw_row = int(round((transform[5] - outlet_xy[1]) / (-transform[4])))
    raw_col = int(round((outlet_xy[0] - transform[2]) / transform[0]))
    snapped_xy, acc_val = _upstream_trace_fallback(
        acc_arr, fdir_arr, raw_row, raw_col, min_accumulation, max_trace, transform)

    if snapped_xy:
        snap_dist = float(np.linalg.norm(np.array(snapped_xy) - np.array(outlet_xy)))
        result.update(snapped_xy=snapped_xy, accumulation=acc_val,
                      snap_distance_m=snap_dist, method="upstream_trace_fallback")
        logger.warning("Outlet used fallback trace: dist=%.1f m, acc=%.0f", snap_dist, acc_val)
    else:
        logger.error("Could not snap outlet at %s — no valid stream cell found.", outlet_xy)

    return result


def _flow_path_is_valid(fdir_arr, row, col, max_steps):
    """Trace D8 downstream; return True if path exits without hitting sink or loop."""
    visited = set()
    for _ in range(max_steps):
        cell = (row, col)
        if cell in visited:
            return False  # loop detected
        visited.add(cell)
        d = int(fdir_arr[row, col])
        if d not in D8_OFFSETS:
            return False  # sink or nodata
        dr, dc = D8_OFFSETS[d]
        row += dr
        col += dc
        if row < 0 or col < 0 or row >= fdir_arr.shape[0] or col >= fdir_arr.shape[1]:
            return True  # reached raster edge — valid drainage outlet
    return True  # trace exhausted without finding a sink


def _upstream_trace_fallback(acc_arr, fdir_arr, row, col,
                              min_acc, max_steps, transform):
    """
    Walk upstream (reverse D8 directions) from raw cell until an accumulation-
    qualifying cell is found. Returns (world_xy, acc_value) or (None, None).
    """
    REVERSE_D8 = {v: k for k, v in D8_OFFSETS.items()}
    # Invert: we need cells whose downstream neighbour is the current cell
    # Simple approach: search a small window for cells flowing into (row, col)
    for step in range(max_steps):
        if acc_arr[row, col] >= min_acc:
            from rasterio.transform import xy as _xy
            wx, wy = _xy(transform, row, col)
            return (wx, wy), float(acc_arr[row, col])
        # Move to the neighbour with the highest accumulation
        best_acc, best_row, best_col = -1, row, col
        for dr, dc in D8_OFFSETS.values():
            nr, nc = row + dr, col + dc
            if 0 <= nr < acc_arr.shape[0] and 0 <= nc < acc_arr.shape[1]:
                if acc_arr[nr, nc] > best_acc:
                    best_acc, best_row, best_col = acc_arr[nr, nc], nr, nc
        if best_row == row and best_col == col:
            break  # no improvement — genuine flat/sink area
        row, col = best_row, best_col
    return None, None

Step 4 — Topological consistency check

Spatial proximity does not guarantee hydrologic connectivity. Two outlets can be 5 m apart yet drain to opposite sides of a divide if the DEM has a ridge between them. This check confirms each snapped outlet participates in a continuous downstream flow path.

python
def check_topological_consistency(snapped_results: list, fdir_arr, acc_arr,
                                   transform, max_trace: int = 500):
    """
    For each snapped result, verify the flow path reaches the raster edge
    (or a downstream outlet) without entering a sink. Flags inconsistent records.
    """
    flagged = []
    for rec in snapped_results:
        if rec["snapped_xy"] is None:
            rec["topology_valid"] = False
            flagged.append(rec["original_xy"])
            continue

        x, y = rec["snapped_xy"]
        row = int(round((transform[5] - y) / (-transform[4])))
        col = int(round((x - transform[2]) / transform[0]))
        valid = _flow_path_is_valid(fdir_arr, row, col, max_trace)
        rec["topology_valid"] = valid
        if not valid:
            flagged.append(rec["original_xy"])
            logger.warning("Topological inconsistency at snapped point %s", rec["snapped_xy"])

    logger.info("Topology check complete. %d / %d outlets flagged.",
                len(flagged), len(snapped_results))
    return snapped_results, flagged

This step is critical before passing outlets to boundary topology validation, where geometric defects in catchment polygons are traced back to inconsistent outlet placement.


Production-Ready Code

The function below integrates all steps, wraps them with logging and error handling, and writes a structured GeoJSON output with full audit metadata.

python
import json
import logging
from datetime import datetime, timezone
from pathlib import Path

import geopandas as gpd
import numpy as np
import rasterio
from rasterio.transform import xy as rasterio_xy
from scipy.spatial import KDTree
from shapely.geometry import Point

logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO,
                    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")


def validate_outlet_points(
    outlet_path: str,
    dem_path: str,
    acc_path: str,
    fdir_path: str,
    stream_path: str,
    output_path: str,
    tolerance_m: float = 90.0,
    min_accumulation: float = 1000.0,
    max_trace: int = 300,
) -> gpd.GeoDataFrame:
    """
    Full outlet-point validation pipeline.

    Parameters
    ----------
    outlet_path     : Path to raw outlet points (CSV / GeoJSON / shapefile).
    dem_path        : Hydrologically conditioned DEM GeoTIFF.
    acc_path        : Flow-accumulation GeoTIFF (float64, same grid as DEM).
    fdir_path       : D8 flow-direction GeoTIFF (uint8, power-of-two encoding).
    stream_path     : Vector stream network (GeoPackage or shapefile).
    output_path     : Output GeoJSON path for validated outlets.
    tolerance_m     : Maximum snapping distance in projected metres.
    min_accumulation: Minimum upstream cell count to qualify as a stream cell.
    max_trace       : Maximum D8 steps for flow-path and fallback traces.

    Returns
    -------
    GeoDataFrame of validated outlets with audit columns.
    """
    logger.info("=== Outlet Validation Pipeline START ===")
    logger.info("Inputs: outlets=%s  acc_threshold=%.0f  tolerance=%.0fm",
                outlet_path, min_accumulation, tolerance_m)

    # 1. Load and align inputs
    with rasterio.open(dem_path) as dem_ds:
        dem_crs = dem_ds.crs
        dem_transform = dem_ds.transform

    outlets_gdf = gpd.read_file(outlet_path).to_crs(dem_crs)
    if "outlet_id" not in outlets_gdf.columns:
        outlets_gdf["outlet_id"] = outlets_gdf.index.astype(str)
        logger.warning("No 'outlet_id' column found; using row index as identifier.")

    # 2. Load raster arrays (windowed reads are viable for large DEMs; full read here)
    with rasterio.open(acc_path) as acc_ds:
        acc_arr = acc_ds.read(1).astype(np.float64)
        acc_transform = acc_ds.transform
        acc_nodata = acc_ds.nodata

    with rasterio.open(fdir_path) as fdir_ds:
        fdir_arr = fdir_ds.read(1).astype(np.int32)

    # 3. Build KD-tree from stream cells above threshold
    valid_mask = acc_arr >= min_accumulation
    if acc_nodata is not None:
        valid_mask &= acc_arr != acc_nodata
    rows_valid, cols_valid = np.where(valid_mask)
    xs, ys = rasterio_xy(acc_transform, rows_valid, cols_valid)
    stream_coords = np.column_stack([xs, ys])
    tree = KDTree(stream_coords)
    logger.info("Stream KD-tree: %d qualifying cells.", len(stream_coords))

    # 4. Snap each outlet
    results = []
    for _, row_data in outlets_gdf.iterrows():
        oid = row_data["outlet_id"]
        geom = row_data.geometry
        outlet_xy = (geom.x, geom.y)
        snap_result = snap_outlet(
            outlet_xy, tree, stream_coords, acc_arr, fdir_arr,
            acc_transform, tolerance_m, min_accumulation, max_trace
        )
        snap_result["outlet_id"] = oid
        results.append(snap_result)

    # 5. Topological consistency
    results, flagged = check_topological_consistency(
        results, fdir_arr, acc_arr, acc_transform, max_trace
    )

    # 6. Assemble output GeoDataFrame
    rows_out = []
    for rec in results:
        if rec["snapped_xy"] is None:
            geom_out = None
        else:
            geom_out = Point(rec["snapped_xy"])
        rows_out.append({
            "outlet_id": rec["outlet_id"],
            "geometry": geom_out,
            "original_x": rec["original_xy"][0],
            "original_y": rec["original_xy"][1],
            "snap_distance_m": rec.get("snap_distance_m"),
            "accumulation": rec.get("accumulation"),
            "snap_method": rec.get("method"),
            "topology_valid": rec.get("topology_valid", False),
            "validated_at": datetime.now(timezone.utc).isoformat(),
            "tolerance_m": tolerance_m,
            "min_accumulation": min_accumulation,
        })

    out_gdf = gpd.GeoDataFrame(rows_out, crs=dem_crs)
    valid_count = out_gdf["topology_valid"].sum()
    logger.info("Validation complete: %d / %d outlets fully valid.",
                valid_count, len(out_gdf))

    # 7. Export
    out_gdf.to_file(output_path, driver="GeoJSON")
    logger.info("Validated outlets written to %s", output_path)
    logger.info("=== Outlet Validation Pipeline END ===")
    return out_gdf


# Example invocation
if __name__ == "__main__":
    result = validate_outlet_points(
        outlet_path="data/raw_gauges.geojson",
        dem_path="data/dem_conditioned.tif",
        acc_path="data/flow_accumulation.tif",
        fdir_path="data/flow_direction_d8.tif",
        stream_path="data/nhd_streams.gpkg",
        output_path="outputs/validated_outlets.geojson",
        tolerance_m=90.0,
        min_accumulation=1000.0,
    )
    print(result[["outlet_id", "snap_distance_m", "accumulation", "topology_valid"]])

Validation Protocol

After running the pipeline, apply these checks before passing validated outlets to downstream delineation:

1. Displacement histogram — Plot the distribution of snap_distance_m. Values beyond 2× the DEM resolution (e.g., >60 m for a 30 m DEM) warrant manual inspection. A long right tail suggests systematic GPS offset in the source data.

python
import matplotlib.pyplot as plt
out_gdf["snap_distance_m"].hist(bins=40)
plt.axvline(90, color="red", linestyle="--", label="Tolerance boundary")
plt.xlabel("Snap distance (m)")
plt.title("Outlet snap displacement distribution")
plt.legend()
plt.tight_layout()
plt.savefig("outputs/snap_displacement.png", dpi=150)

2. Accumulation quantile check — At least 90 % of validated outlets should have accumulation above the median threshold for the region. Outlets consistently landing near the minimum threshold suggest the stream network is under-dense or the tolerance is too large.

3. Overlay against NHD — Load the USGS National Hydrography Dataset and buffer each validated outlet by 1× cell width. Every snapped point should fall within a buffered NHD reach. Record mismatches as potential gauge-network mismatches rather than pipeline errors.

4. Topology flag ratetopology_valid == False should be below 2 % in well-conditioned datasets. Rates above 5 % indicate DEM conditioning problems: flat areas, widespread sinks, or resolution mismatches between the DEM and stream network. Revisit DEM pit-filling algorithms and flat-area resolution before rerunning.

5. Round-trip delineation spot check — For a random 5 % sample, run a full watershed delineation using each validated outlet and inspect the resulting catchment polygon for geographic plausibility. Anomalously small or large catchments (>3 standard deviations from the regional mean) suggest lingering snapping errors.


Common Failure Modes & Optimization

Flat-area stagnation. When the DEM contains extended flat regions — wetlands, lake beds, coastal plains — the D8 flow direction grid assigns arbitrary or zero-valued directions to entire zones. Outlets snapped into these zones pass the accumulation check but fail the topology test. Mitigation: apply an imposed gradient (epsilon surface) or use a priority-flood DEM conditioning algorithm before computing flow direction. See the spatial resolution tradeoffs page for conditioning strategies that preserve hydrologic connectivity across resolution transitions.

Projection artefacts. CRS mismatches between the outlet CSV and the DEM produce snapping offsets that scale with latitude. A WGS 84 outlet at 45°N snapped against a UTM raster without reprojection can be displaced by >70 km. Always assert CRS equality after to_crs() using assert outlets_gdf.crs == streams_gdf.crs.

Memory exhaustion on large DEMs. Loading a continental-scale 10 m DEM into acc_arr requires >80 GB RAM. Use rasterio windowed reads scoped to the bounding box of each outlet batch (expand by tolerance_m on all sides) and process in spatial tiles. The KD-tree step only needs to cover the tile’s extent.

Duplicate outlet IDs. When the input CSV contains duplicate outlet_id values — common in gauge networks that list the same station at multiple rating periods — the downstream delineation pipeline will produce overlapping catchments. Add a de-duplication step immediately after loading:

python
n_before = len(outlets_gdf)
outlets_gdf = outlets_gdf.drop_duplicates(subset=["outlet_id"], keep="first")
logger.info("Dropped %d duplicate outlet IDs.", n_before - len(outlets_gdf))

Karst and endorheic terrain. In karst landscapes and closed basins, the flow direction grid may route water to internal sinks by design. The topology check flags these as invalid, but they are correct for the terrain type. Add a terrain_type field to the outlet registry and skip the downstream topology test for outlets classified as endorheic:

python
if rec.get("terrain_type") == "endorheic":
    rec["topology_valid"] = True  # internal drainage is expected
    logger.info("Outlet %s classified endorheic; topology check skipped.", rec["outlet_id"])

GDAL directory scanning overhead. On network file systems or large tile collections, rasterio.open() triggers directory enumeration that can add seconds per file. Set GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR before importing rasterio:

python
import os
os.environ["GDAL_DISABLE_READDIR_ON_OPEN"] = "EMPTY_DIR"
import rasterio

When to Use This vs. Alternatives

Scenario Recommended approach
Gauge stations with ≤5 m GPS accuracy on 10 m DEM This workflow — stage-1 KD-tree sufficient; set tolerance_m=30
Historical records digitised from paper maps (±100 m uncertainty) This workflow with tolerance_m=300 and manual review of all snap_distance_m > 100
Outlets derived programmatically from NHD reaches Skip snapping; assign outlet to the downstream node of the NHD reach directly
Outlets in karst or endorheic catchments This workflow plus endorheic flag; disable topology check for flagged outlets
Continental-scale batch with >10 000 outlets Tile the DEM and acc rasters first; run this pipeline per tile in parallel
Divergent hillslope terrain requiring multi-directional routing Use D-Infinity routing patterns for flow direction before running this workflow

For coarser or legacy 30 m data, the simpler snapping logic in automating outlet point snapping to stream networks in Python covers the common case without the upstream-trace fallback.


Frequently Asked Questions

What snapping tolerance should I use for outlet points?

Start at 1–3 DEM cell widths (30–90 m for 30 m DEMs). Increase only when GPS positional accuracy is poor or gauge records explicitly note a location offset. Always log the original vs. snapped distance so reviewers can flag large jumps.

How do I choose a flow accumulation threshold?

Regional calibration against NHD or a locally digitised stream network works best. As a starting point, 1 000 cells for a 30 m DEM captures perennial channels in humid regions; arid catchments often need 5 000–10 000 cells. The stream threshold tuning page covers calibration methods in detail.

Why does my outlet land on a non-flowing cell after snapping?

Flat-area artefacts in the flow direction grid or an unconditioned DEM are the most common causes. Ensure the DEM has been sink-filled and that flat regions have been resolved with an imposed gradient before computing flow direction.

Can I validate multiple outlet points in one pass?

Yes. Build the spatial index and open raster datasets once, then iterate over all outlets in a vectorised loop. Windowed rasterio reads scoped to the bounding box of each outlet keep memory usage flat regardless of DEM size.