Using Slope-Area Thresholds for Channel Initiation

A single accumulation threshold assumes that channels begin at the same contributing area everywhere in a basin. In terrain of uniform relief that is close enough. In a basin that spans steep uplands and a flat valley floor it is wrong in both places at once — over-delineating the flats and missing upland channels — and the usual response, splitting the difference, gets both wrong by less. The slope-area formulation removes the assumption. This guide is part of the stream threshold tuning topic within flow routing and stream network extraction.

Prerequisites

  • A flow accumulation grid and a slope grid from the same conditioned DEM.
  • Ideally a set of mapped channel heads for fitting; without them the exponent must be taken from published values for a comparable landscape.
  • numpy, rasterio, scipy.

Core Technique: A · Sᵏ > C

The criterion replaces A > C with A · S^k > C, where A is specific contributing area, S is local slope and k is the slope exponent. Rearranged, the threshold area at a given slope is C / S^k: steep ground needs less area, flat ground needs more.

What the Slope Term Does to the Threshold A constant threshold is a horizontal line at 500 cells regardless of slope. A slope-area threshold with exponent 1.0 falls from 2400 cells at 2 percent slope to 130 at 40 percent. With exponent 1.7 it falls from 6100 to 60 across the same range. 2 % 6 % 12 % 24 % 40 % local slope at the candidate channel head 50 250 1 200 6 000 threshold area, cells constant threshold — 500 cells k = 1.0 k = 1.7 the three agree only here on flats a constant threshold over-delineates on steep ground it under-delineates

Annotated Code Example

python
import logging

import numpy as np
import rasterio
from scipy import stats

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


def fit_slope_exponent(
    heads_area_cells: np.ndarray,
    heads_slope: np.ndarray,
) -> dict:
    """
    Fit the slope exponent from mapped channel heads.

    At a channel head A·S^k is at its critical value C, so log A = log C − k·log S.
    A linear regression of log A on log S returns −k as the gradient.
    """
    m = (heads_area_cells > 0) & (heads_slope > 1e-4) & np.isfinite(heads_slope)
    if m.sum() < 20:
        raise ValueError(f"only {int(m.sum())} usable channel heads — too few "
                         "to fit an exponent; use a published value instead")

    log_a = np.log10(heads_area_cells[m].astype(float))
    log_s = np.log10(heads_slope[m].astype(float))
    result = stats.linregress(log_s, log_a)

    k = -float(result.slope)
    C = float(10 ** result.intercept)
    log.info("Fitted from %d channel heads: k = %.3f, C = %.1f, r² = %.3f",
             int(m.sum()), k, C, result.rvalue ** 2)
    if result.rvalue ** 2 < 0.3:
        log.warning("r² of %.2f is weak — the heads may span several process "
                    "regimes, or the slope grid may be noisy", result.rvalue ** 2)
    if not 0.5 <= k <= 2.5:
        log.warning("Fitted exponent %.2f is outside the range usually reported "
                    "(1.0–2.0); check the slope units are a gradient, not degrees", k)
    return {"k": k, "C": C, "r_squared": float(result.rvalue ** 2),
            "n_heads": int(m.sum())}


def slope_area_streams(
    facc_path: str,
    slope_path: str,
    out_path: str,
    k: float = 1.7,
    C: float = 500.0,
    min_slope: float = 0.005,
) -> dict:
    """
    Extract a stream mask using a slope-area criterion.

    Parameters
    ----------
    facc_path  : Flow accumulation raster, in cells.
    slope_path : Slope raster as a GRADIENT (rise over run), not degrees.
    out_path    : Destination for the boolean stream mask.
    k, C       : Slope exponent and critical constant.
    min_slope  : Floor applied to slope, so near-zero slopes do not produce an
                 infinite threshold and exclude genuine valley-floor channels.
    """
    with rasterio.open(facc_path) as src:
        acc = src.read(1).astype("float64")
        profile = src.profile.copy()
        nodata = src.nodata
    with rasterio.open(slope_path) as src:
        slope = src.read(1).astype("float64")

    if slope.shape != acc.shape:
        raise ValueError("slope and accumulation grids are not on the same grid")
    if np.nanmax(slope) > 5.0:
        log.warning("Slope maximum is %.1f — this looks like degrees rather "
                    "than a gradient; converting", np.nanmax(slope))
        slope = np.tan(np.radians(slope))

    # --- Floor the slope. Without it a perfectly flat cell gives S^k = 0 and
    # an infinite required area, which deletes valley-floor channels — the
    # opposite of what the method is for. ---
    s = np.clip(slope, min_slope, None)
    metric = acc * np.power(s, k)
    mask = metric >= C

    if nodata is not None:
        mask &= acc != nodata

    n = int(mask.sum())
    constant_equivalent = float(np.median(acc[mask])) if n else float("nan")
    log.info("Slope-area extraction: %d stream cells (%.3f %% of the grid)",
             n, 100.0 * n / mask.size)
    log.info("Median accumulation at extracted cells: %.0f — a constant "
             "threshold of about this value would give a similar total length "
             "but distribute it differently", constant_equivalent)

    # Report how the threshold varies, which is the whole point of the method.
    for lo, hi, label in [(0.0, 0.03, "flat"), (0.03, 0.12, "moderate"),
                          (0.12, 10.0, "steep")]:
        band = (s >= lo) & (s < hi)
        if band.any():
            req = C / np.power(np.median(s[band]), k)
            log.info("  %-8s slope band: required area %.0f cells", label, req)

    profile.update(dtype="uint8", nodata=0, compress="LZW", tiled=True)
    with rasterio.open(out_path, "w", **profile) as dst:
        dst.write(mask.astype("uint8"), 1)
        dst.update_tags(THRESHOLD_METHOD="slope-area", SLOPE_EXPONENT=str(k),
                        CRITICAL_CONSTANT=str(C))
    log.info("Wrote %s", out_path)
    return {"stream_cells": n, "k": k, "C": C, "output": out_path}


