Flow Routing in lsdtt3
This page explains how flow routing is implemented in lsdtt3, what each
method is for, and where lsdtt3 deliberately differs from similar software packages.
Graph vocabulary used on this page
Flow routing is graph (in the mathematical sense) work, and to read this section you will need to know what some terms mean. They are defined once here.
-
Node and edge. Each valid DEM cell is a node. An edge points from a cell to a cell it sends water to. "Receiver" means the cell downstream; "donor" means a cell upstream that sends water in.
-
Tree. A graph where every node has exactly one outgoing edge, and following those edges never returns you to where you started. D8 produces a tree (strictly, a forest - one tree per outlet). Because each cell has one receiver, the set of cells draining to a point is a clean, non-overlapping group, which is what makes basins and drainage area exact.
-
DAG - directed acyclic graph. "Directed" because edges have a direction; "acyclic" because you can never go round in a circle. A DAG relaxes the tree rule: a node may have several outgoing edges. D-infinity and MFD produce DAGs, because a cell splits its water between two or more receivers. Every tree is a DAG; not every DAG is a tree. The difference is why the D8 flow routing routines are numerically different from routines using multiple flow directions.
-
Topological order. An ordering of the nodes in which every node appears after all the nodes that feed into it. Any DAG has one; a graph with a cycle has none. It is what lets flow accumulation be computed in a single sweep: when you reach a cell, everything upstream of it is already finished. Many of the big gains in computational efficiency come from this, as compare to brute force searches, which were the standard method in DEM processing prior to the introduction of graph theory.
-
CSR - compressed sparse row. A compact way to store a graph in which each node has a variable number of edges, using two flat arrays instead of a list per node: one array holds all the edges end to end, and an offsets array records where each node's edges begin. Node
i's edges are the slice fromoffsets[i]tooffsets[i+1]. It is the standard sparse-matrix layout, and it is used here because a D-infinity or MFD cell may have one to eight receivers. -
DFS - depth-first search. Traversing a graph by following one branch as far as it goes before backtracking to the next. A preorder DFS records each node when it is first reached. Done from an outlet up a D8 tree, it lists every cell of a drainage basin consecutively - which is what
s_vectorexploits. More on that below. -
Kahn's algorithm and the "ready queue". A way of producing a topological order without recursion (Kahn 1962). Count how many donors each node is still waiting on. Any node waiting on none is ready, so put it in a queue. Pop a node, emit it, and decrement its receiver's count; if that receiver's count reaches zero it is now ready, so push it. Repeat until the queue empties. If nodes remain unemitted, the graph had a cycle - which is a useful built-in check on a routing graph that should not have one. This is another algorithm that has been around in computer science for a long time but has only recently been injected into flow routing routines.
Routing objects and use-cases
lsdtt3 has two flow-routing backends:
lsdtt_flowinfo(D8): one receiver per valid cell, tree/forest topology, optionals_vectorindex for O(1) upslope-membership tests and contiguous upslope slices.lsdtt_flowgraph_cpu(D-infinity / MFD): multiple weighted receivers per cell, DAG topology (CSR storage), intended for distributed-flow metrics such as D-infinity/MFD area and TWI.
In practice:
- Use D8 (
lsdtt_flowinfo) for channel-network and basin workflows. - Use D-infinity or MFD (
lsdtt_flowgraph_cpu) when fractional flow partition is the intended model.
Provenance of the routing schemes
The three routing schemes in lsdtt3 are not variants of one idea. They are
three answers to the question of how much a grid cell
is allowed to know about where its water goes. Each was a response to a specific
failure of the one before. Please indulge us in a bit of pedantic history telling.
D8 - O'Callaghan and Mark (1984)
The original. Each cell sends all of its water to exactly one of its eight neighbours: the one of steepest descent. The network that results is a forest of trees, every cell having one receiver and any number of donors.
The crucial detail, and the one most often got wrong, is that steepest descent is
drop divided by distance, not lowest elevation. A diagonal neighbour is
sqrt(2) cell-widths away, so it must be sqrt(2) times lower to win against a
cardinal one. lsdtt3 implements the drop-over-distance rule. Implementations
that instead pick the lowest neighbour by elevation give systematically different
networks - see "Why RichDEM's D8 differs" below, which is a worked example of
exactly this.
D8's weaknesses were understood from the beginning and are geometric, not computational:
- Flow is quantised to 45 degrees. A hillslope draining at 20 degrees is represented as alternating cardinal and diagonal steps.
- It cannot diverge. On a ridge, a fan, or any convex surface, real water spreads and D8 cannot. Every cell has one receiver, so upslope area is a set of disjoint trees, and adjacent hillslope cells can be assigned to different basins on the strength of a centimetre of elevation difference.
- Flat areas have no defined direction at all, which is why depression handling (below) is a prerequisite rather than a refinement.
What it buys in exchange is the reason it is still the default: the network is a tree, so upslope area is exact and integer, basins partition the DEM cleanly, channel extraction and chi analysis are well posed, and the whole thing is linear-time (see "Why D8 is effectively O(n)").
MFD - Freeman (1991) and Quinn et al. (1991)
Multiple-flow-direction routing attacks the divergence problem directly: a cell gives water to every downslope neighbour, in proportion to
weight_i proportional to (tan beta_i)^p
where tan beta_i is the slope to neighbour i and p is a tuning exponent
(mfd_exponent, default 1). Freeman and Quinn et al. arrived at closely related
schemes independently in the same year, from different directions - Freeman from
catchment-area computation, Quinn et al. from TOPMODEL-style distributed
hydrology, where the D8 assumption that a hillslope drains along a single line
made the wetness index behave badly. Freeman weighted by slope to a power p
and recommended p = 1.1. Quinn et al. used p = 1 and also multiplied each
slope by an effective contour length (0.5 of the cell size on a cardinal
neighbour, 0.354 on a diagonal). lsdtt3 implements slope to the power p
with no contour-length factor, so p = 1 is not exactly Quinn et al.'s
weighting: a diagonal receiver gets a larger share than theirs. MFD looks much
more like a finite difference approach than a flow routing approach.
The exponent is the whole argument. p = 1 spreads flow broadly and is defensible
on hillslopes; large p concentrates flow and converges toward D8 in the limit.
There is no physically derived value, which is MFD's central weakness: the answer
depends on a parameter chosen by the user.
MFD also gives up the tree. Upslope "area" becomes a fractional, overlapping quantity - a cell can be partly in several basins - so basin partitioning, chi analysis, and O(1) upslope queries are no longer available in the same form.
D-infinity - Tarboton (1997)
Tarboton's method lies between these end members. The eight neighbours define eight triangular facets around the central cell; the steepest downslope direction is computed on those facets as a continuous angle, not snapped to a neighbour. Flow is then split between the two neighbours that bracket that angle, in proportion to how close the angle is to each.
This keeps most of what MFD was after - flow is no longer quantised to 45 degrees, and divergence on a convex surface is representable - while limiting dispersion to two receivers rather than up to eight. It also removes MFD's free parameter: the partition is fixed by the geometry.
The price is that it is still not a tree, so the same structural limits apply as for MFD, and the facet computation is more expensive per cell.
How to choose
| D8 | D-infinity | MFD | |
|---|---|---|---|
| receivers per cell | 1 | 1-2 | up to 8 |
| flow direction | 45-degree steps | continuous | continuous, dispersed |
| free parameter | none | none | mfd_exponent |
| structure | tree | DAG | DAG |
| exact basins, chi, O(1) upslope | yes | no | no |
Use D8 when the tree structure is the point: basin delineation, channel networks, chi analysis, most cosmogenic work. Use D-infinity or MFD when hillslope divergence matters and you do not need a partition - hydrological wetness indices, sediment or contaminant dispersal, debris-flow inundation.
How the D8 graph and the multi-receiver graph differ
The difference between D8 and D-infinity/MFD is not a tuning choice; it changes the kind of graph the library is holding, and most of the downstream consequences follow from that.
| D8 | D-infinity / MFD | |
|---|---|---|
| outgoing edges per cell | exactly 1 | 1 to 8 |
| graph type | tree (forest) | DAG |
| edges carry a weight? | no - all the water goes | yes - a fraction per edge |
| storage | one receiver per cell, a fixed-size array | CSR, because the count varies |
| upslope set of a cell | a disjoint group of cells | overlapping, fractional |
| drainage area | integer count of cells | a weighted sum |
| basins | partition the DEM exactly | overlap; no clean partition |
Two practical consequences:
- You can index a tree in a way you cannot index a DAG. In a tree, the cells
upslope of a point form one contiguous block under a preorder DFS, so
membership is an interval test - this is
s_vector, below. In a DAG a cell can be partly upslope of two different points at once, so no such interval exists. - Storage must be variable-width for a DAG. One receiver per cell fits a plain array; one-to-eight weighted receivers per cell is exactly the case CSR exists for.
lsdtt_flowgraph_cpu builds the receiver DAG in CSR form and walks it in
topological order using a Kahn ready queue (see the vocabulary above), following
the multi-receiver linear-layout idea in Anand et al. (2020).
Hydraulic graph routing (GraphFlood lineage)
lsdtt_hydraulic_graph_cpu is a graph-based hydraulic solver that advances
water depth over a multi-receiver routing graph. It has two kinds of run mode.
In the fixed-graph modes the graph is copied once from an existing
lsdtt_flowgraph_cpu and stays unchanged while the water depth evolves. In the
dynamic-graph modes the graph is rebuilt from the current water surface (bed
plus water depth) every reroute_every steps. lsdtt-graphflood uses the
dynamic stationary mode, and with its hydraulic domain set it rebuilds the
graph every iteration. This is the approach of GraphFlood
(Gailleton et al., 2024), which approximates 2-D shallow-water hydrodynamics by
solving for a steady water surface on the flow graph, at a small fraction of the
cost of a full hydrodynamic model. lsdtt3's implementation is credited to those
concepts in the code comments.
The relevance to this page is that GraphFlood consumes a multi-receiver graph: water spreading across a floodplain is precisely the situation D8's single receiver cannot represent, so the DAG is not an optional refinement here but a prerequisite. Much of the logic behind our MFD algorithms stems from the Anand paper and the Gailleton et al. 2024 papers.
Braun and Willett: graph theory enters the field
Braun and Willett (2013) is not a routing method, and it is worth being precise about what it did contribute, because the vocabulary it introduced is now everywhere in this codebase.
The problem it solved. Landscape evolution models integrate the stream power law, in which a cell's incision depends on its own elevation and on the elevation of the cell it drains to. Solved explicitly, the timestep is limited by the Courant condition and shrinks as resolution rises. Solved implicitly, you appear to need a large sparse linear solve every step.
The observation. Under D8 the drainage network is a directed tree: every
cell has exactly one receiver, so the dependency graph of the implicit update is
a tree, not a general sparse matrix. If you process cells in an order where every
cell comes after the cell it drains into, each update needs only a value already
computed. No matrix, no iteration - one sweep, O(n), and unconditionally stable.
The machinery. Braun and Willett laid out how to build that order in linear time, and their construction is a textbook graph algorithm dressed in geomorphological clothes:
- count donors per cell (
NDonorsVector); - prefix-sum those counts into offsets (
DeltaVector) and fill a flat donor list (DonorStackVector) - this is a counting sort into CSR adjacency, exactly the layout a sparse-matrix library would use; - depth-first traverse from each base level, pushing cells onto a stack
(
SVector) - a preorder DFS, giving both the update order and, as a by-product, contiguous upslope intervals.
Those four array names appear verbatim in LSDTopoTools2's LSDFlowInfo, which
implements the paper directly.
What lsdtt3 kept and what it changed. The idea - order the cells so that donors precede receivers, then sweep once - is retained and is the basis of every accumulation in the library. The construction is different:
lsdtt3orders cells with Kahn's algorithm (Kahn 1962) on a donor count, not on a stored donor list. Every cell holds a single number: how many donors it is still waiting for. Cells waiting for none are emitted, and each time a cell is emitted its receiver's count drops by one; when a receiver reaches zero it becomes ready in turn.
Why change it? Braun and Willett's construction has to know, for each cell, which cells drain into it, so it builds the full donor list (the CSR arrays). Kahn's algorithm only ever needs to know how many are left. That difference is worth three things:
- Memory. A D8 cell has at most 8 donors, so the count fits in one byte.
The donor list it replaces costs 12 bytes per cell (
NDonorsVector+DeltaVector+DonorStackVector), and building it needs a second pass and a temporary. See the memory section below. - Cycle detection for free. If the queue empties while cells remain
unemitted, the receiver graph contains a cycle. A recursive DFS would have to
be instrumented to notice; here it falls out of the loop condition, and
lsdtt3uses it to mark the object non-acyclic rather than producing a silently wrong ordering. - No recursion. The DFS formulation recurses to the depth of the longest flow path, which on a large DEM can be tens of thousands of cells - a real stack-overflow risk. Kahn's algorithm is a flat loop over a queue.
The trade is that Kahn's order is breadth-like rather than depth-first, so it
does not produce contiguous upslope blocks as a by-product. That is why
s_vector is a separate, optional structure in lsdtt3 rather than something
the ordering hands you for nothing.
- The preorder-DFS product is kept, but as an optional structure
(s_vector), built only when O(1) upslope queries are actually wanted.
Note that for most geomorphology operations you will want to retain the s_vector
so the "savings" gained from leaving it out are a bit notional.
What "owing a debt" to the paper means here. lsdtt3 does not run Braun and
Willett's algorithm: none of their four arrays exists in the code. However,
the code in lsdtt3 has legacy from LSDTopoTools2 and LSDTopoTools, which
were direct implementations of the Brain and Willet algorithm, coded very shortly
after the lead developer (SMM) was blown away by Jean's presentation at EGU in 2013.
What lsdtt3 takes from Braun and Willet is the framing: that a D8 drainage network is a directed tree, that
the useful question is what order to visit its cells in, and that the right order
turns an apparently expensive implicit calculation into one linear sweep. That
insight is now so embedded in the field that it is easy to forget it was an
argument someone had to make. Every accumulation in this library is a single pass
over a topological order because of it, even though the order is built by a
different algorithm.
The paper is therefore cited for the idea, not the implementation - and the
shared vocabulary in LSDTopoTools2 (SVector, DeltaVector and the rest) is a
direct trace of the older code following the paper array for array.
Why D8 is effectively O(n)
For n valid cells, D8 build and accumulation are linear in cell count (with a
small constant factor):
- Receiver assignment: each cell checks at most 8 neighbours.
- Donor counting: one pass over cells.
- Topological ordering: each cell enqueued/dequeued once; one receiver edge processed per cell.
- Accumulation: one forward push along the topological order.
So work scales as a bounded number of full-grid passes, not an all-to-all operation.
From a raw DEM to a fully routed DEM
A raw DEM cannot be routed directly. It has depressions (cells, or groups of
cells, with no lower neighbour on the way out) and, once those are dealt with,
flats (connected cells of equal elevation). lsdtt3's D8 router requires
every interior cell to have a strictly lower neighbour; on a true flat it stops
with could not assign a receiver. Getting to a routed DEM is therefore three
steps, and the first two are what this section is about.
- Depressions. Raise them (fill), cut through their rims (breach), or breach first and fill whatever the breach leaves.
- Flats. Give them a gradient. Either the fill imposes it while it floods (the default), or a separate pass resolves the flats afterwards (Barnes et al. 2014b).
- Routing. Build the D8 receivers, the topological order and, by default, the S-vector; drainage area follows from them. See s_vector rationale.
A surface is routable when step 2 has run: every valid cell, other than one on the raster edge or next to nodata, has a strictly lower neighbour.
The defaults
Analysis CLIs (lsdtt-drainage-analysis, lsdtt-chi-analysis,
lsdtt-basin-extraction, lsdtt-channel-extraction and the other routing
CLIs) do steps 1-3 in one run when raster_is_filled: false:
| step | default | parameter |
|---|---|---|
| depressions | Priority-Flood fill (Barnes et al. 2014), binary heap. fill_pits switches to grayscale reconstruction when fill_min_slope is 0, which leaves flats the router rejects |
fill_min_slope |
| flats | gradient imposed during the fill: each cell ends at least fill_min_slope × step length above the cell it was reached from (× √2 on a diagonal), and never less than one float32 step above it, so the rise cannot round away at high elevation |
fill_min_slope: 0.0001, fill_flat_resolution_choice: inline_gradient |
| routing | D8 steepest descent, with the S-vector | build_flow_s_vector: true (exposed by lsdtt-drainage-analysis only; the other CLIs always build it) |
No breaching runs in the analysis CLIs (lsdtt-stream-burning has its own
carve-then-fill route). With raster_is_filled: true the fill is skipped and
the DEM is routed as given; in the CLIs that take
fill_flat_resolution_choice, a barnes2014b_* option still resolves any flats
it holds.
lsdtt-raster-preprocessing writes conditioned DEMs and exposes every
option. Its defaults: fill_algorithm_choice: barnes2014, fill_min_slope:
0.0001, fill_flat_resolution_choice: inline_gradient,
priority_flood_queue: binary, breach_before_fill: false,
breach_algorithm_choice: lindsay2016. The fill runs only when an output asks
for it (write_fill_raster: true or write_fill_diff_raster: true; both
default to false), and breaching runs only when breach_before_fill,
write_breached_raster or write_carve_diff_raster is true. So a run with
write_fill_raster: true and nothing else gives the same surface the analysis
CLIs route. Every fill route in the table below needs write_fill_raster: true
in this CLI.
The options
| route | how to ask for it | routable? | what it does |
|---|---|---|---|
| Priority-Flood with an imposed gradient (default) | fill_algorithm_choice: barnes2014, fill_min_slope > 0 |
yes | fills and imposes fill_min_slope across flats in one pass. Flow on a flat runs in parallel lines towards the outlet (why) |
| same, radix queue | add priority_flood_queue: radix (raster-preprocessing, internal-drainage) |
yes | same fill levels. At fill_min_slope > 0 the gradients on flats can differ, because ties between equal cells are broken in a different order |
| Grayscale reconstruction, no gradient | fill_algorithm_choice: vincent1993, fill_min_slope: 0 |
no | the same surface as barnes2014 at fill_min_slope: 0, faster, but flats stay flat. An endpoint, or an input to a flat-resolution step |
| Fill, then Barnes 2014b flat resolution | fill_flat_resolution_choice: barnes2014b_gradient or barnes2014b_float_step |
yes | fills to the spill level, then raises flats so flow converges towards the outlet. Up to four refill-and-resolve rounds plus a final pass on real terrain (details) |
| Complete breaching, then fill | breach_before_fill: true, breach_algorithm_choice: lindsay2016, write_fill_raster: true |
yes (the fill makes it so) | Lindsay (2016) complete breaching: every depression is cut through to a lower cell, then the default fill handles flats and anything left |
| Least-cost breaching | breach_algorithm_choice: least_cost, write_breached_raster: true |
yes, with the default remainder fill | Lindsay (2020) least-cost breaching (below). Cells the breach cannot solve, and flats, are filled by fill_pits with fill_min_slope |
| least-cost breaching, then the fill | add breach_before_fill: true, write_fill_raster: true |
yes | as above, then a full fill pass over the breached DEM |
| Fill-Spill-Merge | fill_algorithm_choice: barnesetal2021 |
not guaranteed | distributes a finite rainfall volume (Barnes et al. 2021); not every depression is filled |
Filling changes the DEM by raising cells only. Breaching lowers cells along a channel through the rim, which keeps lakes and closed basins closer to their original elevations but cuts trenches. Which is better is a modelling choice, not a speed one.
Least-cost breaching
breach_algorithm_choice: least_cost is a port of Whitebox's
breach_depressions_least_cost (whitebox_workflows 2.0.6), which implements
Lindsay (2020). For each pit, lowest first, a least-cost search finds a path to
a cell low enough to drain it, and the cells on the path are lowered to a
channel that descends by a small increment per step. With
breach_lc_edge_policy: whitebox, an explicit breach_lc_flat_increment and
fill_remainder_after_breach: false, lsdtt3's output is bit-for-bit
float32(Whitebox's output); the test suite checks this on two real DEMs and on
2400 random ones.
Changed in release 0.6.0
Least-cost breaching was rewritten as this port on 2026-10-11. Its output
differs from releases up to 0.5.2 in lsdtt-raster-preprocessing,
lsdtt-stream-burning and the Python breach_least_cost /
least_cost_carve_depth. max_dist is now a path length in cells (it was a
search radius), the cost no longer includes distance unless
breach_lc_minimize_dist is true, and lsdtt-stream-burning's
breach_max_cost is therefore total depth cut in metres. Re-run anything
that depends on least-cost output.
lsdtt3's defaults differ from Whitebox's in four places, each for a reason:
| parameter | lsdtt3 default | Whitebox | why |
|---|---|---|---|
breach_lc_edge_policy |
outlet: a search may leave the raster at any edge cell, exactly as it may drain into nodata |
the edge drains only if an edge cell is already low enough | Whitebox's rule carved trenches across divides to reach a low edge cell (about 1760 m deep on a 500² alpine test DEM) |
breach_lc_max_dist |
1000 cells (outlet), 100 (whitebox) |
100 | long enough to cross large water bodies. At 100, a lake in a 1000² lakeland test DEM with no interior outlet in reach was carved 56 m deep to the raster edge |
breach_lc_flat_increment |
automatic: 2 × the float32 spacing at the DEM's largest |z| (2.4e-4 m at 1024-2047 m) | about 1e-10 m in float64 | lsdtt3 stores float32, where Whitebox's increment would round away and leave the carved channel flat |
fill_remainder_after_breach |
true, with fill_min_slope |
fill_deps: false |
without it the output is not routable when any pit is left unsolved |
breach_lc_minimize_dist (default false, as in Whitebox) switches the cost from
total depth cut (m) to depth × path length (m × cells).
The algorithm, step by step (as in Whitebox; δ is the flat increment):
- Find pits. A pit is a valid cell whose 8 neighbours are all valid, on the raster, and none strictly lower. One row-major pass finds them and sets each, in place, to its lowest neighbour − δ. A pit in a hollow is raised by this; a pit on a flat floor, whose lowest neighbour is at its own elevation, is lowered by δ. Because the pass works in place, only alternate cells of a flat floor become pits. Cells on the raster edge or next to nodata are never pits.
- Order. Pits are processed lowest original elevation first; among equal elevations the last in scan order goes first. A pit that an earlier breach has already given a lower neighbour is skipped.
- Search. From each pit, a least-cost search over a min-heap. A cell's
back-link and path length are fixed when it is first reached, with no later
relaxation. The target elevation
Lsteps from the pit isz_pit − L·δ. The first cell reached at or below its target, or a nodata cell (and, withedge_policy: outlet, the raster edge), ends the search. - Cost. Pushing a neighbour adds the depth it would have to be cut to
(
minimize_dist: false), or the mean cut of the two cells × the step length in cells (minimize_dist: true). Cells are pushed only up tomax_diststeps. The search gives up when the heap empties or the popped cost exceedsmax_cost; the pit is then unsolved and keeps its raised value. - Carve. The cells on the path are lowered to their targets, so the channel descends by δ per step to the outlet. The outlet cell itself is not changed.
- Remainder fill (
fill_remainder_after_breach: true, the lsdtt3 default). If any interior cell is left without a strictly lower neighbour, the breached DEM goes throughfill_pitswithfill_min_slopeandfill_flat_resolution_choice, which makes it routable.
The automatic increment. With breach_lc_flat_increment: 0, δ is
2 × ULP_f32(max(|z|max, 1)), where |z|max is the largest |z| over valid cells
and ULP_f32 is the gap between adjacent float32 values at that magnitude. It
is the smallest step that keeps a carved channel strictly descending once
stored as float32: 1.2e-4 m for |z|max of 512-1023 m, 2.4e-4 m at 1024-2047 m,
4.9e-4 m at 2048-4095 m. Whitebox's own automatic value (a few float64 ULPs,
about 1e-10 m) would round away in float32 and leave the channel flat.
Numerics. The working copy is float64 and is rounded to float32 once, at
the end, as Whitebox computes in float64 and lsdtt3 stores float32. The search
heap is a hand port of Rust's BinaryHeap, so equal-cost cells pop in
Whitebox's order, and the source file is compiled with -ffp-contract=off so
that no compiler fuses a multiply-add and changes the rounding. These three are
what make the Whitebox comparison bitwise rather than approximate.
Memory. A float64 copy of the DEM plus a few bytes per cell of search state:
peak 644-657 MiB for the whole lsdtt-raster-preprocessing process on a 4000²
DEM, against 1757-1774 MiB for the Whitebox process (same server campaign as
below). The search is serial.
The implementation notes are in src/cpu/lsdtt_raster_breach_least_cost_cpu.cpp.
Speed of the options
What these numbers are
One server campaign: forth01, AMD EPYC 9654P, 2026-10-12, lsdtt3 0.6.0
(e09e809) built with GCC 13 -O3. The node was shared, not an exclusive
allocation, and the governor was schedutil. Every row is 1 thread, the
median of 5 repeats after a warm-up, and the compute time the CLI prints
itself (no file I/O). Treat ratios as indicative of this machine and these
DEMs. A campaign the day before on the same node had every engine taking
about 15% longer; compare ratios between campaigns, not seconds. Raw data
and method: Running benchmarks on your own system and
Benchmarking and testing in lsdtt3.
The DEMs are Copernicus 30 m crops: an alpine region (steep, few depressions) and a lakeland region (low relief, many lakes), at 1000², 2000² and 4000² cells. Seconds to a conditioned DEM (steps 1-2 only):
| route | routable? | alpine 1k | lakeland 1k | alpine 2k | lakeland 2k | alpine 4k | lakeland 4k |
|---|---|---|---|---|---|---|---|
reconstruction, no gradient (vincent1993) |
no | 0.044 | 0.076 | 0.177 | 0.366 | 1.152 | 1.900 |
| Priority-Flood, no gradient | no | 0.125 | 0.113 | 0.577 | 0.517 | 2.683 | 2.790 |
| Priority-Flood + gradient (default) | yes | 0.126 | 0.116 | 0.592 | 0.521 | 2.743 | 2.767 |
| same, radix queue | yes | 0.178 | 0.141 | 0.758 | 0.571 | 3.264 | 2.584 |
| fill + Barnes 2014b, gradient | yes | 0.193 | 0.706 | 1.953 | 3.204 | 10.300 | 15.761 |
| least-cost breach, defaults (remainder fill on) | yes | 0.031 | 0.176 | 0.115 | 0.813 | 0.425 | 8.189 |
| least-cost breach, then the default fill | yes | 0.154 | 0.314 | 0.690 | 1.458 | 3.134 | 11.730 |
| Lindsay 2016 breach, then the default fill | yes | 0.269 | 0.299 | 1.234 | 1.373 | 5.749 | 7.398 |
What the table shows, by terrain:
- Alpine: least-cost breaching with the defaults is the fastest routable option (4-6.5× faster than the default fill). Few pits, each solved by a short search.
- Lakeland: the default fill is the fastest routable option up to 2000²;
at 4000² the radix queue is (2.58 s against 2.77 s). Least-cost breaching
with
max_dist1000 takes 8.2 s at lakeland 4k: its searches cross large lakes. - Barnes 2014b costs 1.5-6× the default fill. It is the option that makes flow converge on flats; it is not the fast one.
- Breach then fill, least-cost against Lindsay 2016: least-cost is faster on alpine at every size, about equal on lakeland up to 2000², and slower on lakeland 4k (11.7 s against 7.4 s).
End to end, lsdtt-drainage-analysis on the raw DEM (default fill + D8 with
S-vector + drainage area) took 0.21 / 0.22 / 0.95 / 0.95 / 4.23 / 4.46 s on the
six DEMs. The fill is about 60% of that and the D8 build about 40%, of which
the S-vector is 27-37% of the D8 step (build_flow_s_vector: false takes it
from 1.50 s to 1.08 s at alpine 4k).
How lsdtt3 differs from RichDEM, TopoToolbox and Whitebox
Each package's default route from a raw DEM to D8 flow accumulation:
| package | depressions | flats | slope on flats in the output DEM | D8 picks |
|---|---|---|---|---|
| lsdtt3 | Priority-Flood fill (Barnes 2014) | gradient imposed during the fill | at least fill_min_slope (1e-4 m/m) |
steepest descent (drop / distance) |
| RichDEM 2.3.0 | Priority-Flood fill. The default leaves flats its D8 does not route; FillDepressions(epsilon=True) raises each flat cell by one floating-point step |
one float step per cell | of order the float spacing / cell size | the lowest neighbour, not the steepest (why) |
| TopoToolbox (Python 0.0.12) | grayscale reconstruction (fillsinks, hybrid) |
routed inside FlowObject by least-cost carving on an auxiliary surface; the DEM itself is not changed |
zero: flats stay flat | steepest descent; on flats, the auxiliary surface |
| Whitebox (whitebox_workflows 2.0.6) | pit-centric fill (fill_depressions), one pit at a time |
raised in 1e-4 m steps by a rank that leads towards lower terrain | small but real | steepest descent |
Where the same algorithm exists in more than one package, the test suite compares the outputs value for value (Benchmarking and testing in lsdtt3):
- Priority-Flood fill (no gradient): lsdtt3, RichDEM, Whitebox
(
fill_depressions_wang_and_liu, Wang and Liu 2006). - Grayscale reconstruction: lsdtt3
vincent1993, TopoToolboxfillsinks. - Lindsay (2016) complete breaching: lsdtt3, RichDEM
BreachDepressions. - Least-cost breaching: lsdtt3 (
edge_policy: whitebox), Whitebox. - D8 steepest descent: lsdtt3, TopoToolbox (accumulation asserted equal).
Two structural differences explain most of the speed differences between default pipelines:
- The S-vector. lsdtt3's D8 build also builds the upstream ordering that chi analysis, basin extraction and the cosmogenic routines rely on. No other package builds it, and it is 27-37% of lsdtt3's D8 time. With it switched off, lsdtt3's D8 accumulation took 0.55-0.70 of Whitebox's time and 0.61-0.72 of RichDEM's in the campaign above.
- Where flats are paid for. lsdtt3 resolves flats inside the fill, at a cost that hardly depends on terrain. TopoToolbox fills cheaply by reconstruction and pays for flats in its routing step, at a cost that grows with flat area.
Seconds, raw DEM to D8 accumulation, each package's defaults (same campaign):
| package | alpine 1k | lakeland 1k | alpine 2k | lakeland 2k | alpine 4k | lakeland 4k |
|---|---|---|---|---|---|---|
| lsdtt3 | 0.213 | 0.216 | 0.949 | 0.945 | 4.227 | 4.464 |
| RichDEM (epsilon fill) | 0.213 | 0.208 | 0.972 | 0.924 | 4.278 | 4.329 |
| TopoToolbox | 0.133 | 0.257 | 0.513 | 1.149 | 2.369 | 5.640 |
| Whitebox | 0.103 | 0.411 | 0.379 | 4.963 | 1.980 | 60.053 |
These pipelines do different work and produce different surfaces, so the table answers "how long does each package take with its defaults", not "which implementation is faster". On alpine 4k the TopoToolbox / lsdtt3 difference decomposes (from separately measured steps) into about 1.7 s for the fill family (reconstruction 1.08 s against Priority-Flood with a gradient 2.74 s; lsdtt3's own reconstruction takes 1.15 s) and about 0.4 s for the S-vector. On lakeland 4k TopoToolbox's flat routing took about 4.0 s and lsdtt3 was faster. Whitebox's pit-centric fill is fast where pits are few and slow where they are many (60 s at lakeland 4k).
The same pipeline in lsdtt3 and Whitebox (a Priority-Flood fill with a gradient,
Whitebox fill_depressions_wang_and_liu with flat_increment = 1e-4 × cell
size, then steepest-descent D8) took, in Whitebox, 0.89-1.34 times lsdtt3's
time across the six DEMs. Whitebox was launched pinned to one core: its
max_procs setting alone did not keep it on one.
Depression filling: two algorithms, one result
lsdtt3 ships two depression-filling backends. They produce bit-identical
output at fill_min_slope: 0 and differ only in cost.
fill_algorithm_choice |
algorithm | complexity | memory access |
|---|---|---|---|
barnes2014 (CLI default) |
Improved Priority-Flood (Barnes et al. 2014) | O(N log N) — nearly every cell through a binary heap | heap-ordered, cache-hostile |
vincent1993 |
grayscale reconstruction by erosion (Vincent 1993), hybrid form: forward scan, backward scan, then a FIFO queue for the residue | O(N), small constant | almost entirely sequential |
Measured on a 1000×1000 Copernicus tile (Himalaya), fill_min_slope: 0:
| backend | compute | throughput |
|---|---|---|
barnes2014 |
0.1282 s | 7.80 Mcells/s |
vincent1993 |
0.0446 s | 22.42 Mcells/s |
2.87× faster, 0 differing cells out of 1,000,000.
Which to use
fill_min_slope: 0→vincent1993. Same surface, ~3× faster.fill_min_slope > 0→barnes2014, and there is no choice. Reconstruction fills to exactly the spill level and has no mechanism for imposing a gradient across the resulting flats. Priority-flood applies the increment inline as it floods. Flats left by amin_slope: 0fill can instead be resolved with the opt-in Barnes et al. (2014b) options, which make flow converge across the flat; see Flat resolution: Barnes et al. (2014b).- Periodic boundaries →
barnes2014. The reconstruction fast path has its own neighbour walk and does not wrap.
Note that lsdtt3's D8 router requires a gradient on flats — it throws
could not assign a receiver on a truly flat surface — so a vincent1993 fill
is an endpoint (or an input to something other than D8 routing), not a
precursor to routing. That is why fill_min_slope defaults to 1e-4.
The library dispatches; the CLI does not
fill_pits() selects the backend automatically from min_slope
(<= 0 → reconstruction). lsdtt-raster-preprocessing instead dispatches
on fill_algorithm_choice, which defaults to barnes2014, so a CLI run
with fill_min_slope: 0 pays the heap cost unless you ask for
vincent1993 explicitly.
This is also what TopoToolbox does
TopoToolbox's fillsinks() takes a hybrid flag defaulting to True —
the same reconstruction algorithm as vincent1993. Measured on the same tile:
| reconstruction | priority-flood | ratio | |
|---|---|---|---|
| lsdtt3 | 0.0446 s | 0.1282 s | 2.87× |
| TopoToolbox | 0.0541 s | 0.1067 s | 1.97× |
Both tools show the same ordering, and matched algorithm against algorithm the two implementations are close. Comparing across families is the mistake to avoid — an early version of the benchmark suite ran lsdtt3's default (heap) against TopoToolbox's default (reconstruction) and reported lsdtt3 2.9× slower, which measured the choice of algorithm rather than the quality of either implementation. See Benchmarking and testing in lsdtt3.
Flat resolution: Barnes et al. (2014b)
The default, fill_flat_resolution_choice: inline_gradient, imposes
fill_min_slope during the Priority-Flood fill, as described above. Two opt-in
options replace the imposed ramp on flats with the flat-resolution method of
Barnes, Lehman and Mulla (2014b):
fill_flat_resolution_choice |
what it does on flats left by a fill_min_slope: 0 fill |
|---|---|
inline_gradient (default) |
nothing separate: the Priority-Flood fill imposes fill_min_slope as it floods |
barnes2014b_gradient |
the Barnes mask scaled to a physical gradient set by fill_min_slope |
barnes2014b_float_step |
the Barnes mask applied as float32 steps: RichDEM ResolveFlats on float32 in its first pass |
The option is a parameter of lsdtt-raster-preprocessing and of the routing
CLIs that run a fill (see their CLI reference pages). With the default, the
output is unchanged, bit for bit. The implementation is in
src/cpu/lsdtt_raster_flat_resolution_cpu.{hpp,cpp}.
Why the default gives parallel flow on flats
The inline ramp raises each flat cell by fill_min_slope × cell size (times
√2 on a diagonal) for each step it takes from the outlet. On an 8-neighbour grid
every cell of a cross-valley column is the same number of steps from the
outlet, so the ramp has no cross-valley component. Flow on the flat then runs in
parallel lines, with no convergence towards the outlet.
The valley test case is 11 × 24 cells at 10 m, with a floor at 100 m, walls at 110 m, and the outlet at row 5, column 0. D8 accumulation, in cells, across the valley at column 12, rows 1-9:
| method | rows 1-9 |
|---|---|
default fill_pits, fill_min_slope 1e-4 |
24 12 12 12 12 12 12 12 24 |
barnes2014b_float_step |
2 3 4 5 104 5 4 3 2 |
With the Barnes mask, the flow from the whole flat converges on the centre line
(row 5) and runs to the outlet. The default spreads the same area evenly across
the floor. This is test F5 in tests/cpp/test_flat_resolution.cpp.
The Barnes 2014b mask
The mask is computed on a depression-free DEM (the output of
fill_pits_reconstruction). Each valid cell is classified as follows.
- Draining if it has a strictly lower valid neighbour, or if it is an exit. An exit is a cell on the raster border, or a cell touching interior nodata. This is the same exit rule as the fill, so a flat next to an interior hole drains into it.
- NO_FLOW otherwise.
- A low edge is a draining cell with an equal-elevation NO_FLOW neighbour.
- A high edge is a NO_FLOW cell with a strictly higher neighbour.
A flat region is the set of 8-connected, equal-elevation cells connected to a low edge. Within each region two breadth-first distances are measured over NO_FLOW cells:
a, the distance from the high edges (a = 1on a high edge), andH, the largestain the region;t, the distance from the low edges (t = 1on a low edge).
The mask of a NO_FLOW cell is 2t + H - a in a region with high edges, and
2t otherwise. Low edges get 2, which is the RichDEM convention. Every NO_FLOW
cell has a neighbour at least one mask unit lower, so every cell drains. The
step onto a low edge can be up to H + 1 units.
Two rules follow from the reference implementation:
- Border cells are never raised, whatever their mask. RichDEM's
ResolveFlatsnever modifies them, and the same rule is used here so that the two agree. Cells next to interior nodata are raised. - The distances and maxima are taken over a fixed graph, so the mask does not depend on scan order, queue order or thread count.
Reference: Barnes, R., Lehman, C., Mulla, D. (2014). An efficient assignment of
drainage direction over flat surfaces in raster digital elevation models.
Computers & Geosciences 62, 128-135. doi:10.1016/j.cageo.2013.01.009. The code
calls it barnes2014b, to keep it apart from the Priority-Flood paper
(barnes2014).
Option 1: barnes2014b_float_step
Each flat cell is raised by its mask in float32 steps: nextafter applied
mask times. The steps are computed in O(1) by step_up_float32, which is
bit-identical to the loop. This is RichDEM's ResolveFlats on a float32 raster.
On flats at |z| ≥ 1 m the first round is bit-for-bit equal to RichDEM, border
cells included, on the hand-built fixtures (oracle O1). On the fetched alpine and
lakeland small DEMs (oracle O2), the only cells that differ from RichDEM are those
that the later rounds changed, and none is below RichDEM's value.
The slopes are not physical. A float-step drop is one or a few ULPs of the elevation, so the gradient across a resolved flat is about ULP(z) / cell size. It depends on elevation and jumps by a factor of two at 512, 1024, 2048 and 4096 m. On a 30 m grid (D8 diagonal) it is about 2e-7 m/m at 100 m, 1.4e-6 m/m at 1000 m and 2.3e-5 m/m at 8848 m. The consequences:
- lsdtt3's D8 router compares elevations in double precision, so it routes any positive drop, even at 0 m.
- Most physics consumers floor slope at 1e-5 to 1e-3 (channel extent, GIUH, sediment routing, the hydraulic graph). To them a float-step flat is still flat.
- Any consumer that forms the slope in float32, or takes its logarithm, sees a small number that depends on elevation. At 0 m it sees zero.
The elevation change can be large. The raise is mask × ULP. The float32
ULP is 7.6e-6 m on [64, 128) m, 1.22e-4 m on [1024, 2048) m and 4.88e-4 m on
[4096, 8192) m. For a 30 m flat the raise is:
| flat (30 m grid) | at 100 m | at 1500 m | at 3700 m | at 4700 m |
|---|---|---|---|---|
| floodplain, mask 350 (5 km × 1 km) | 2.7 mm | 4.3 cm | 8.5 cm | 17 cm |
| lake, mask 720 (10 km × 3 km) | 5.5 mm | 8.8 cm | 18 cm | 35 cm |
| salar or high lake, mask 5000-7500 | 4-6 cm | 0.6-0.9 m | 1.2-1.8 m | 2.4-3.7 m |
| lidar 1 m floodplain, mask 4150 | 3.2 cm | 0.5 m | 1.0 m | 2.0 m |
On most flats the change is millimetres to centimetres. On large high flats it can be metres, and a rim a few ULPs above a flat can be overtopped. The rounds described below repair those rims.
The near-zero guard. The float32 ULP shrinks towards zero elevation. At 0 m
it is 1.4e-45 m, a denormal number, so a flat at 0 m would get steps that
float32 consumers see as zero slope (TWI becomes +inf and the MFD drops the
receiver). For flats with |z| < 1 m each mask unit is therefore a fixed
2^-23 m (1.19e-7 m): z' = float(z + mask × 2^-23). Above 1 m the native ULP
is already at least 2^-23 m, so the step is continuous at |z| = 1 m. The guard
keeps the Barnes ordering exactly and is always on; it is not a parameter. It
is bit-equal to RichDEM everywhere except on those flats. The step stays
distinct only while z' < 2 m, and the code throws beyond that bound (a mask of
about 8.4 million at |z| < 1 m). On a 30 m grid the smallest float32 slope it
leaves is 2^-23 / (√2 × 30 m) = 2.8e-9 m/m.
In lsdtt-raster-preprocessing and lsdtt-basin-stats, min_elevation
(default 0) sets cells at or below that elevation to nodata before the fill.
By default, flats at or below 0 m are therefore removed, and the guard acts on
flats between 0 and 1 m. Any negative min_elevation switches this masking off
entirely (it is not a lower threshold), which brings sea-level and
below-sea-level flats into the fill, and then the guard acts on them.
Option 2: barnes2014b_gradient
The same mask and the same raised cells, but one mask unit is a physical length:
u = fill_min_slope × √2 × L
where √2 L is the router's own diagonal path length. Every raised NO_FLOW cell
has a neighbour one unit lower at most √2 L away, so every D8 step on an
uncapped flat has slope at least fill_min_slope, in the router's double
arithmetic. Inside a flat the steps are one to three units, so the slopes run
from fill_min_slope to 3√2 × fill_min_slope on a cardinal step. The unit
includes the √2 because a unit of fill_min_slope × L would guarantee only
0.71 × fill_min_slope on a diagonal drop.
Four measures make the gradient hold on real float32 data.
- Quantised unit. Rounding
z_f + m × uto float32 cell by cell can shorten a drop. Measured on synthetic flats during development: one-unit drops of 0.978 u at 1500-4000 m on a 30 m grid, and, on 1 m cells at 4000 m (u = 1.4e-4 m, below the float32 ULP of 2.4e-4 m), 1262 of 3000 one-unit drops were zero. So for each flat,qis the float32 spacing (a power of two) at the top of its raise, andu_q = ceil(u / q) × q. Whereu < qthe gradient is coarser than requested (u_q = q), but it is never belowfill_min_slope. - Rim cap. A raised cell that reaches or passes a formerly higher neighbour
leaves that neighbour with no lower cell. Each flat's unit is therefore capped
at the minimum, over raised cells
cand higher neighboursh, of(z_h - z_f) / (mask(c) + 1), quantised down to a multiple ofq. A capped flat keeps the Barnes ordering but falls belowfill_min_slope. That is reported, not hidden. - Verification and fallback. Each gradient-resolved flat is checked on the float32 output: every NO_FLOW cell needs a strictly lower final neighbour, and no raised cell may reach a formerly higher neighbour. A flat that fails, or whose capped unit quantises to zero, is raised with float steps instead. That fallback is counted in the report.
- Across rounds. A later round can change the neighbourhood of a gradient cell (see the next section), so the gradient guarantee holds within the round that resolved the flat (see "What is guaranteed").
The raise is larger than the inline ramp's. Its upper bound per flat is
(2T + H) × √2 × fill_min_slope × L, where T and H are the towards and
away distances of the flat, against about fill_min_slope × path length for the
inline ramp. The measured maxima on the real DEMs are in the table below.
Real terrain: one pass is not routable
On real float32 DEMs a single Barnes pass leaves cells without a lower neighbour.
They are rim cells that a flat has overtopped by a few ULPs or millimetres, and
low edges whose neighbouring lower flat was raised past them. The counts below
are the cells left without descent after one pass, from
lsdtt-raster-preprocessing with fill_min_slope 1e-4:
| fixture | gradient | float step |
|---|---|---|
| alpine_500 | 27 | 28 |
| alpine_1000 | 20 | 21 |
| lakeland_500 | 918 | 918 |
| lakeland_1000 | 2469 | 2475 |
The D8 router then fails with could not assign a receiver. Synthetic fixtures,
whose rims sit metres above their flats, did not show this. RichDEM's
ResolveFlats also leaves cells without a lower neighbour on the fetched small
DEMs (oracle O2 counts 1 on alpine_small and 41 on lakeland_small).
The resolver runs up to four rounds:
- Round 1 is the Barnes pass described above.
- If any cell still has no lower neighbour, the whole surface is refilled
with
fill_pits_reconstruction. That only raises pits and the cells that drain into them, to their spill level. A cell that already drains is not changed. - The flats are resolved again on the refilled surface, with the same method, unit, cap, verification and guard. From round 2, low edges are not raised; only NO_FLOW cells (mask 4 or more) are. Raising a low edge that belongs to an already resolved flat would strand that flat again.
- Repeat from step 2 until no cell lacks a lower neighbour, or four rounds have run.
- Final pass. If cells remain after the last round, one priority-flood pass, seeded at the exits, raises only a cell that is not above the cell it is reached from. It raises it by one float32 step (2^-23 m below 1 m, with the guard). A cell with a strictly descending path to an exit is not changed.
No cell is ever lowered, by any round or by the final pass. The resolver checks
this and throws if it does not hold. Measured by lsdtt-raster-preprocessing on
the real DEMs (one run per row, deterministic counts):
| fixture | option | rounds (cells without descent after each round) | changed after round 1 (max extra raise) | final pass cells (max raise) | max raise | capped / float-step fallback | min effective gradient (m/m) | cells below min slope / NO_FLOW |
|---|---|---|---|---|---|---|---|---|
| alpine_500 | gradient | 4 (27, 16, 9, 2) | 60 (0.042 m) | 2 (0.98 mm) | 0.220 m | 90 / 19 | 1.15e-5 | 835 / 8260 |
| alpine_500 | float step | 4 (28, 15, 9, 2) | 62 (0.042 m) | 2 (0.98 mm) | 0.096 m | - | - | - |
| alpine_1000 | gradient | 2 (20, 0) | 20 (0.028 m) | 0 | 0.352 m | 261 / 56 | 1.15e-5 | 4784 / 24338 |
| alpine_1000 | float step | 2 (21, 0) | 25 (0.028 m) | 0 | 0.096 m | - | - | - |
| lakeland_500 | gradient | 4 (918, 388, 489, 374) | 10199 (0.020 m) | 5898 (3.1 mm) | 0.159 m | 1222 / 314 | 7.2e-7 | 21775 / 66374 |
| lakeland_500 | float step | 4 (918, 386, 488, 373) | 10199 (0.012 m) | 5912 (5.0 mm) | 0.014 m | - | - | - |
| lakeland_1000 | gradient | 4 (2469, 403, 642, 480) | 11209 (0.046 m) | 5618 (2.6 mm) | 0.192 m | 4969 / 1722 | 7.2e-7 | 67029 / 275305 |
| lakeland_1000 | float step | 4 (2475, 406, 642, 487) | 11260 (0.043 m) | 5629 (2.6 mm) | 0.044 m | - | - | - |
The final pass ran on alpine_500 and on both lakeland DEMs. On alpine_1000 the rounds alone were enough.
What is guaranteed
Routable. With the defaults (four rounds and the final pass), both barnes
options return a surface in which no valid cell, other than an exit, lacks a
strictly lower valid neighbour. The D8 router accepts it. This is tested on the
hermetic real-terrain crop and on the fetched real DEMs through
lsdtt-drainage-analysis, which exits 0.
Gradient, within a round. Every D8 step of every uncapped gradient-resolved
flat has slope at least fill_min_slope, in the round that resolved it. The
guarantee is not complete overall:
- capped flats fall below
fill_min_slopeby design; - a later round, or the final pass, can raise the receiver of a gradient cell and reduce its slope.
So the report counts the gradient cells below fill_min_slope (Cells below min
slope) and gives the minimum gradient measured on the final surface. On the real
DEMs above, 10 % to 33 % of the NO_FLOW cells end below fill_min_slope, and the
minimum measured gradient is 1.15e-5 m/m on the alpine DEMs and 7.2e-7 m/m on
the lakeland DEMs. In short, the gradient is at least fill_min_slope where the
rim allows it, and the cells where it does not are counted.
Float step, elevations. Elevation changes are as in the table in Option 1: millimetres to centimetres on most flats, metres on large high flats.
Interior nodata. A flat next to an interior hole drains into it, and the
hole's neighbours are never stranded. This assumes interior_nodata_is_outlet
is true, which is the flow-routing default and what all the CLIs set. With it
false, a flat next to a hole can be a sink.
Relation to RichDEM
- On flats at |z| ≥ 1 m, round 1 of
barnes2014b_float_stepis bit-for-bit RichDEMResolveFlatson float32. On guarded flats (|z| < 1 m) it differs by design. - The later rounds and the final pass do work that RichDEM does not do. On the lakeland 500 and 1000 DEMs they change 10,199 to 11,260 cells after round 1 (the table above). The final output therefore equals RichDEM only where no later round changed a cell.
- RichDEM's "Cells inappropriately raised above surrounding terrain" counts
ordered neighbour pairs (c, n) with z(n) > z(c) before and z'(c) ≥ z'(n) after,
so the count uses
≥. Where two raised flats touch, RichDEM's printed count depends on its loop order. On one real crop it prints 367 where a recount from its own output gives 364. The round-1 count of lsdtt3 is compared with the recount.
Large MFD exponents (underflow fallback)
The MFD weight of each receiver is a power of its slope, computed in float32.
On gentle surfaces with a large exponent the float32 power underflows (a slope of
about 1e-7 with an exponent of 6.5 or more). The weights can then sum to zero, in
which case the cell silently becomes a sink, or to a subnormal number, or
1 / total overflows. This failure is not specific to the float step. With the
default fill at exponent 8 the earlier code already failed on the alpine and
lakeland small fixtures, and it left silent sinks on the lakeland fixture.
The fix applies only when the summed weight is not a normal, finite float. Then
the cell's weights are recomputed as (S_k / S_max)^p and normalised. Normalisation
removes the common factor, so the weights are the same. Every other cell is
computed exactly as before. Outputs are bitwise unchanged at exponents 1 and 4 on
the valley, alpine small and lakeland small fixtures, and at exponent 8 on the
valley. The runs that failed before (exponent 8 on the two real fixtures) now
complete.
s_vector rationale (D8 only)
s_vector is the key D8 indexing feature in lsdtt3:
- Stores a preorder DFS layout of each drainage tree.
- Each cell
igetsstart = s_vector_start[i]andsize = s_vector_size[i]. - The upslope set of
iis the contiguous interval[start, start + size)ins_vector. is_upslope_of(a, b)becomes an interval-membership check (O(1)).
Why this matters:
- Repeated upslope queries (chi, basin summaries, thresholded source finding, many cosmogenic operations) avoid repeated graph searches.
- Memory is paid once at build time; query-time cost is predictable.
What it costs at build time
The S-vector is not free, and it is worth knowing how much of a D8 build it
accounts for. Measured on a 1000×1000 filled tile, single-threaded, by toggling
each lsdtt_flowinfo_options flag:
| configuration | build time |
|---|---|
| all on (what the CLIs request) | 186.4 ms |
| without the acyclicity check | 190.0 ms (free) |
| without the topological order | 178.3 ms |
| without the S-vector | 80.1 ms |
| receivers only | 52.4 ms |
The S-vector is ~106 ms of the 186 ms — more than everything else combined.
Because get_drainage_area_raster() reads the precomputed s_vector_size
intervals, the drainage-area call itself then costs ~1.4 ms: the accumulation
work was already paid for during the build. Accumulating over the topological
order instead gives the identical raster (0 differing cells of 1,000,000) with
a cheaper total:
| route | build | accumulate | total |
|---|---|---|---|
s_vector subtree sizes |
0.1934 s | 0.0016 s | 0.1951 s |
topological order + accumulate() |
0.0941 s | 0.0400 s | 0.1341 s |
This is deliberately not changed. If drainage area were the only product,
the second route would be 1.45× faster — but the S-vector is what makes
is_upslope_of an O(1) interval test, and chi analysis, basin summaries,
source finding and the cosmogenic routines all depend on it. Building it once
during routing is cheaper than reconstructing upslope sets on demand later.
The consequence for benchmarking is that lsdtt3's D8 accumulation timing includes work no other tool performs. That asymmetry is recorded in the benchmark case notes rather than engineered away.
For D-infinity/MFD, upslope sets overlap fractionally, so an equivalent contiguous interval index is not available in the same form.
64-bit indexing policy and rationale
lsdtt3 uses 64-bit flat indices in flow-routing objects:
- Removes structural 32-bit index ceilings for very large rasters.
- Keeps indexing semantics uniform across analyses.
- Trades some per-cell bytes for scalability beyond the practical limits seen in older 32-bit-indexed designs.
See overview.md (Memory use and Very large DEMs) for measured and derived
capacity/memory discussion. The use of a 64 bit integer has a memory cost, but lsdtt3
is aimed at processing very large DEMs, so we don't want to run out of indices!!
Memory: what lsdtt3 changed from LSDTopoTools2
Both libraries build the same D8 topology. They store it very differently, and the difference is mostly a matter of what is kept versus what is recomputed or never needed.
Writing n for grid cells and m for valid (non-nodata) cells:
LSDTopoTools2 (LSDFlowInfo, protected members):
| structure | type | extent | bytes |
|---|---|---|---|
NodeIndex |
Array2D<int> |
n | 4n |
FlowDirection |
Array2D<int> |
n | 4n |
FlowLengthCode |
Array2D<int> |
n | 4n |
RowIndex, ColIndex |
vector<int> x2 |
m | 8m |
NDonorsVector, DeltaVector, DonorStackVector |
vector<int> x3 |
m | 12m |
ReceiverVector |
vector<int> |
m | 4m |
SVector, SVectorIndex, NContributingNodes, BLBasinVector |
vector<int> x4 |
m | 16m |
roughly 12n + 40m bytes, or about 52 bytes per cell when most cells are valid.
lsdtt3 (lsdtt_flowinfo, private members):
| structure | type | extent | bytes | when |
|---|---|---|---|---|
mask_ |
uint8 |
n | 1n | always |
receiver_ |
int64 |
n | 8n | always |
receiver_dir_ |
uint8 |
n | 1n | always |
donor_count_ |
uint8 |
n | 1n | always |
topological_order_ |
int64 |
m | 8m | released if not requested |
s_vector_ + s_vector_start_ + s_vector_size_ |
int64/int64/uint64 |
m + n + n | 8m + 16n | only if built |
roughly 11 bytes per cell for the core graph, 19 with a retained topological order, and 43 with the O(1) upslope index.
Where the savings come from
- The flat index is the node id. LSDTopoTools2 maintains a
(row, col) -> NodeIndexmap plusRowIndex/ColIndexto invert it.lsdtt3addresses cells by flat index throughout, soNodeIndex,RowIndexandColIndex- three arrays - simply do not exist. FlowLengthCodeis derivable. LSDTopoTools2 stores a fullintgrid whose only values are 1 (cardinal) and 2 (diagonal).lsdtt3recovers that from the direction code, which it already has.- The donor CSR is never materialised.
NDonorsVector+DeltaVector+DonorStackVector- 12 bytes per cell - collapse to a singleuint8donor_count_. A D8 cell can have at most 8 donors, so a byte is sufficient, and Kahn's algorithm needs only the count. - Narrow types where the range allows. Direction codes and donor counts are
uint8, notint. - Optional structures are optional. The topological order is released after
build if the caller did not ask for it, and
s_vectoris built only on demand.
What it costs
64-bit indexing. receiver_ is int64 over the full grid - 8 bytes per cell
where LSDTopoTools2 spends 4 per valid node. That is a deliberate trade: it
removes the 32-bit ceiling on raster size (see below) at a cost the eliminations
above more than repay: the overall memory usage in lsdtt3 is less than LSDTopoTools2.
Net effect
The per-structure accounting above resolves to a single headline figure and a
caveat about how it was obtained. Rather than restate them here, both live in
overview.md under Memory use, which is
where a reader looking for the comparison will go first.
For cross-implementation comparisons against RichDEM, TopoToolbox and Whitebox
— timings, fixtures, machine and the caveats that go with each — see
testing_and_benchmarking.md,
which is the durable record. If you need publication-grade numbers, run the
repository benchmark harness and report the environment and fixture details
alongside them.
Depression hierarchy
lsdtt_depression_hierarchy_cpu is lsdtt3's port of the Barnes et al. (2020)
depression hierarchy: a tree in which every closed depression is a node, leaves
are the innermost hollows, and a parent is the meta-depression two children merge
into once water rises to their shared saddle. It underpins
lsdtt-internal-drainage's depression_hierarchy detector and the fill-spill-merge
routines in lsdtt-raster-preprocessing.
It is validated against the upstream reference implementation by
unit_depression_hierarchy_oracle, which compares depression count and the
per-depression fields (parent, lchild, rchild, pit_elev, out_elev,
cell_count, ocean_parent) across 25 committed test DEMs. Provenance of those
fixtures, including the upstream commits they pin, is in
tests/expected/depression_hierarchy/PROVENANCE.md.
Choosing a detector: what the hierarchy adds, and what it risks
lsdtt-internal-drainage offers two sink detectors through
sink_detection_algorithm_choice. The default is fill_difference.
fill_difference takes the difference between the filled and raw DEM and
treats each connected patch that filling would raise as one sink, keeping its
deepest cell as the pit. It is the method the published Greenland moulin work
used. It is fast, robust on rough surfaces, and has no notion of nesting: a lake
containing several hollows is one sink.
depression_hierarchy builds the full tree and adds three things
fill_difference cannot give you:
- Nesting. A lake containing several hollows becomes a parent with children,
reported through
parent_sink_idandhierarchy_level. Withsink_leaves_only: trueyou get the innermost hollows; withfalseyou also get the enclosing depressions. - True depression volume, accumulated over the tree, rather than a summed fill difference.
- Spill relationships between depressions — which depression overflows into which, and which reach the ocean directly.
Use it where that structure is the point: playas, karst, quarry benches, closed tectonic basins — terrain with real depressions inside depressions.
The risk: it can report nothing at all
On a rough surface, every innermost hollow is tiny. Combining
depression_hierarchy with sink_leaves_only: true and a
min_pixels_in_patch threshold can therefore filter out every sink and
return an empty result, with no error.
This is not hypothetical. On an 18 m Greenland ablation-zone DEM (17.4 Mpx) with
the configuration that reproduces the published work
(min_pixels_in_patch: 1000):
| detector | sinks found | runtime |
|---|---|---|
fill_difference |
143 | 5.4 s |
depression_hierarchy |
0 | 24.6 s |
The hierarchy is not wrong there — it finds 468,878 leaf depressions. But every one is a metres-across dimple in the ice, none reaches 1000 pixels, and the size filter removes them all. The fill-difference patches, by contrast, are the filled lakes the analysis is actually about.
So before using depression_hierarchy on a noisy surface, either smooth the DEM
first, or filter on depth or volume rather than on pixel count, or set
sink_leaves_only: false so enclosing depressions survive the threshold. If a
run reports zero sinks, this is the first thing to check.
Note that sink_leaves_only applies only to depression_hierarchy. Under
fill_difference there is no tree to take the leaves of, and setting it to
false prints a warning and is otherwise ignored.
Defining where water leaves the grid
The hierarchy needs to know which cells are "ocean" — the places water leaves the model. There are two ways to say it, and they can be combined:
- Boundary conditions (
boundary_conditions, default"oooo"). Valid cells that drain off an open grid edge become ocean seeds. This is lsdtt3's native convention and matches the rest of the flow-routing layer. - An explicit ocean definition, for DEMs where the sea is encoded in the data
rather than at the edge. These are library options, not
.paramkeys — they are reachable from the Python bindings (seedrainage) and from C++, butlsdtt-internal-drainagedoes not declare them, so putting them in a parameter file has no effect: nodata_is_ocean— treat NODATA cells as ocean. Common where a coastline or ice margin has been masked out.ocean_level— treat valid cells at exactly this elevation as ocean. Common where the sea is burned in as a constant.ocean_from_boundary— set tofalseto use only the explicit rules.
Seeds are the union of whichever rules are enabled. If none of them selects
any cell, build_depression_hierarchy() throws rather than guessing.
This matters beyond convenience: a DEM whose sea is a nodata region cannot be described by boundary conditions at all, because no valid cell touches the grid edge. Upstream Barnes takes an ocean mask for the same reason.
Depressions that spill at the same height
Imagine slowly raising the water level across a landscape. Each depression fills until it reaches its lowest rim, then spills over and joins whatever is next door. The hierarchy records the order in which those joins happen: the depression that spills first sits lowest in the tree.
That ordering is obvious when every rim is at a different height. It is not obvious when two rims are at the same height. If two depressions would spill at exactly the same moment, which one joins first? Nothing in the landscape decides it — the answer has to come from the software.
Equal rim heights are common rather than exotic. Any DEM stored as whole-number elevations produces them constantly, and so does any flat saddle.
What the original code does. It sorts the spill points by height and takes them in that order. When two have the same height, it leaves their relative order to whatever the sorting routine happens to do — which in practice depends on the order items came out of an internal lookup table. That order is not defined by anything meaningful. It can differ between one computer and another, between compiler versions, or even if the table happens to be a different size.
What lsdtt3 does. When two spill points are at the same height, lsdtt3 compares the identifying numbers of the depressions involved, and then the position of the spill cell itself, and uses that to decide. These are arbitrary tie-breakers, but they are fixed: the same DEM gives the same tree on any machine, every time. For a tool producing published results, reproducibility is worth more than matching another implementation's accident.
What this costs. On a DEM with equal-height rims, lsdtt3 and the original build different trees — and both are correct. They disagree about the order of events that genuinely happened simultaneously.
This shows up in testing. Of the 25 reference DEMs checked against the original implementation, 8 produce a different tree. In every one of those 8:
- the depressions themselves are identical — same locations, same floor and rim elevations, same number of cells in each;
- only the way they are linked together differs.
In five of the eight, every spill point in the DEM is at the same elevation, so there is no correct nesting to find at all; any tree is as good as any other.
Because the depressions agree even when the tree does not, the test on those 8 DEMs checks the depressions and ignores the tree, rather than skipping the DEM. That way a genuine mistake in finding depressions would still be caught.
Multipart footprints
A sink's cells need not be contiguous. When min_pixels_in_patch filters out a
small hollow, its cells are attributed to the nearest surviving ancestor, and
that ancestor's footprint can end up in two or more pieces separated by its other
children. sink_depressions_to_polygons() emits one polygon record per
connected part, all carrying the same sink attributes, so no terrain is lost.
test_min_pixels_conserves_terrain pins the invariant: the union of all reported
footprints must not change when the size filter is applied, because the filter
merges sinks and never removes ground.
Important behavioral differences
Why RichDEM's D8 differs from lsdtt3 and TopoToolbox
RichDEM should not be used as the reference for steepest-descent D8. This is a genuine definitional difference, not an implementation bug, and it is worth stating precisely because it has been described loosely elsewhere.
The mechanism. richdem/flowmet/d8_flowdirs.hpp, d8_FlowDir():
T minimum_elevation = elevations(x,y);
int flowdir = NO_FLOW;
for(int n=1;n<=8;n++)
if( elevations(x+d8x[n],y+d8y[n]) < minimum_elevation
|| (elevations(x+d8x[n],y+d8y[n]) == minimum_elevation
&& flowdir>0 && flowdir%2==0 && n%2==1) )
{ minimum_elevation = ...; flowdir = n; }
The selection criterion is lowest elevation. Distance never enters. True D8
(O'Callaghan and Mark 1984, which both libraries cite) selects on drop divided
by distance, so a diagonal neighbour must be sqrt(2) times lower to win.
lsdtt3 and TopoToolbox both implement drop-over-distance; RichDEM does not.
A worked case. Cardinal south is 1.0 m lower; diagonal south-east is 1.2 m lower on a 1 m grid:
| drop | distance | gradient | chosen by | |
|---|---|---|---|---|
| cardinal S | 1.0 | 1.0 | 1.000 | lsdtt3, TopoToolbox |
| diagonal SE | 1.2 | 1.414 | 0.849 | RichDEM |
RichDEM takes the diagonal because it is lower; steepest descent takes the
cardinal because it is steeper. Verified directly against the RichDEM source at
commit af61c65: d8_FlowDir returns 6 (diagonal SE) for this configuration.
Two corrections to earlier descriptions in this repository. Both were checked
against the source and by running d8_FlowDir directly:
- The tie-break is often described as preferring diagonals. It does the
opposite. In RichDEM's numbering,
n8_diag = {false,false,true,false,true, false,true,false,true}- oddnis cardinal, even is diagonal - so the clauseflowdir%2==0 && n%2==1switches from a diagonal to a cardinal at equal elevation. RichDEM's own source carries the comment//TODO: What is this modulus stuff for?beside it. - RichDEM is often said to fail a planar-ramp test, producing diagonal
stripes. It does not. On a surface tilted purely north-south,
d8_FlowDirreturns 7 - cardinal south, the correct answer - because the tie-break above pulls it off the diagonal.
Neither correction weakens the conclusion. The divergence does not depend on ties
at all: it appears wherever the lowest neighbour is diagonal and a cardinal
neighbour is steeper, which is common on real terrain. Measured consequences on
alpine_small:
| peak D8 accumulation | |
|---|---|
| lsdtt3 | 41,437 |
| TopoToolbox | 41,437 |
| RichDEM | 40,446 |
with a D8 rank correlation of 0.51 against RichDEM, rising to 0.74 for D-infinity and 0.99 for MFD - dispersion washes the single-receiver choice out.
RichDEM remains a valid reference for depression handling, where lsdtt3 tests against it directly. It is only D8 flow direction that is incomparable.
TopoToolbox as a cross-check
lsdtt3 testing docs report that TopoToolbox's D8 behavior matches the
drop-over-distance steepest-descent rule and is used as an asserted comparison
for D8 accumulation checks.
Barnes / Lindsay preprocessing papers are upstream of routing
- Barnes et al. (2014) and Lindsay (2016/2020) in
lsdtt3apply to depression handling (fill/breach), i.e. conditioning the DEM before routing. - They are not alternative D8 routing equations.
- In low-relief settings, flat-resolution choices (
fill_min_slope, breach policy) can strongly influence subsequent D8 network realization.
Primary references
- O'Callaghan, J.F., and Mark, D.M. (1984). The extraction of drainage networks from digital elevation data. Computer Vision, Graphics, and Image Processing 28(3), 323-344.
- Tarboton, D.G. (1997). A new method for the determination of flow directions and upslope areas in grid digital elevation models. Water Resources Research 33(2), 309-319.
- Quinn, P., Beven, K., Chevallier, P., and Planchon, O. (1991). The prediction of hillslope flow paths for distributed hydrological modelling using digital terrain models. Hydrological Processes 5(1), 59-79.
- Freeman, T.G. (1991). Calculating catchment area with divergent flow based on a regular grid. Computers & Geosciences 17(3), 413-422.
- Barnes, R., Lehman, C., and Mulla, D. (2014). Priority-flood: An optimal depression-filling and watershed-labeling algorithm for digital elevation models. Computers & Geosciences 62, 117-127.
- Barnes, R., Lehman, C., and Mulla, D. (2014). An efficient assignment of
drainage direction over flat surfaces in raster digital elevation models.
Computers & Geosciences 62, 128-135. doi:10.1016/j.cageo.2013.01.009. The
flat-resolution mask behind
barnes2014b_gradientandbarnes2014b_float_step. - Barnes, R., Callaghan, K.L., and Wickert, A.D. (2021). Computing water flow through complex landscapes - Part 3: Fill-Spill-Merge: flow routing in depression hierarchies. Earth Surface Dynamics 9, 105-121. doi:10.5194/esurf-9-105-2021.
- Vincent, L. (1993). Morphological grayscale reconstruction in image analysis:
applications and efficient algorithms. IEEE Transactions on Image
Processing 2(2), 176-201. The
vincent1993fill. - Wang, L., and Liu, H. (2006). An efficient method for identifying and filling surface depressions in digital elevation models for hydrologic analysis and modelling. International Journal of Geographical Information Science 20(2), 193-213.
- Lindsay, J.B. (2016). Efficient hybrid breaching-filling sink removal methods for flow path enforcement in digital elevation models. Hydrological Processes 30(6), 846-857.
- Lindsay, J.B. (2020). Pit-centric depression removal methods. Geomorphometry 2020 Conference Proceedings.
- Braun, J., and Willett, S.D. (2013). A very efficient O(n), implicit and parallel method to solve the stream power equation governing fluvial incision and landscape evolution. Geomorphology 180-181, 170-179.
- Anand, S.K., Hooshyar, M., and Porporato, A. (2020). Linear layout of multiple flow-direction networks for landscape-evolution simulations. Environmental Modelling & Software 133, 104804.
- Kahn, A.B. (1962). Topological sorting of large networks. Communications of the ACM 5(11), 558-562. The ready-queue topological sort used to order both the D8 tree and the multi-receiver DAG.
- Gailleton, B., Steer, P., Davy, P., Schwanghart, W., and Bernard, T. (2024).
GraphFlood 1.0: an efficient algorithm to approximate 2D hydrodynamics for
landscape evolution models. Earth Surface Dynamics 12, 1295-1313. The
graph-based hydraulic approach behind
lsdtt_hydraulic_graph_cpu.
For implementation specifics, also see: