Multiple Flow Direction Methods in Python
Distributed watershed models and soil-moisture mapping tasks require flow routing that respects the diffuse, branching character of real overland flow — something a deterministic single-cell algorithm cannot capture. Multiple Flow Direction (MFD) routing solves this by partitioning each cell’s contributing area across all downslope neighbours in proportion to their slope gradients, producing a dense accumulation surface rather than a network of isolated channels.
As part of Flow Routing Algorithms & Stream Network Extraction, MFD sits between the simplest option — D8 flow direction implementation, which forces every cell into one downslope neighbour — and the facet-based D-Infinity routing patterns, which split flow across two adjacent facets. When your terrain contains broad convex hillslopes, wetland complexes, or alluvial fans where convergent flow paths are ambiguous, MFD is the physically grounded choice.
The diagram below shows how the three routing families partition flow from a single source cell. MFD sends weighted fractions to every lower neighbour; D8 commits entirely to the steepest; D-Infinity splits between two facet vectors.
Prerequisites & Environment Setup
MFD is sensitive to DEM quality. Artificial depressions, flat areas, and coordinate-system mismatches all corrupt proportion calculations before a single flow weight is written.
Python stack (exact versions):
pip install richdem>=0.3.4 rasterio>=1.3.0 numpy>=1.24.0 scipy>=1.10.0
or via conda:
conda install -c conda-forge richdem rasterio numpy scipy
Input data requirements:
| Property | Requirement |
|---|---|
| Format | Single-band GeoTIFF, float32 or float64 |
| CRS | Projected (metric), e.g. UTM or local state plane |
| NoData | Explicit nodata value registered in raster metadata |
| Resolution | Uniform square pixels (non-square distorts diagonal distance) |
| Coverage | No voids inside the area of interest; clip to valid extent first |
Reproject angular coordinates to a metric CRS before any slope computation — see coordinate reference system alignment for misalignment diagnosis and reprojection patterns. Apply DEM pit filling to eliminate depressions that would trap and terminate flow proportion chains.
System resources: The sparse proportion matrix uses approximately 8 × N bytes per nonzero entry, where N is the cell count. A 10 000 × 10 000 DEM (~100 M cells, up to 800 M nonzeros) requires 6–8 GB RAM for in-core direct solving.
Algorithm Mechanics
The Freeman / Quinn Proportion Formula
MFD distributes each cell’s incoming flow across every downslope (8-connected) neighbour. The proportion assigned to neighbour j from source cell i is:
f_ij = (tan β_ij)^p / Σ_k (tan β_ik)^p
where β_ij is the slope angle toward neighbour j (positive for downslope), p is the dispersion exponent, and the sum runs over all neighbours k with positive gradient.
Parameter Reference
| Parameter | Typical range | Effect |
|---|---|---|
p = 1.0 |
Quinn et al. 1991 default | Linear proportional split; maximum dispersion |
p = 1.1–1.3 |
Convergent hillslopes | Slight concentration, reduces fan artefacts |
p → ∞ |
Theoretical limit | Approaches D8 (single steepest neighbour) |
cell_size |
metres (match CRS) | Scales diagonal distances by √2; must be accurate |
Neighbour Distance Correction
Diagonal neighbours are √2 × cell_size apart. Failing to apply this correction over-weights diagonal flow paths, biasing accumulation toward 45° angles. The implementation below applies per-direction distance scaling before computing tan β.
Edge and Flat Handling
Boundary cells lack full 8-neighbourhoods. The safe approach is to restrict slices rather than pad with zeros — zero-padding creates artificial sinks at the DEM edge. Flat cells (zero gradient to all neighbours) receive a proportion of 1/N_lower across all valid lower cells after ResolveFlats assigns micro-gradients; without flat resolution they stall flow entirely. See removing flat area artefacts from flow direction grids for the richdem routine and its parameters.
Step-by-Step Workflow
The five-step pipeline below moves from raw elevation data to a validated accumulation surface. Each stage feeds directly into the next — conditioning errors compound, so do not skip Steps 1 and 2.
Step 1 — Load and validate the DEM
import logging
import rasterio
import numpy as np
logger = logging.getLogger(__name__)
def load_dem(path: str) -> tuple[np.ndarray, np.ndarray, dict]:
"""Read a single-band elevation raster and return (dem, valid_mask, src_meta)."""
with rasterio.open(path) as src:
if src.count != 1:
raise ValueError(f"Expected single-band DEM, got {src.count} bands: {path}")
dem = src.read(1).astype(np.float64)
nodata = src.nodata
if nodata is not None:
dem[dem == nodata] = np.nan
mask = np.isfinite(dem)
n_valid = int(mask.sum())
if n_valid == 0:
raise ValueError(f"DEM contains no finite values: {path}")
logger.info("Loaded DEM %s — shape %s, %d valid cells", path, dem.shape, n_valid)
return dem, mask, src.meta.copy()
Step 2 — Condition: fill depressions and resolve flats
Priority-flood depression filling and flat resolution are both mandatory before proportion computation. Using richdem’s FillDepressions followed by ResolveFlats handles both in two calls.
import richdem as rd
def condition_dem(dem: np.ndarray) -> np.ndarray:
"""Apply priority-flood fill then flat resolution. Returns conditioned float64 array."""
rd_dem = rd.rdarray(dem, no_data=np.nan)
filled = rd.FillDepressions(rd_dem, epsilon=False, in_place=False)
logger.info("Depression filling complete")
resolved = rd.ResolveFlats(filled, in_place=False)
logger.info("Flat resolution complete")
return np.array(resolved, dtype=np.float64)
The epsilon=False flag uses exact priority-flood without the small epsilon increment — appropriate when the downstream export step handles slope proportion normalisation. Switch to epsilon=True if you observe residual flat-area stagnation after resolution.
Step 3 — Build the sparse proportion matrix
This is the computational core. The function iterates over eight direction offsets, computes downslope tangent slopes, applies the Freeman exponent, and assembles a coordinate-list sparse matrix that is then normalised row-wise.
from scipy import sparse
def compute_mfd_proportions(
dem: np.ndarray,
cell_size: float = 1.0,
p: float = 1.0
) -> sparse.csr_matrix:
"""
Build the MFD proportion matrix P where P[i,j] is the fraction of flow
sent from cell i to cell j. All entries ≥ 0; each row sums to ≤ 1.0
(rows for sink cells sum to 0).
"""
rows, cols = dem.shape
n_cells = rows * cols
cell_ids = np.arange(n_cells).reshape(rows, cols)
# 8-connected direction offsets and their Euclidean distances
directions = [
((-1, -1), cell_size * np.sqrt(2)),
((-1, 0), cell_size),
((-1, 1), cell_size * np.sqrt(2)),
(( 0, -1), cell_size),
(( 0, 1), cell_size),
(( 1, -1), cell_size * np.sqrt(2)),
(( 1, 0), cell_size),
(( 1, 1), cell_size * np.sqrt(2)),
]
row_idx, col_idx, weights = [], [], []
for (dr, dc), dist in directions:
# Slice source and target windows to avoid boundary padding
r_src = slice(max(0, -dr), min(rows, rows - dr))
c_src = slice(max(0, -dc), min(cols, cols - dc))
r_tgt = slice(max(0, dr), min(rows, rows + dr))
c_tgt = slice(max(0, dc), min(cols, cols + dc))
# Positive dz → neighbour is lower → valid downslope
dz = dem[r_src, c_src] - dem[r_tgt, c_tgt]
downslope = (dz > 0) & np.isfinite(dz)
if not np.any(downslope):
continue
tan_beta = dz[downslope] / dist
w = np.power(tan_beta, p)
src_ids = cell_ids[r_src, c_src][downslope].ravel()
tgt_ids = cell_ids[r_tgt, c_tgt][downslope].ravel()
row_idx.extend(src_ids)
col_idx.extend(tgt_ids)
weights.extend(w)
P = sparse.coo_matrix(
(weights, (row_idx, col_idx)), shape=(n_cells, n_cells)
).tocsr()
# Normalise each row so fractions sum to 1.0
row_sums = np.array(P.sum(axis=1)).ravel()
row_sums[row_sums == 0.0] = 1.0 # sink cells: avoid division by zero
P = sparse.diags(1.0 / row_sums, format="csr") @ P
nnz = P.nnz
logger.info("Proportion matrix built — %d cells, %d nonzeros, p=%.2f", n_cells, nnz, p)
return P
Step 4 — Solve flow accumulation
The accumulation vector A satisfies (I − P) A = 1: each cell contributes one unit of water, and accumulation propagates via the proportion matrix. scipy.sparse.linalg.spsolve factorises (I − P) directly.
def compute_accumulation(P: sparse.csr_matrix, shape: tuple[int, int]) -> np.ndarray:
"""
Solve (I - P) A = 1 for flow accumulation A.
Raises RuntimeError if the linear system is singular (residual sinks present).
"""
n = P.shape[0]
M = sparse.eye(n, format="csr") - P
ones = np.ones(n)
try:
A = sparse.linalg.spsolve(M, ones)
except Exception as exc:
raise RuntimeError(
"Accumulation solver failed — check for residual sinks or "
"disconnected DEM components. Consider re-running condition_dem()."
) from exc
if not np.all(np.isfinite(A)):
raise RuntimeError("Solver returned non-finite values — singular system.")
logger.info(
"Accumulation solved — min=%.1f, max=%.1f, mean=%.1f",
float(A.min()), float(A.max()), float(A.mean())
)
return A.reshape(shape)
Step 5 — Export and validate
def export_accumulation(
out_path: str,
acc: np.ndarray,
mask: np.ndarray,
src_meta: dict,
) -> None:
"""Write accumulation GeoTIFF and run mass-conservation check."""
out = acc.copy()
out[~mask] = np.nan
total = float(out[mask].sum())
expected = float(mask.sum())
ratio = total / expected if expected > 0 else 0.0
if not np.isclose(ratio, 1.0, rtol=1e-3):
logger.warning(
"Mass conservation check failed: got %.3f, expected 1.000 (ratio=%.6f). "
"Likely cause: unresolved sinks or NaN leakage.",
total / expected, ratio
)
else:
logger.info("Mass conservation OK (ratio=%.6f)", ratio)
meta = src_meta.copy()
meta.update(dtype="float64", count=1, nodata=np.nan)
with rasterio.open(out_path, "w", **meta) as dst:
dst.write(out, 1)
logger.info("Accumulation raster written to %s", out_path)
Production-Ready Code
The function below integrates every step into a single callable with structured logging, parameter validation, and metadata preservation. Drop it into any automated preprocessing pipeline.
import logging
import numpy as np
import rasterio
import richdem as rd
from scipy import sparse
logger = logging.getLogger(__name__)
def run_mfd_accumulation(
dem_path: str,
out_path: str,
cell_size: float | None = None,
p: float = 1.0,
) -> np.ndarray:
"""
End-to-end MFD flow accumulation from a raw DEM.
Parameters
----------
dem_path : Path to input single-band elevation GeoTIFF.
out_path : Destination path for the accumulation GeoTIFF.
cell_size : Pixel size in metres. Inferred from raster metadata if None.
p : Freeman dispersion exponent. 1.0 = Quinn formulation (max dispersion).
Increase toward 1.3 on convergent hillslopes.
Returns
-------
acc : 2-D float64 array of flow accumulation values (NaN outside valid area).
"""
logger.info("=== MFD accumulation starting: %s", dem_path)
# --- Load ---
with rasterio.open(dem_path) as src:
if src.count != 1:
raise ValueError(f"Expected single-band DEM, got {src.count} bands.")
if cell_size is None:
cell_size = abs(src.res[0])
logger.info("Inferred cell size: %.4f m", cell_size)
meta = src.meta.copy()
dem_raw = src.read(1).astype(np.float64)
if src.nodata is not None:
dem_raw[dem_raw == src.nodata] = np.nan
mask = np.isfinite(dem_raw)
n_valid = int(mask.sum())
if n_valid == 0:
raise ValueError("DEM contains no finite values.")
logger.info("Valid cells: %d of %d", n_valid, dem_raw.size)
# --- Condition ---
rd_dem = rd.rdarray(dem_raw, no_data=np.nan)
filled = rd.FillDepressions(rd_dem, epsilon=False, in_place=False)
conditioned = np.array(rd.ResolveFlats(filled, in_place=False), dtype=np.float64)
logger.info("DEM conditioning complete")
# --- Proportion matrix ---
rows, cols = conditioned.shape
n_cells = rows * cols
cell_ids = np.arange(n_cells).reshape(rows, cols)
directions = [
((-1, -1), cell_size * np.sqrt(2)),
((-1, 0), cell_size),
((-1, 1), cell_size * np.sqrt(2)),
(( 0, -1), cell_size),
(( 0, 1), cell_size),
(( 1, -1), cell_size * np.sqrt(2)),
(( 1, 0), cell_size),
(( 1, 1), cell_size * np.sqrt(2)),
]
row_idx, col_idx, wts = [], [], []
for (dr, dc), dist in directions:
r_s = slice(max(0, -dr), min(rows, rows - dr))
c_s = slice(max(0, -dc), min(cols, cols - dc))
r_t = slice(max(0, dr), min(rows, rows + dr))
c_t = slice(max(0, dc), min(cols, cols + dc))
dz = conditioned[r_s, c_s] - conditioned[r_t, c_t]
down = (dz > 0) & np.isfinite(dz)
if not np.any(down):
continue
row_idx.extend(cell_ids[r_s, c_s][down].ravel())
col_idx.extend(cell_ids[r_t, c_t][down].ravel())
wts.extend(np.power(dz[down] / dist, p))
P = sparse.coo_matrix((wts, (row_idx, col_idx)), shape=(n_cells, n_cells)).tocsr()
rs = np.array(P.sum(axis=1)).ravel()
rs[rs == 0.0] = 1.0
P = sparse.diags(1.0 / rs, format="csr") @ P
logger.info("Proportion matrix: %d nonzeros (p=%.2f)", P.nnz, p)
# --- Solve ---
M = sparse.eye(n_cells, format="csr") - P
try:
A = sparse.linalg.spsolve(M, np.ones(n_cells))
except Exception as exc:
raise RuntimeError("Sparse solve failed — residual sinks suspected.") from exc
if not np.all(np.isfinite(A)):
raise RuntimeError("Solver returned non-finite values — singular system.")
acc = A.reshape(rows, cols)
logger.info("Accumulation: max=%.1f cells", float(acc[mask].max()))
# --- Export ---
out = acc.copy()
out[~mask] = np.nan
ratio = float(out[mask].sum()) / n_valid
if not np.isclose(ratio, 1.0, rtol=1e-3):
logger.warning("Mass conservation ratio=%.6f (expected 1.0)", ratio)
meta.update(dtype="float64", count=1, nodata=np.nan)
with rasterio.open(out_path, "w", **meta) as dst:
dst.write(out, 1)
logger.info("Output written: %s", out_path)
return out
Validation Protocol
Run these checks immediately after run_mfd_accumulation before using the accumulation surface for channel extraction or model calibration.
1. Mass conservation — the sum of all valid accumulation cells must equal the count of valid DEM cells within 0.1 %. The production function logs this as a ratio; values below 0.999 indicate unresolved sinks.
2. Maximum accumulation location — the cell with the highest accumulation value should correspond to the DEM outlet (lowest valid perimeter cell). Verify with:
max_loc = np.unravel_index(np.nanargmax(acc), acc.shape)
logger.info("Peak accumulation at grid position %s", max_loc)
3. Overlay against reference hydrography — extract a test channel network at a threshold (e.g. acc > 500) and compare against NHD flowlines or equivalent authoritative data. Channel centrelines should align within one cell width for well-conditioned DEMs. Systematic offset indicates a CRS mismatch; diffuse channel initiation indicates the threshold needs raising or DEM conditioning is incomplete.
4. Hillslope wetness index — compute ln(acc / tan_slope) and verify that ridge cells show low values while valley floors show high values. Inverted patterns indicate proportion matrix errors.
5. Difference raster against D8 accumulation — run the same DEM through a D8 solver, subtract, and map the difference. MFD accumulation should be systematically lower on steep incised channels (where D8 concentrates) and higher on broad hillslopes (where MFD disperses). If the difference is spatially random, check that both runs used identically conditioned DEMs.
Common Failure Modes & Optimization
Flat-area fan artefacts — fan-shaped accumulation patterns on topographic benches or lake beds indicate unresolved flat areas. ResolveFlats must be called before compute_mfd_proportions. If artefacts persist, check that epsilon=False is not re-introducing very small gradients that defeat the resolution step on high-precision float64 data.
Memory exhaustion during sparse solve — the spsolve direct factoriser builds a fill-in matrix that can be 3–5× larger than (I − P). For grids above 5 000 × 5 000 cells, switch to an iterative solver:
A, info = sparse.linalg.bicgstab(M, np.ones(n_cells), tol=1e-8, maxiter=500)
if info != 0:
logger.warning("bicgstab did not converge (info=%d) — check for sinks", info)
Edge cell leakage — incorrect slicing that pads boundary neighbourhoods with zeros creates artificial low-elevation cells at the DEM border, draining all accumulation to the edges. Always use max(0, ...) / min(rows, ...) slice clamps as shown above; never use np.pad.
Diagonal bias at p = 1.0 — on planar slopes, diagonal neighbours receive disproportionate weight because they occur at 45° to the gradient and tan β is computed along the flow vector rather than projected. This is inherent to the Freeman formulation. Increase p slightly (1.1–1.2) to dampen the diagonal surplus, or switch to a facet-based approach via D-Infinity routing if isotropy is critical.
Slow solve on Windows — spsolve uses UMFPACK by default on Linux/Mac. On Windows, call sparse.linalg.spsolve(M, ones, use_umfpack=False) to fall back to SuperLU, which avoids a common DLL conflict.
Projection distortion — angular coordinates (EPSG:4326) cause diagonal distances to vary with latitude, systematically biasing southern vs. northern neighbours. Always reproject to a metric CRS. See coordinate reference system alignment for the rasterio reprojection workflow.
When to Use MFD vs. Alternatives
Use this algorithm when:
- Your terrain contains broad convex hillslopes, alluvial fans, or wetlands where flow genuinely disperses across multiple paths.
- You are generating a wetness index, soil-moisture map, or distributed hydrologic model input — all of which depend on continuous accumulation surfaces rather than a single channel skeleton.
- Channel initiation thresholds are uncertain and you want a smooth accumulation surface to test multiple threshold values with stream threshold tuning.
Prefer D8 flow direction implementation when:
- Your downstream model (e.g. HEC-HMS, TauDEM) expects a single integer flow-direction raster with one downslope code per cell.
- The terrain is steeply incised and channels are topographically unambiguous — D8 is faster and produces cleaner skeletons in those conditions.
- You need deterministic reproducibility: the same DEM always routes the same path, which matters for reproducible automated pipelines.
Prefer D-Infinity routing when you need a two-cell split that approximates a continuous slope vector. D-Infinity is faster than full MFD and produces sharper channel boundaries while still avoiding the single-neighbour bias of D8 — a useful middle ground for moderate hillslope terrain.
Frequently Asked Questions
When should I use MFD instead of D8?
Use MFD for hillslope soil-moisture mapping, distributed hydrologic models, or any terrain where diffuse overland flow dominates. D8 flow direction implementation is preferable for well-incised channel networks and when a single deterministic flow path per cell is required by downstream tools.
What exponent p should I use for the Freeman MFD formula?
p = 1.0 (Quinn formulation) distributes flow proportionally to slope. Values of 1.1–1.3 reduce artificial divergence on convergent hillslopes. Values above 2.0 approach D8 behaviour, concentrating flow in a single preferred direction.
Why does my accumulation raster show fan-shaped artefacts on flat areas?
Flat areas have zero slope gradient, so MFD divides flow equally among all eight neighbours. Apply richdem’s ResolveFlats before computing proportions to assign micro-gradients that guide flow toward natural drainage outlets. The dedicated page on removing flat area artefacts from flow direction grids covers all parameter options.
How do I handle memory limits on large DEMs?
The (I − P) sparse matrix grows with cell count. For grids beyond roughly 50 million cells, use dask.array for tiled proportion computation and iterative solvers such as scipy’s bicgstab rather than the direct spsolve.
Can I use MFD output directly as input to HEC-HMS or TauDEM?
Most HEC-HMS grid-based modules and TauDEM tools expect a D8-style integer direction raster with one direction code per cell. MFD produces a fractional accumulation surface, not a direction grid. Use the MFD accumulation raster to determine channel network extent and initiation thresholds, then pass those boundaries into your deterministic routing tool.
Related Topics
- Flow Routing Algorithms & Stream Network Extraction — parent overview covering the full routing algorithm family
- D8 Flow Direction Implementation — single-direction routing for incised channel networks
- D-Infinity Routing Patterns — two-cell facet split as an intermediate between D8 and MFD
- Stream Threshold Tuning — selecting accumulation cutoffs after MFD accumulation is complete
- Removing Flat Area Artefacts from Flow Direction Grids — richdem flat-resolution parameters and edge cases
- DEM Pit Filling Algorithms — depression filling strategies required before MFD routing