# --- Example usage ---
# fit = fit_slope_exponent(head_areas, head_slopes)
# slope_area_streams("acc.tif", "slope.tif", "streams_sa.tif",
#                    k=fit["k"], C=fit["C"])

Parameter Reference

Parameter Typical Notes
k 1.0–2.0 Higher in landslide-dominated steep terrain; fit it where heads are mapped
C Basin-specific Has units that depend on k; never transfer it between basins without refitting
min_slope 0.003–0.01 Prevents flat cells from requiring infinite area
Slope units Gradient Degrees produce an exponent that is wrong by a factor that varies with slope
Slope window 3 × 3 Larger windows smooth away exactly the local steepening that marks a head

Worked Example: Where the Two Methods Disagree

The same 90 km² basin, spanning a steep upland and a flat alluvial valley:

Zone Area share Constant threshold (500 cells) Slope-area (k = 1.7)
Steep upland, S > 15 % 34 % 41 km of channel 68 km
Moderate, 3–15 % 44 % 79 km 74 km
Valley floor, S < 3 % 22 % 52 km 21 km
Total 172 km 163 km

The totals are close, and that is exactly why the constant threshold survives review: the network is the right length. It is in the wrong places — 27 km too short in the uplands and 31 km too long on the valley floor, where the extra channels are agricultural swales and field drains that no geomorphic channel occupies.

Same Total Length, Very Different Places Paired bars of extracted channel length by slope band. In the steep upland the constant threshold gives 41 kilometres against 68 for slope-area. In the moderate band they are close at 79 and 74. On the valley floor the constant threshold gives 52 kilometres against 21. steep, > 15 % 41 km vs 68 km moderate, 3–15 % 79 km vs 74 km valley floor, < 3 % 52 km vs 21 km constant threshold slope-area, k = 1.7 channel km totals: 172 vs 163 km

The fit is only as good as the channel-head set behind it. Plotting the heads in log-log space shows immediately whether one relationship describes them or several processes are mixed together.

Reading the Channel-Head Cloud Before Fitting Channel heads plotted with log slope on the horizontal axis and log contributing area on the vertical. One cloud follows a straight line with a gradient of minus 1.7. A second cloud sits above it at low slope, from seepage-initiated heads that a single fit would distort. log slope at the head log area fitted: gradient −1.7, r² 0.81 a second regime — seepage-initiated heads Fitting one line through both clouds gives an exponent that describes neither.

Gotchas and Edge Cases

  • Slope in degrees. The exponent then applies to a number between 0 and 90 rather than a gradient near zero, and the resulting threshold is wrong by orders of magnitude.
  • No slope floor. Flat cells require an infinite area, so genuine valley-floor channels vanish. This is the failure mode that makes people abandon the method.
  • Transferring C between basins. Its units depend on k, and its value depends on the DEM resolution. Refit both.
  • A smoothed slope grid. Computing slope over a large window removes the local steepening at a channel head, which is precisely the signal the method uses.
  • Fitting on too few heads. Below about twenty, the regression is dominated by scatter and the fitted exponent is unusable.
  • Applying it in genuinely uniform terrain. The slope term barely varies, so the method adds a parameter and changes nothing. A constant threshold, calibrated as described in the parent topic, is the better choice there.