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
# 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.
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
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.
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
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.
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.
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.
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 rate — topology_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:
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:
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:
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.
Related Topics
- Watershed Delineation & Catchment Synchronization — parent workflow covering the full DEM-to-boundary pipeline
- Nested Catchment Delineation — hierarchical sub-basin extraction that depends on correctly placed outlet points
- Basin Partitioning Strategies — sub-catchment decomposition downstream of validated outlets
- Boundary Topology Validation — geometric QA for catchment polygons generated from snapped outlets
- Stream Threshold Tuning — calibrating the accumulation threshold that determines which cells qualify as stream cells
- Automating Outlet Point Snapping to Stream Networks in Python — focused implementation guide for the snapping step