Repository files navigation

FlowTopo

testslicence: MIT

Orderings, layerings and partitions for D8 flow networks, in Python.

A D8 flow-direction grid fixes the order in which cells must be visited: a cell can be processed only after its upstream neighbours, or, for some kernels, only after its downstream one. FlowTopo computes that order once, from the flow-direction grid alone, and stores it as a reusable structure. Any routine that walks the network in drainage order (drainage area, flow length, stream order, routing) can then reuse the structure instead of rebuilding the traversal each time.

Three kinds of structure are provided:

  • serial orderings (three): a topological sort of the cells, walked in one pass on one core;
  • parallel layerings (three): cells grouped into layers so that cells in the same layer are independent of each other and can run on several threads;
  • spatial partitions (two): the network cut into independent subregions, one per processor.

Four kernels are bundled to exercise the structures: upstream drainage area, distance to outlet, longest upstream path and Strahler stream order. They are test cases, not the purpose of the package. Each kernel runs on every structure and the results are cross-checked.

In the snippets below, topo is a FlowTopo object built once from a flow-direction raster; Quick start shows how.

This repository holds the Python reference implementation and one worked example basin. The global 90 m products computed with the same method are released separately on Zenodo; see Global products. The companion paper (Jiang et al.) is in preparation and has no link yet.

A narrated video walks through the structures and the three ways of moving a value from a cell to its receiver: https://youtu.be/tE5K2wM3TTY. The animations in this README are reduced previews. Each has a full-size MP4 link beneath it, and all files are in docs/media.

Serial orderings

An ordering is a topological sort of the valid cells. One pass over it computes any kernel, because every cell is visited after whatever it needs. Which end that is depends on the kernel: drainage area needs a cell's donors first, flow length needs its receiver first. A sequence is stored in one direction and reversed on demand, and the kernels do the reversing.

topological sort from the sources
ordering="topo"
breadth-first from the pit
ordering="bfs"
depth-first from the pit
ordering="dfs"
a cell is appended once all its donors are donecells in order of hop count from the pitone tributary subtree at a time
full-size MP4full-size MP4full-size MP4

The three differ in memory access pattern. Depth-first has the lowest simulated L1 miss rate on the example basin: 10.55%, against 21.83% (breadth-first) and 37.02% (topological sort). Those are one alignment of the two arrays the traversal reads, the one where they collide in every cache set. Shift them apart and the numbers fall to about 8.8%, 14.2% and 36.9%. The order does not move, at any alignment tried.

Direction. An ordering is built in one direction and reversed on demand. Depth-first and breadth-first start at the pit, so they come out downstream to upstream (d2u, position 0 is a pit); the topological sort starts at the headwaters, so it comes out upstream to downstream (u2d, position 0 is a headwater). The two are reverses of each other.

Which one a kernel needs follows from the way its values travel. Drainage area, longest upstream path and Strahler order accumulate into the receiver and need u2d; distance to outlet reads from the receiver and needs d2u. The kernels flip the sequence for you, so topo.upstream_area(ordering="dfs") walks the depth-first order upstream to downstream even though it was built the other way. Ask for a direction explicitly with topo.ordering("dfs", "u2d").

Parallel layerings

A layering assigns every cell a layer index such that cells in the same layer are independent of each other. Layers run in order; cells within a layer run in parallel. Layer 0 holds the headwaters.

as soon as possible
layering="asap"
conflict-free downstream
layering="cfds"
as late as possible
layering="alap"
every cell in the earliest layer its donors allowas soon as possible, plus one rule: no two cells in a layer share a receiverevery cell in the latest layer possible
full-size MP4full-size MP4full-size MP4

The minimum layer count is set by the longest flow path. The conflict-free rule may add a few layers; on the example basin it adds none (949 layers for all three).

Layerings are built u2d, layer 0 at the headwaters. The downstream-propagating kernel needs them the other way round, and flips them itself; topo.decomposition("cfds", "d2u") gives that view directly.

Spatial partitions

A layering spreads work across threads that share memory. Splitting the network across processors needs a second cut, along the drainage hierarchy, so that no value crosses a subregion boundary while a kernel runs.

Set n_parts to the number of processors: one subregion each, so a subregion's working set stays in its own memory. The paper's benchmark uses four, because the server has four Xeon Platinum 8270 processors, each a NUMA node with 26 cores, and runs about 13 threads inside each subregion.

Two basins mapped onto two subregions.level="basin" keeps each basin whole, so the 62-cell basin and the 11-cell basin cannot be balanced. level="subbasin" walks up the dominant basin's mainstem, and a tributary subtree moves to the lighter subregion.

Whole basins cannot be split, so one large basin leaves the other processors idle. The bundled example is one basin, so it shows this plainly:

topo.partition(n_parts=4, level="basin")[1] # [93432, 0, 0, 0]topo.partition(n_parts=4, level="subbasin")[1] # [23121, 23121, 23121, 23120]

Subbasin-level walks upstream from the outlet, taking the larger tributary at each confluence. That isolates the mainstem; the tributary subtrees hanging off it are dealt to the lighter subregions, and the mainstem runs in a second stage once they finish. Its cells are marked flowtopo.MAINSTEM. That is why the four subregion loads above come to 92,483 rather than 93,432: the 949 mainstem cells are held back for the second stage and do not count as first-stage work.

Write conflicts

Whichever structure carries the traversal, a kernel still has to move a value from a cell to its receiver. There are three ways to do it:

pullatomic pushpush
each receiver reads its donors; needs the upstream tabledonors write through atomics; correct, but float sums are not reproducibledonors write directly; deterministic, no locks; requires the conflict-free layering
full-size MP4full-size MP4full-size MP4

A push is only safe if no two cells in a layer write to the same receiver. Conflict counts on the example basin (93,432 cells):

layeringconflicting writes inside a layer
as soon as possible12,122
conflict-free downstream0
as late as possible39,130

The count is a property of the layering and can be checked before running. A test run is not a reliable check: a race does not always trigger. Strahler order has no atomic form, because its confluence rule is a comparison rather than an addition, so its only parallel push is under the conflict-free layering.

Because no two cells in a layer share a receiver, the sums always happen in the same order: the conflict-free push returns bit-identical results at any thread count, which an atomic push cannot promise. If manner is not given, FlowTopo picks a safe one for the layering.

Which structure to use

From the paper's benchmark on the full 90 m network (22.2 billion cells, 65 regions):

  • One sweep, one core — the depth-first ordering. Up to 5.1× faster than the slowest serial ordering; subtree contiguity drops the L3 miss rate from 37% to about 6%. It is also the baseline to measure parallel speedup against; a slower baseline overstates the speedup.
  • Repeated traversal (calibration, ensembles) — the as-late-as-possible layering with pull. Fastest in parallel: each layer's working set stays in cache. Pull must store the donor table.
  • Non-linear kernels, or when RAM is tight — the conflict-free downstream layering with push. Lock-free and deterministic, stores only the receiver pointer, and the only parallel option for Strahler order.
  • Across processors — the subbasin partition, run at about 13 threads each, beyond which memory bandwidth rather than the algorithm bounds the speedup.

The structures are computed once from the static D8 field and reused without limit.

Those rankings come from the paper's C run at continental scale. This package is numba over numpy at a much smaller size, and it does not reproduce them term for term. On a 16-million-cell grid here, push under cfds is the fastest parallel form, and pull loses even to the serial sequence: building and reading the donor table costs more than ten threads save. The structures are the same either way. Which one wins depends on your machine and your grid, so measure with benchmark.py before choosing.

Install

pip install -e .# numpy + rasterio
pip install -e ".[speed]"# + numba, for threaded kernels

Needs Python 3.10 or newer. The tests run on 3.10, 3.11 and 3.12. Without numba everything still runs, in pure Python.

Quick start

importflowtopotopo=flowtopo.FlowTopo.from_raster("data/dir_example.tif")
upa=topo.upstream_area(ordering="dfs") # serialupa=topo.upstream_area(layering="cfds", manner="push") # a layer at a timeldn=topo.distance_to_outlet(ordering="dfs")
lup=topo.longest_upstream_path(ldn, ordering="dfs")
strord=topo.strahler_order(ordering="dfs",
channel_mask=topo.channel_mask(upa, 10.0))
part, load=topo.partition(n_parts=4, level="subbasin") # across processorstopo.to_2d(upa) # back on the grid

With numba, the same kernels with threads:

fromflowtopoimportparallelupa=parallel.upstream_area(topo, layering="cfds", manner="push")

Run everything on the example basin:

python example.py # every structure, kernel and manner; ends PASS or FAIL
python benchmark.py # serial vs threaded at several grid sizes

Documentation

Example data

data/dir_example.tif is the example basin of the paper: 292 rows by 614 columns at 3 arc-seconds, 93,432 valid cells, 731 km², cut from MERIT Hydro (Yamazaki et al., 2019). The GeoJSON files are the basin boundary and the outlet.

Any D8 GeoTIFF in the same convention works: codes are powers of two clockwise from east, with 0 and 255 terminal. The file's own nodata value is honoured too, which matters because 255 means a terminal here, not nodata.

python example.py --data my_dir.tif

Two ways to use this

Take the structures we release

The structures for the whole 90 m network are on Zenodo, one tile per hydrological region, ready to read. To compute anything on them you also need the flow-direction grid they are indexed against, and that is not redistributed here: get it from its authors at https://global-hydrodynamics.github.io/MERIT_Hydro/, under the CC BY-NC 4.0 terms they set.

Working out which of their files a region needs is the fiddly part, and https://fullhydro.org/fullbasin/regions/ does that for you. Pick a region on the map or in the table and it lists the MERIT Hydro tiles covering it, with the row and column offset to place each one. It does not host MERIT Hydro, only the list. The same page has all 96 region outlines as one GeoJSON, tile names and offsets included, for doing it in a script. Those 96 are 65 continental regions, two that straddle the antimeridian and 29 island groups; the products below cover the 65. Region boxes sit on whole degrees and one degree is exactly 1,200 cells at 3 arc-seconds, so a region cuts out of the global rasters by integer arithmetic, with no resampling.

You do not need a whole region. Clip whatever you like out of one and the structures clip with it: filter a released sequence to the cells you kept and it is still a topological sort of them, and a filtered layering keeps its layers mutually independent, the conflict-free guarantee included. That holds for any subset, because dropping cells from a valid order cannot put a cell before something it depends on, and dropping cells from a layer cannot make two of the survivors depend on each other. Checked on a 12,809-cell subbasin of the bundled example: all three orderings stayed valid, all three layerings kept their layers independent, cfds kept zero conflicts, and recomputing from scratch on the clip reproduced all 12,809 values bit for bit.

Clip however suits you: a basin, a rectangle, a country. Two things behave differently and are worth keeping apart.

The structures describe the network you hand over. Clip it and the structures of the clip are exact: still a topological sort, still layers of independent cells, still zero conflicts under cfds. Any clip, no exceptions.

A kernel answers a question about that same network. Drainage area asks how much area drains into a cell, and how much area is however much you supplied. Clip a catchment in half and the answer halves, not because the computation slipped but because you asked about a smaller catchment. The number is exactly right for the data it was given.

So the only question is whether your clip contains the whole catchment of the cells you care about. A basin does, by definition: clipping one out of the bundled example and recomputing reproduces every cell to the last decimal. A rectangle does for most of them. The cells it gets wrong are exactly the ones with catchment outside the cut, so the share depends on how much drainage the cut intercepts rather than on any fixed number: across thirteen rectangles through the bundled example, between 92.7% and 99.6% of the kept cells came back identical, median 97.8%. For the rest, the ones below a channel the cut crossed, the number describes your rectangle rather than the world.

Clip a rectangle when the area you kept is what you are studying. Clip whole basins, or read the values out of MERIT-DrainAttr, when the numbers have to mean what they mean globally.

Bring your own flow directions

Any D8 grid in this convention works. Build the eight structures yourself and compute on them:

importflowtopotopo=flowtopo.FlowTopo.from_raster("my_dir.tif")
seq=topo.ordering("dfs") # one of three orderingslayers, n=topo.layering("cfds") # one of three layeringspart, load=topo.partition(n_parts=4) # one of two partitionsupa=topo.upstream_area(ordering="dfs") # or any kernel, on any structure

This is the same code that produced the released structures, so a region you build yourself and a region you download are the same thing.

Work a region or a basin at a time rather than the globe. A region encloses only complete basins and stays within 38° × 38°, which keeps its cell indices inside 32-bit integers, which is why this package uses int32 throughout. The region boundaries are in MERIT-FullBasin, below.

Global products

This repository holds the method and one example basin. Applied to the whole 90 m MERIT Hydro network, it produces two Zenodo records, both as per-region GeoTIFFs for 65 regions.

The structures

MERIT-FlowTopo (10.5281/zenodo.20653058) holds the traversal structures themselves. Every region carries the depth-first sequence, the conflict-free downstream and as-late-as-possible layerings, and the subbasin partition; Region 43 (South China) carries all eight, so the alternatives can be compared somewhere. 978 GB uncompressed, 49 GB compressed.

All eight structures, drawn over the whole network: three serial orderings, three parallel layerings, two spatial partitions. The release itself carries four of them per region, and all eight for Region 43.

Because the D8 field does not change, these are computed once and reused without limit. That is the point: a cost every tool currently pays on every run becomes a read.

What the structures compute

MERIT-DrainAttr (10.5281/zenodo.20686664) is the four kernels run over those structures: a baseline, so the variables almost everyone wants need not be recomputed either.

Flow length downstream, flow length upstream and Strahler stream order for every region; upstream drainage area for Region 43 only, since MERIT Hydro already distributes it globally. 622 GB uncompressed, 60 GB compressed.

Anything else is one pass over a structure you already have: a different accumulation, a routing state, a variable nobody has asked for yet. That is why the release is the structures and not the variables.

The input partition

MERIT-FullBasin (10.5281/zenodo.20344112) divides the network into the 65 hydrologically independent regions everything above is organised by. It comes from the companion dataset, not from here.

Acknowledgements

MERIT Hydro (Yamazaki et al., 2019; 10.1029/2019WR024873) provides the flow-direction field everything here traverses: the example basin is cut from it, and the global products are built on its 90 m network. The bundled excerpt keeps its CC BY-NC 4.0 terms.

The representation this package works on comes from pyflwdir (D. Eilander, Deltares and the Institute for Environmental Studies, Vrije Universiteit Amsterdam; MIT licence; 10.5281/zenodo.4287337). What FlowTopo takes from it:

  • the flat downstream-pointer array — for every cell, the linear index of the cell it drains into, with a pit pointing at itself. Every structure and every kernel here is derived from that one array;
  • the D8 decoding conventions — codes as powers of two clockwise from east, 0 and 255 terminal, 247 nodata, and a cell draining off the grid or into nodata treated as a pit;
  • the donor-count array and its sentinel convention;
  • the chain-tracing rank and the breadth-first sequence builder, which appear here in the variants set out in docs/methods.md.

No pyflwdir source is included. The code here was written against those conventions rather than copied from them, and FlowTopo.from_d8 takes the same array pyflwdir.from_array(d8, ftype="d8") takes, so the two read the same rasters.

The three layerings, the conflict-free downstream rule, the propagation manners, the locality metrics and the benchmark drivers are this project's own.

Claude (Anthropic) helped prepare this repository: the Python implementation, the tests, the documentation and the packaging were drafted with it and checked by the authors, and the commit history records where. The structures, the algorithms and the results they produce are the authors' own work, described in the companion manuscript.

Verification

Expected outputs for the example basin are stored with the tests, so a fresh clone verifies itself:

pytest

170 tests. Every ordering, layering, partition, kernel and manner is cross-checked on the example basin, write-conflict counts included. The structures are checked against their definitions directly: an ordering is a topological sort, a layer is an antichain, a receiver comes after its donors, and all of that survives clipping a basin out of a region. Degenerate inputs get their own: an empty grid, a lone cell, networks with cycles. So do the API's promises: cached arrays are read-only, repeated calls agree bit for bit, accumulation keeps the precision it was given.

Those tests and example.py run on Python 3.10, 3.11 and 3.12 on every push. The badge at the top links to the runs.

Contact

Questions, problems and suggestions are welcome by email at lulu_jiang@pku.edu.cn, or as a GitHub issue. Email is the surer way to reach us.

Citing

The companion manuscript is MERIT-FlowTopo v1.0: a reusable computational foundation for hyperresolution hydrology on the global 90 m drainage network (Jiang et al., in preparation). A citation file will be added once it appears; until then, cite the manuscript.

Licence

MIT for the code; see LICENSE. The bundled MERIT Hydro excerpt keeps its own CC BY-NC 4.0 terms; see DATA_NOTICE.md.

About

Serial orderings, parallel layerings and spatial partitions for D8 flow networks, computed once from the flow-direction grid and reused by any drainage-order computation. Python reference implementation.

Topics

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

FlowTopo

testslicence: MIT

Orderings, layerings and partitions for D8 flow networks, in Python.

A D8 flow-direction grid fixes the order in which cells must be visited: a cell can be processed only after its upstream neighbours, or, for some kernels, only after its downstream one. FlowTopo computes that order once, from the flow-direction grid alone, and stores it as a reusable structure. Any routine that walks the network in drainage order (drainage area, flow length, stream order, routing) can then reuse the structure instead of rebuilding the traversal each time.

Three kinds of structure are provided:

  • serial orderings (three): a topological sort of the cells, walked in one pass on one core;
  • parallel layerings (three): cells grouped into layers so that cells in the same layer are independent of each other and can run on several threads;
  • spatial partitions (two): the network cut into independent subregions, one per processor.

Four kernels are bundled to exercise the structures: upstream drainage area, distance to outlet, longest upstream path and Strahler stream order. They are test cases, not the purpose of the package. Each kernel runs on every structure and the results are cross-checked.

In the snippets below, topo is a FlowTopo object built once from a flow-direction raster; Quick start shows how.

This repository holds the Python reference implementation and one worked example basin. The global 90 m products computed with the same method are released separately on Zenodo; see Global products. The companion paper (Jiang et al.) is in preparation and has no link yet.

A narrated video walks through the structures and the three ways of moving a value from a cell to its receiver: https://youtu.be/tE5K2wM3TTY. The animations in this README are reduced previews. Each has a full-size MP4 link beneath it, and all files are in docs/media.

Serial orderings

An ordering is a topological sort of the valid cells. One pass over it computes any kernel, because every cell is visited after whatever it needs. Which end that is depends on the kernel: drainage area needs a cell's donors first, flow length needs its receiver first. A sequence is stored in one direction and reversed on demand, and the kernels do the reversing.

topological sort from the sources
ordering="topo"
breadth-first from the pit
ordering="bfs"
depth-first from the pit
ordering="dfs"
a cell is appended once all its donors are donecells in order of hop count from the pitone tributary subtree at a time
full-size MP4full-size MP4full-size MP4

The three differ in memory access pattern. Depth-first has the lowest simulated L1 miss rate on the example basin: 10.55%, against 21.83% (breadth-first) and 37.02% (topological sort). Those are one alignment of the two arrays the traversal reads, the one where they collide in every cache set. Shift them apart and the numbers fall to about 8.8%, 14.2% and 36.9%. The order does not move, at any alignment tried.

Direction. An ordering is built in one direction and reversed on demand. Depth-first and breadth-first start at the pit, so they come out downstream to upstream (d2u, position 0 is a pit); the topological sort starts at the headwaters, so it comes out upstream to downstream (u2d, position 0 is a headwater). The two are reverses of each other.

Which one a kernel needs follows from the way its values travel. Drainage area, longest upstream path and Strahler order accumulate into the receiver and need u2d; distance to outlet reads from the receiver and needs d2u. The kernels flip the sequence for you, so topo.upstream_area(ordering="dfs") walks the depth-first order upstream to downstream even though it was built the other way. Ask for a direction explicitly with topo.ordering("dfs", "u2d").

Parallel layerings

A layering assigns every cell a layer index such that cells in the same layer are independent of each other. Layers run in order; cells within a layer run in parallel. Layer 0 holds the headwaters.

as soon as possible
layering="asap"
conflict-free downstream
layering="cfds"
as late as possible
layering="alap"
every cell in the earliest layer its donors allowas soon as possible, plus one rule: no two cells in a layer share a receiverevery cell in the latest layer possible
full-size MP4full-size MP4full-size MP4

The minimum layer count is set by the longest flow path. The conflict-free rule may add a few layers; on the example basin it adds none (949 layers for all three).

Layerings are built u2d, layer 0 at the headwaters. The downstream-propagating kernel needs them the other way round, and flips them itself; topo.decomposition("cfds", "d2u") gives that view directly.

Spatial partitions

A layering spreads work across threads that share memory. Splitting the network across processors needs a second cut, along the drainage hierarchy, so that no value crosses a subregion boundary while a kernel runs.

Set n_parts to the number of processors: one subregion each, so a subregion's working set stays in its own memory. The paper's benchmark uses four, because the server has four Xeon Platinum 8270 processors, each a NUMA node with 26 cores, and runs about 13 threads inside each subregion.

Two basins mapped onto two subregions.level="basin" keeps each basin whole, so the 62-cell basin and the 11-cell basin cannot be balanced. level="subbasin" walks up the dominant basin's mainstem, and a tributary subtree moves to the lighter subregion.

Whole basins cannot be split, so one large basin leaves the other processors idle. The bundled example is one basin, so it shows this plainly:

topo.partition(n_parts=4, level="basin")[1] # [93432, 0, 0, 0]topo.partition(n_parts=4, level="subbasin")[1] # [23121, 23121, 23121, 23120]

Subbasin-level walks upstream from the outlet, taking the larger tributary at each confluence. That isolates the mainstem; the tributary subtrees hanging off it are dealt to the lighter subregions, and the mainstem runs in a second stage once they finish. Its cells are marked flowtopo.MAINSTEM. That is why the four subregion loads above come to 92,483 rather than 93,432: the 949 mainstem cells are held back for the second stage and do not count as first-stage work.

Write conflicts

Whichever structure carries the traversal, a kernel still has to move a value from a cell to its receiver. There are three ways to do it:

pullatomic pushpush
each receiver reads its donors; needs the upstream tabledonors write through atomics; correct, but float sums are not reproducibledonors write directly; deterministic, no locks; requires the conflict-free layering
full-size MP4full-size MP4full-size MP4

A push is only safe if no two cells in a layer write to the same receiver. Conflict counts on the example basin (93,432 cells):

layeringconflicting writes inside a layer
as soon as possible12,122
conflict-free downstream0
as late as possible39,130

The count is a property of the layering and can be checked before running. A test run is not a reliable check: a race does not always trigger. Strahler order has no atomic form, because its confluence rule is a comparison rather than an addition, so its only parallel push is under the conflict-free layering.

Because no two cells in a layer share a receiver, the sums always happen in the same order: the conflict-free push returns bit-identical results at any thread count, which an atomic push cannot promise. If manner is not given, FlowTopo picks a safe one for the layering.

Which structure to use

From the paper's benchmark on the full 90 m network (22.2 billion cells, 65 regions):

  • One sweep, one core — the depth-first ordering. Up to 5.1× faster than the slowest serial ordering; subtree contiguity drops the L3 miss rate from 37% to about 6%. It is also the baseline to measure parallel speedup against; a slower baseline overstates the speedup.
  • Repeated traversal (calibration, ensembles) — the as-late-as-possible layering with pull. Fastest in parallel: each layer's working set stays in cache. Pull must store the donor table.
  • Non-linear kernels, or when RAM is tight — the conflict-free downstream layering with push. Lock-free and deterministic, stores only the receiver pointer, and the only parallel option for Strahler order.
  • Across processors — the subbasin partition, run at about 13 threads each, beyond which memory bandwidth rather than the algorithm bounds the speedup.

The structures are computed once from the static D8 field and reused without limit.

Those rankings come from the paper's C run at continental scale. This package is numba over numpy at a much smaller size, and it does not reproduce them term for term. On a 16-million-cell grid here, push under cfds is the fastest parallel form, and pull loses even to the serial sequence: building and reading the donor table costs more than ten threads save. The structures are the same either way. Which one wins depends on your machine and your grid, so measure with benchmark.py before choosing.

Install

pip install -e .# numpy + rasterio
pip install -e ".[speed]"# + numba, for threaded kernels

Needs Python 3.10 or newer. The tests run on 3.10, 3.11 and 3.12. Without numba everything still runs, in pure Python.

Quick start

importflowtopotopo=flowtopo.FlowTopo.from_raster("data/dir_example.tif")
upa=topo.upstream_area(ordering="dfs") # serialupa=topo.upstream_area(layering="cfds", manner="push") # a layer at a timeldn=topo.distance_to_outlet(ordering="dfs")
lup=topo.longest_upstream_path(ldn, ordering="dfs")
strord=topo.strahler_order(ordering="dfs",
channel_mask=topo.channel_mask(upa, 10.0))
part, load=topo.partition(n_parts=4, level="subbasin") # across processorstopo.to_2d(upa) # back on the grid

With numba, the same kernels with threads:

fromflowtopoimportparallelupa=parallel.upstream_area(topo, layering="cfds", manner="push")

Run everything on the example basin:

python example.py # every structure, kernel and manner; ends PASS or FAIL
python benchmark.py # serial vs threaded at several grid sizes

Documentation

Example data

data/dir_example.tif is the example basin of the paper: 292 rows by 614 columns at 3 arc-seconds, 93,432 valid cells, 731 km², cut from MERIT Hydro (Yamazaki et al., 2019). The GeoJSON files are the basin boundary and the outlet.

Any D8 GeoTIFF in the same convention works: codes are powers of two clockwise from east, with 0 and 255 terminal. The file's own nodata value is honoured too, which matters because 255 means a terminal here, not nodata.

python example.py --data my_dir.tif

Two ways to use this

Take the structures we release

The structures for the whole 90 m network are on Zenodo, one tile per hydrological region, ready to read. To compute anything on them you also need the flow-direction grid they are indexed against, and that is not redistributed here: get it from its authors at https://global-hydrodynamics.github.io/MERIT_Hydro/, under the CC BY-NC 4.0 terms they set.

Working out which of their files a region needs is the fiddly part, and https://fullhydro.org/fullbasin/regions/ does that for you. Pick a region on the map or in the table and it lists the MERIT Hydro tiles covering it, with the row and column offset to place each one. It does not host MERIT Hydro, only the list. The same page has all 96 region outlines as one GeoJSON, tile names and offsets included, for doing it in a script. Those 96 are 65 continental regions, two that straddle the antimeridian and 29 island groups; the products below cover the 65. Region boxes sit on whole degrees and one degree is exactly 1,200 cells at 3 arc-seconds, so a region cuts out of the global rasters by integer arithmetic, with no resampling.

You do not need a whole region. Clip whatever you like out of one and the structures clip with it: filter a released sequence to the cells you kept and it is still a topological sort of them, and a filtered layering keeps its layers mutually independent, the conflict-free guarantee included. That holds for any subset, because dropping cells from a valid order cannot put a cell before something it depends on, and dropping cells from a layer cannot make two of the survivors depend on each other. Checked on a 12,809-cell subbasin of the bundled example: all three orderings stayed valid, all three layerings kept their layers independent, cfds kept zero conflicts, and recomputing from scratch on the clip reproduced all 12,809 values bit for bit.

Clip however suits you: a basin, a rectangle, a country. Two things behave differently and are worth keeping apart.

The structures describe the network you hand over. Clip it and the structures of the clip are exact: still a topological sort, still layers of independent cells, still zero conflicts under cfds. Any clip, no exceptions.

A kernel answers a question about that same network. Drainage area asks how much area drains into a cell, and how much area is however much you supplied. Clip a catchment in half and the answer halves, not because the computation slipped but because you asked about a smaller catchment. The number is exactly right for the data it was given.

So the only question is whether your clip contains the whole catchment of the cells you care about. A basin does, by definition: clipping one out of the bundled example and recomputing reproduces every cell to the last decimal. A rectangle does for most of them. The cells it gets wrong are exactly the ones with catchment outside the cut, so the share depends on how much drainage the cut intercepts rather than on any fixed number: across thirteen rectangles through the bundled example, between 92.7% and 99.6% of the kept cells came back identical, median 97.8%. For the rest, the ones below a channel the cut crossed, the number describes your rectangle rather than the world.

Clip a rectangle when the area you kept is what you are studying. Clip whole basins, or read the values out of MERIT-DrainAttr, when the numbers have to mean what they mean globally.

Bring your own flow directions

Any D8 grid in this convention works. Build the eight structures yourself and compute on them:

importflowtopotopo=flowtopo.FlowTopo.from_raster("my_dir.tif")
seq=topo.ordering("dfs") # one of three orderingslayers, n=topo.layering("cfds") # one of three layeringspart, load=topo.partition(n_parts=4) # one of two partitionsupa=topo.upstream_area(ordering="dfs") # or any kernel, on any structure

This is the same code that produced the released structures, so a region you build yourself and a region you download are the same thing.

Work a region or a basin at a time rather than the globe. A region encloses only complete basins and stays within 38° × 38°, which keeps its cell indices inside 32-bit integers, which is why this package uses int32 throughout. The region boundaries are in MERIT-FullBasin, below.

Global products

This repository holds the method and one example basin. Applied to the whole 90 m MERIT Hydro network, it produces two Zenodo records, both as per-region GeoTIFFs for 65 regions.

The structures

MERIT-FlowTopo (10.5281/zenodo.20653058) holds the traversal structures themselves. Every region carries the depth-first sequence, the conflict-free downstream and as-late-as-possible layerings, and the subbasin partition; Region 43 (South China) carries all eight, so the alternatives can be compared somewhere. 978 GB uncompressed, 49 GB compressed.

All eight structures, drawn over the whole network: three serial orderings, three parallel layerings, two spatial partitions. The release itself carries four of them per region, and all eight for Region 43.

Because the D8 field does not change, these are computed once and reused without limit. That is the point: a cost every tool currently pays on every run becomes a read.

What the structures compute

MERIT-DrainAttr (10.5281/zenodo.20686664) is the four kernels run over those structures: a baseline, so the variables almost everyone wants need not be recomputed either.

Flow length downstream, flow length upstream and Strahler stream order for every region; upstream drainage area for Region 43 only, since MERIT Hydro already distributes it globally. 622 GB uncompressed, 60 GB compressed.

Anything else is one pass over a structure you already have: a different accumulation, a routing state, a variable nobody has asked for yet. That is why the release is the structures and not the variables.

The input partition

MERIT-FullBasin (10.5281/zenodo.20344112) divides the network into the 65 hydrologically independent regions everything above is organised by. It comes from the companion dataset, not from here.

Acknowledgements

MERIT Hydro (Yamazaki et al., 2019; 10.1029/2019WR024873) provides the flow-direction field everything here traverses: the example basin is cut from it, and the global products are built on its 90 m network. The bundled excerpt keeps its CC BY-NC 4.0 terms.

The representation this package works on comes from pyflwdir (D. Eilander, Deltares and the Institute for Environmental Studies, Vrije Universiteit Amsterdam; MIT licence; 10.5281/zenodo.4287337). What FlowTopo takes from it:

  • the flat downstream-pointer array — for every cell, the linear index of the cell it drains into, with a pit pointing at itself. Every structure and every kernel here is derived from that one array;
  • the D8 decoding conventions — codes as powers of two clockwise from east, 0 and 255 terminal, 247 nodata, and a cell draining off the grid or into nodata treated as a pit;
  • the donor-count array and its sentinel convention;
  • the chain-tracing rank and the breadth-first sequence builder, which appear here in the variants set out in docs/methods.md.

No pyflwdir source is included. The code here was written against those conventions rather than copied from them, and FlowTopo.from_d8 takes the same array pyflwdir.from_array(d8, ftype="d8") takes, so the two read the same rasters.

The three layerings, the conflict-free downstream rule, the propagation manners, the locality metrics and the benchmark drivers are this project's own.

Claude (Anthropic) helped prepare this repository: the Python implementation, the tests, the documentation and the packaging were drafted with it and checked by the authors, and the commit history records where. The structures, the algorithms and the results they produce are the authors' own work, described in the companion manuscript.

Verification

Expected outputs for the example basin are stored with the tests, so a fresh clone verifies itself:

pytest

170 tests. Every ordering, layering, partition, kernel and manner is cross-checked on the example basin, write-conflict counts included. The structures are checked against their definitions directly: an ordering is a topological sort, a layer is an antichain, a receiver comes after its donors, and all of that survives clipping a basin out of a region. Degenerate inputs get their own: an empty grid, a lone cell, networks with cycles. So do the API's promises: cached arrays are read-only, repeated calls agree bit for bit, accumulation keeps the precision it was given.

Those tests and example.py run on Python 3.10, 3.11 and 3.12 on every push. The badge at the top links to the runs.

Contact

Questions, problems and suggestions are welcome by email at lulu_jiang@pku.edu.cn, or as a GitHub issue. Email is the surer way to reach us.

Citing

The companion manuscript is MERIT-FlowTopo v1.0: a reusable computational foundation for hyperresolution hydrology on the global 90 m drainage network (Jiang et al., in preparation). A citation file will be added once it appears; until then, cite the manuscript.

Licence

MIT for the code; see LICENSE. The bundled MERIT Hydro excerpt keeps its own CC BY-NC 4.0 terms; see DATA_NOTICE.md.

About

Serial orderings, parallel layerings and spatial partitions for D8 flow networks, computed once from the flow-direction grid and reused by any drainage-order computation. Python reference implementation.

Topics

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

FlowTopo

testslicence: MIT

Orderings, layerings and partitions for D8 flow networks, in Python.

A D8 flow-direction grid fixes the order in which cells must be visited: a cell can be processed only after its upstream neighbours, or, for some kernels, only after its downstream one. FlowTopo computes that order once, from the flow-direction grid alone, and stores it as a reusable structure. Any routine that walks the network in drainage order (drainage area, flow length, stream order, routing) can then reuse the structure instead of rebuilding the traversal each time.

Three kinds of structure are provided:

  • serial orderings (three): a topological sort of the cells, walked in one pass on one core;
  • parallel layerings (three): cells grouped into layers so that cells in the same layer are independent of each other and can run on several threads;
  • spatial partitions (two): the network cut into independent subregions, one per processor.

Four kernels are bundled to exercise the structures: upstream drainage area, distance to outlet, longest upstream path and Strahler stream order. They are test cases, not the purpose of the package. Each kernel runs on every structure and the results are cross-checked.

In the snippets below, topo is a FlowTopo object built once from a flow-direction raster; Quick start shows how.

This repository holds the Python reference implementation and one worked example basin. The global 90 m products computed with the same method are released separately on Zenodo; see Global products. The companion paper (Jiang et al.) is in preparation and has no link yet.

A narrated video walks through the structures and the three ways of moving a value from a cell to its receiver: https://youtu.be/tE5K2wM3TTY. The animations in this README are reduced previews. Each has a full-size MP4 link beneath it, and all files are in docs/media.

Serial orderings

An ordering is a topological sort of the valid cells. One pass over it computes any kernel, because every cell is visited after whatever it needs. Which end that is depends on the kernel: drainage area needs a cell's donors first, flow length needs its receiver first. A sequence is stored in one direction and reversed on demand, and the kernels do the reversing.

topological sort from the sources
ordering="topo"
breadth-first from the pit
ordering="bfs"
depth-first from the pit
ordering="dfs"
a cell is appended once all its donors are donecells in order of hop count from the pitone tributary subtree at a time
full-size MP4full-size MP4full-size MP4

The three differ in memory access pattern. Depth-first has the lowest simulated L1 miss rate on the example basin: 10.55%, against 21.83% (breadth-first) and 37.02% (topological sort). Those are one alignment of the two arrays the traversal reads, the one where they collide in every cache set. Shift them apart and the numbers fall to about 8.8%, 14.2% and 36.9%. The order does not move, at any alignment tried.

Direction. An ordering is built in one direction and reversed on demand. Depth-first and breadth-first start at the pit, so they come out downstream to upstream (d2u, position 0 is a pit); the topological sort starts at the headwaters, so it comes out upstream to downstream (u2d, position 0 is a headwater). The two are reverses of each other.

Which one a kernel needs follows from the way its values travel. Drainage area, longest upstream path and Strahler order accumulate into the receiver and need u2d; distance to outlet reads from the receiver and needs d2u. The kernels flip the sequence for you, so topo.upstream_area(ordering="dfs") walks the depth-first order upstream to downstream even though it was built the other way. Ask for a direction explicitly with topo.ordering("dfs", "u2d").

Parallel layerings

A layering assigns every cell a layer index such that cells in the same layer are independent of each other. Layers run in order; cells within a layer run in parallel. Layer 0 holds the headwaters.

as soon as possible
layering="asap"
conflict-free downstream
layering="cfds"
as late as possible
layering="alap"
every cell in the earliest layer its donors allowas soon as possible, plus one rule: no two cells in a layer share a receiverevery cell in the latest layer possible
full-size MP4full-size MP4full-size MP4

The minimum layer count is set by the longest flow path. The conflict-free rule may add a few layers; on the example basin it adds none (949 layers for all three).

Layerings are built u2d, layer 0 at the headwaters. The downstream-propagating kernel needs them the other way round, and flips them itself; topo.decomposition("cfds", "d2u") gives that view directly.

Spatial partitions

A layering spreads work across threads that share memory. Splitting the network across processors needs a second cut, along the drainage hierarchy, so that no value crosses a subregion boundary while a kernel runs.

Set n_parts to the number of processors: one subregion each, so a subregion's working set stays in its own memory. The paper's benchmark uses four, because the server has four Xeon Platinum 8270 processors, each a NUMA node with 26 cores, and runs about 13 threads inside each subregion.

Two basins mapped onto two subregions.level="basin" keeps each basin whole, so the 62-cell basin and the 11-cell basin cannot be balanced. level="subbasin" walks up the dominant basin's mainstem, and a tributary subtree moves to the lighter subregion.

Whole basins cannot be split, so one large basin leaves the other processors idle. The bundled example is one basin, so it shows this plainly:

topo.partition(n_parts=4, level="basin")[1] # [93432, 0, 0, 0]topo.partition(n_parts=4, level="subbasin")[1] # [23121, 23121, 23121, 23120]

Subbasin-level walks upstream from the outlet, taking the larger tributary at each confluence. That isolates the mainstem; the tributary subtrees hanging off it are dealt to the lighter subregions, and the mainstem runs in a second stage once they finish. Its cells are marked flowtopo.MAINSTEM. That is why the four subregion loads above come to 92,483 rather than 93,432: the 949 mainstem cells are held back for the second stage and do not count as first-stage work.

Write conflicts

Whichever structure carries the traversal, a kernel still has to move a value from a cell to its receiver. There are three ways to do it:

pullatomic pushpush
each receiver reads its donors; needs the upstream tabledonors write through atomics; correct, but float sums are not reproducibledonors write directly; deterministic, no locks; requires the conflict-free layering
full-size MP4full-size MP4full-size MP4

A push is only safe if no two cells in a layer write to the same receiver. Conflict counts on the example basin (93,432 cells):

layeringconflicting writes inside a layer
as soon as possible12,122
conflict-free downstream0
as late as possible39,130

The count is a property of the layering and can be checked before running. A test run is not a reliable check: a race does not always trigger. Strahler order has no atomic form, because its confluence rule is a comparison rather than an addition, so its only parallel push is under the conflict-free layering.

Because no two cells in a layer share a receiver, the sums always happen in the same order: the conflict-free push returns bit-identical results at any thread count, which an atomic push cannot promise. If manner is not given, FlowTopo picks a safe one for the layering.

Which structure to use

From the paper's benchmark on the full 90 m network (22.2 billion cells, 65 regions):

  • One sweep, one core — the depth-first ordering. Up to 5.1× faster than the slowest serial ordering; subtree contiguity drops the L3 miss rate from 37% to about 6%. It is also the baseline to measure parallel speedup against; a slower baseline overstates the speedup.
  • Repeated traversal (calibration, ensembles) — the as-late-as-possible layering with pull. Fastest in parallel: each layer's working set stays in cache. Pull must store the donor table.
  • Non-linear kernels, or when RAM is tight — the conflict-free downstream layering with push. Lock-free and deterministic, stores only the receiver pointer, and the only parallel option for Strahler order.
  • Across processors — the subbasin partition, run at about 13 threads each, beyond which memory bandwidth rather than the algorithm bounds the speedup.

The structures are computed once from the static D8 field and reused without limit.

Those rankings come from the paper's C run at continental scale. This package is numba over numpy at a much smaller size, and it does not reproduce them term for term. On a 16-million-cell grid here, push under cfds is the fastest parallel form, and pull loses even to the serial sequence: building and reading the donor table costs more than ten threads save. The structures are the same either way. Which one wins depends on your machine and your grid, so measure with benchmark.py before choosing.

Install

pip install -e .# numpy + rasterio
pip install -e ".[speed]"# + numba, for threaded kernels

Needs Python 3.10 or newer. The tests run on 3.10, 3.11 and 3.12. Without numba everything still runs, in pure Python.

Quick start

importflowtopotopo=flowtopo.FlowTopo.from_raster("data/dir_example.tif")
upa=topo.upstream_area(ordering="dfs") # serialupa=topo.upstream_area(layering="cfds", manner="push") # a layer at a timeldn=topo.distance_to_outlet(ordering="dfs")
lup=topo.longest_upstream_path(ldn, ordering="dfs")
strord=topo.strahler_order(ordering="dfs",
channel_mask=topo.channel_mask(upa, 10.0))
part, load=topo.partition(n_parts=4, level="subbasin") # across processorstopo.to_2d(upa) # back on the grid

With numba, the same kernels with threads:

fromflowtopoimportparallelupa=parallel.upstream_area(topo, layering="cfds", manner="push")

Run everything on the example basin:

python example.py # every structure, kernel and manner; ends PASS or FAIL
python benchmark.py # serial vs threaded at several grid sizes

Documentation

Example data

data/dir_example.tif is the example basin of the paper: 292 rows by 614 columns at 3 arc-seconds, 93,432 valid cells, 731 km², cut from MERIT Hydro (Yamazaki et al., 2019). The GeoJSON files are the basin boundary and the outlet.

Any D8 GeoTIFF in the same convention works: codes are powers of two clockwise from east, with 0 and 255 terminal. The file's own nodata value is honoured too, which matters because 255 means a terminal here, not nodata.

python example.py --data my_dir.tif

Two ways to use this

Take the structures we release

The structures for the whole 90 m network are on Zenodo, one tile per hydrological region, ready to read. To compute anything on them you also need the flow-direction grid they are indexed against, and that is not redistributed here: get it from its authors at https://global-hydrodynamics.github.io/MERIT_Hydro/, under the CC BY-NC 4.0 terms they set.

Working out which of their files a region needs is the fiddly part, and https://fullhydro.org/fullbasin/regions/ does that for you. Pick a region on the map or in the table and it lists the MERIT Hydro tiles covering it, with the row and column offset to place each one. It does not host MERIT Hydro, only the list. The same page has all 96 region outlines as one GeoJSON, tile names and offsets included, for doing it in a script. Those 96 are 65 continental regions, two that straddle the antimeridian and 29 island groups; the products below cover the 65. Region boxes sit on whole degrees and one degree is exactly 1,200 cells at 3 arc-seconds, so a region cuts out of the global rasters by integer arithmetic, with no resampling.

You do not need a whole region. Clip whatever you like out of one and the structures clip with it: filter a released sequence to the cells you kept and it is still a topological sort of them, and a filtered layering keeps its layers mutually independent, the conflict-free guarantee included. That holds for any subset, because dropping cells from a valid order cannot put a cell before something it depends on, and dropping cells from a layer cannot make two of the survivors depend on each other. Checked on a 12,809-cell subbasin of the bundled example: all three orderings stayed valid, all three layerings kept their layers independent, cfds kept zero conflicts, and recomputing from scratch on the clip reproduced all 12,809 values bit for bit.

Clip however suits you: a basin, a rectangle, a country. Two things behave differently and are worth keeping apart.

The structures describe the network you hand over. Clip it and the structures of the clip are exact: still a topological sort, still layers of independent cells, still zero conflicts under cfds. Any clip, no exceptions.

A kernel answers a question about that same network. Drainage area asks how much area drains into a cell, and how much area is however much you supplied. Clip a catchment in half and the answer halves, not because the computation slipped but because you asked about a smaller catchment. The number is exactly right for the data it was given.

So the only question is whether your clip contains the whole catchment of the cells you care about. A basin does, by definition: clipping one out of the bundled example and recomputing reproduces every cell to the last decimal. A rectangle does for most of them. The cells it gets wrong are exactly the ones with catchment outside the cut, so the share depends on how much drainage the cut intercepts rather than on any fixed number: across thirteen rectangles through the bundled example, between 92.7% and 99.6% of the kept cells came back identical, median 97.8%. For the rest, the ones below a channel the cut crossed, the number describes your rectangle rather than the world.

Clip a rectangle when the area you kept is what you are studying. Clip whole basins, or read the values out of MERIT-DrainAttr, when the numbers have to mean what they mean globally.

Bring your own flow directions

Any D8 grid in this convention works. Build the eight structures yourself and compute on them:

importflowtopotopo=flowtopo.FlowTopo.from_raster("my_dir.tif")
seq=topo.ordering("dfs") # one of three orderingslayers, n=topo.layering("cfds") # one of three layeringspart, load=topo.partition(n_parts=4) # one of two partitionsupa=topo.upstream_area(ordering="dfs") # or any kernel, on any structure

This is the same code that produced the released structures, so a region you build yourself and a region you download are the same thing.

Work a region or a basin at a time rather than the globe. A region encloses only complete basins and stays within 38° × 38°, which keeps its cell indices inside 32-bit integers, which is why this package uses int32 throughout. The region boundaries are in MERIT-FullBasin, below.

Global products

This repository holds the method and one example basin. Applied to the whole 90 m MERIT Hydro network, it produces two Zenodo records, both as per-region GeoTIFFs for 65 regions.

The structures

MERIT-FlowTopo (10.5281/zenodo.20653058) holds the traversal structures themselves. Every region carries the depth-first sequence, the conflict-free downstream and as-late-as-possible layerings, and the subbasin partition; Region 43 (South China) carries all eight, so the alternatives can be compared somewhere. 978 GB uncompressed, 49 GB compressed.

All eight structures, drawn over the whole network: three serial orderings, three parallel layerings, two spatial partitions. The release itself carries four of them per region, and all eight for Region 43.

Because the D8 field does not change, these are computed once and reused without limit. That is the point: a cost every tool currently pays on every run becomes a read.

What the structures compute

MERIT-DrainAttr (10.5281/zenodo.20686664) is the four kernels run over those structures: a baseline, so the variables almost everyone wants need not be recomputed either.

Flow length downstream, flow length upstream and Strahler stream order for every region; upstream drainage area for Region 43 only, since MERIT Hydro already distributes it globally. 622 GB uncompressed, 60 GB compressed.

Anything else is one pass over a structure you already have: a different accumulation, a routing state, a variable nobody has asked for yet. That is why the release is the structures and not the variables.

The input partition

MERIT-FullBasin (10.5281/zenodo.20344112) divides the network into the 65 hydrologically independent regions everything above is organised by. It comes from the companion dataset, not from here.

Acknowledgements

MERIT Hydro (Yamazaki et al., 2019; 10.1029/2019WR024873) provides the flow-direction field everything here traverses: the example basin is cut from it, and the global products are built on its 90 m network. The bundled excerpt keeps its CC BY-NC 4.0 terms.

The representation this package works on comes from pyflwdir (D. Eilander, Deltares and the Institute for Environmental Studies, Vrije Universiteit Amsterdam; MIT licence; 10.5281/zenodo.4287337). What FlowTopo takes from it:

  • the flat downstream-pointer array — for every cell, the linear index of the cell it drains into, with a pit pointing at itself. Every structure and every kernel here is derived from that one array;
  • the D8 decoding conventions — codes as powers of two clockwise from east, 0 and 255 terminal, 247 nodata, and a cell draining off the grid or into nodata treated as a pit;
  • the donor-count array and its sentinel convention;
  • the chain-tracing rank and the breadth-first sequence builder, which appear here in the variants set out in docs/methods.md.

No pyflwdir source is included. The code here was written against those conventions rather than copied from them, and FlowTopo.from_d8 takes the same array pyflwdir.from_array(d8, ftype="d8") takes, so the two read the same rasters.

The three layerings, the conflict-free downstream rule, the propagation manners, the locality metrics and the benchmark drivers are this project's own.

Claude (Anthropic) helped prepare this repository: the Python implementation, the tests, the documentation and the packaging were drafted with it and checked by the authors, and the commit history records where. The structures, the algorithms and the results they produce are the authors' own work, described in the companion manuscript.

Verification

Expected outputs for the example basin are stored with the tests, so a fresh clone verifies itself:

pytest

170 tests. Every ordering, layering, partition, kernel and manner is cross-checked on the example basin, write-conflict counts included. The structures are checked against their definitions directly: an ordering is a topological sort, a layer is an antichain, a receiver comes after its donors, and all of that survives clipping a basin out of a region. Degenerate inputs get their own: an empty grid, a lone cell, networks with cycles. So do the API's promises: cached arrays are read-only, repeated calls agree bit for bit, accumulation keeps the precision it was given.

Those tests and example.py run on Python 3.10, 3.11 and 3.12 on every push. The badge at the top links to the runs.

Contact

Questions, problems and suggestions are welcome by email at lulu_jiang@pku.edu.cn, or as a GitHub issue. Email is the surer way to reach us.

Citing

The companion manuscript is MERIT-FlowTopo v1.0: a reusable computational foundation for hyperresolution hydrology on the global 90 m drainage network (Jiang et al., in preparation). A citation file will be added once it appears; until then, cite the manuscript.

Licence

MIT for the code; see LICENSE. The bundled MERIT Hydro excerpt keeps its own CC BY-NC 4.0 terms; see DATA_NOTICE.md.

About

Serial orderings, parallel layerings and spatial partitions for D8 flow networks, computed once from the flow-direction grid and reused by any drainage-order computation. Python reference implementation.

Topics

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

FlowTopo

testslicence: MIT

Orderings, layerings and partitions for D8 flow networks, in Python.

A D8 flow-direction grid fixes the order in which cells must be visited: a cell can be processed only after its upstream neighbours, or, for some kernels, only after its downstream one. FlowTopo computes that order once, from the flow-direction grid alone, and stores it as a reusable structure. Any routine that walks the network in drainage order (drainage area, flow length, stream order, routing) can then reuse the structure instead of rebuilding the traversal each time.

Three kinds of structure are provided:

  • serial orderings (three): a topological sort of the cells, walked in one pass on one core;
  • parallel layerings (three): cells grouped into layers so that cells in the same layer are independent of each other and can run on several threads;
  • spatial partitions (two): the network cut into independent subregions, one per processor.

Four kernels are bundled to exercise the structures: upstream drainage area, distance to outlet, longest upstream path and Strahler stream order. They are test cases, not the purpose of the package. Each kernel runs on every structure and the results are cross-checked.

In the snippets below, topo is a FlowTopo object built once from a flow-direction raster; Quick start shows how.

This repository holds the Python reference implementation and one worked example basin. The global 90 m products computed with the same method are released separately on Zenodo; see Global products. The companion paper (Jiang et al.) is in preparation and has no link yet.

A narrated video walks through the structures and the three ways of moving a value from a cell to its receiver: https://youtu.be/tE5K2wM3TTY. The animations in this README are reduced previews. Each has a full-size MP4 link beneath it, and all files are in docs/media.

Serial orderings

An ordering is a topological sort of the valid cells. One pass over it computes any kernel, because every cell is visited after whatever it needs. Which end that is depends on the kernel: drainage area needs a cell's donors first, flow length needs its receiver first. A sequence is stored in one direction and reversed on demand, and the kernels do the reversing.

topological sort from the sources
ordering="topo"
breadth-first from the pit
ordering="bfs"
depth-first from the pit
ordering="dfs"
a cell is appended once all its donors are donecells in order of hop count from the pitone tributary subtree at a time
full-size MP4full-size MP4full-size MP4

The three differ in memory access pattern. Depth-first has the lowest simulated L1 miss rate on the example basin: 10.55%, against 21.83% (breadth-first) and 37.02% (topological sort). Those are one alignment of the two arrays the traversal reads, the one where they collide in every cache set. Shift them apart and the numbers fall to about 8.8%, 14.2% and 36.9%. The order does not move, at any alignment tried.

Direction. An ordering is built in one direction and reversed on demand. Depth-first and breadth-first start at the pit, so they come out downstream to upstream (d2u, position 0 is a pit); the topological sort starts at the headwaters, so it comes out upstream to downstream (u2d, position 0 is a headwater). The two are reverses of each other.

Which one a kernel needs follows from the way its values travel. Drainage area, longest upstream path and Strahler order accumulate into the receiver and need u2d; distance to outlet reads from the receiver and needs d2u. The kernels flip the sequence for you, so topo.upstream_area(ordering="dfs") walks the depth-first order upstream to downstream even though it was built the other way. Ask for a direction explicitly with topo.ordering("dfs", "u2d").

Parallel layerings

A layering assigns every cell a layer index such that cells in the same layer are independent of each other. Layers run in order; cells within a layer run in parallel. Layer 0 holds the headwaters.

as soon as possible
layering="asap"
conflict-free downstream
layering="cfds"
as late as possible
layering="alap"
every cell in the earliest layer its donors allowas soon as possible, plus one rule: no two cells in a layer share a receiverevery cell in the latest layer possible
full-size MP4full-size MP4full-size MP4

The minimum layer count is set by the longest flow path. The conflict-free rule may add a few layers; on the example basin it adds none (949 layers for all three).

Layerings are built u2d, layer 0 at the headwaters. The downstream-propagating kernel needs them the other way round, and flips them itself; topo.decomposition("cfds", "d2u") gives that view directly.

Spatial partitions

A layering spreads work across threads that share memory. Splitting the network across processors needs a second cut, along the drainage hierarchy, so that no value crosses a subregion boundary while a kernel runs.

Set n_parts to the number of processors: one subregion each, so a subregion's working set stays in its own memory. The paper's benchmark uses four, because the server has four Xeon Platinum 8270 processors, each a NUMA node with 26 cores, and runs about 13 threads inside each subregion.

Two basins mapped onto two subregions.level="basin" keeps each basin whole, so the 62-cell basin and the 11-cell basin cannot be balanced. level="subbasin" walks up the dominant basin's mainstem, and a tributary subtree moves to the lighter subregion.

Whole basins cannot be split, so one large basin leaves the other processors idle. The bundled example is one basin, so it shows this plainly:

topo.partition(n_parts=4, level="basin")[1] # [93432, 0, 0, 0]topo.partition(n_parts=4, level="subbasin")[1] # [23121, 23121, 23121, 23120]

Subbasin-level walks upstream from the outlet, taking the larger tributary at each confluence. That isolates the mainstem; the tributary subtrees hanging off it are dealt to the lighter subregions, and the mainstem runs in a second stage once they finish. Its cells are marked flowtopo.MAINSTEM. That is why the four subregion loads above come to 92,483 rather than 93,432: the 949 mainstem cells are held back for the second stage and do not count as first-stage work.

Write conflicts

Whichever structure carries the traversal, a kernel still has to move a value from a cell to its receiver. There are three ways to do it:

pullatomic pushpush
each receiver reads its donors; needs the upstream tabledonors write through atomics; correct, but float sums are not reproducibledonors write directly; deterministic, no locks; requires the conflict-free layering
full-size MP4full-size MP4full-size MP4

A push is only safe if no two cells in a layer write to the same receiver. Conflict counts on the example basin (93,432 cells):

layeringconflicting writes inside a layer
as soon as possible12,122
conflict-free downstream0
as late as possible39,130

The count is a property of the layering and can be checked before running. A test run is not a reliable check: a race does not always trigger. Strahler order has no atomic form, because its confluence rule is a comparison rather than an addition, so its only parallel push is under the conflict-free layering.

Because no two cells in a layer share a receiver, the sums always happen in the same order: the conflict-free push returns bit-identical results at any thread count, which an atomic push cannot promise. If manner is not given, FlowTopo picks a safe one for the layering.

Which structure to use

From the paper's benchmark on the full 90 m network (22.2 billion cells, 65 regions):

  • One sweep, one core — the depth-first ordering. Up to 5.1× faster than the slowest serial ordering; subtree contiguity drops the L3 miss rate from 37% to about 6%. It is also the baseline to measure parallel speedup against; a slower baseline overstates the speedup.
  • Repeated traversal (calibration, ensembles) — the as-late-as-possible layering with pull. Fastest in parallel: each layer's working set stays in cache. Pull must store the donor table.
  • Non-linear kernels, or when RAM is tight — the conflict-free downstream layering with push. Lock-free and deterministic, stores only the receiver pointer, and the only parallel option for Strahler order.
  • Across processors — the subbasin partition, run at about 13 threads each, beyond which memory bandwidth rather than the algorithm bounds the speedup.

The structures are computed once from the static D8 field and reused without limit.

Those rankings come from the paper's C run at continental scale. This package is numba over numpy at a much smaller size, and it does not reproduce them term for term. On a 16-million-cell grid here, push under cfds is the fastest parallel form, and pull loses even to the serial sequence: building and reading the donor table costs more than ten threads save. The structures are the same either way. Which one wins depends on your machine and your grid, so measure with benchmark.py before choosing.

Install

pip install -e .# numpy + rasterio
pip install -e ".[speed]"# + numba, for threaded kernels

Needs Python 3.10 or newer. The tests run on 3.10, 3.11 and 3.12. Without numba everything still runs, in pure Python.

Quick start

importflowtopotopo=flowtopo.FlowTopo.from_raster("data/dir_example.tif")
upa=topo.upstream_area(ordering="dfs") # serialupa=topo.upstream_area(layering="cfds", manner="push") # a layer at a timeldn=topo.distance_to_outlet(ordering="dfs")
lup=topo.longest_upstream_path(ldn, ordering="dfs")
strord=topo.strahler_order(ordering="dfs",
channel_mask=topo.channel_mask(upa, 10.0))
part, load=topo.partition(n_parts=4, level="subbasin") # across processorstopo.to_2d(upa) # back on the grid

With numba, the same kernels with threads:

fromflowtopoimportparallelupa=parallel.upstream_area(topo, layering="cfds", manner="push")

Run everything on the example basin:

python example.py # every structure, kernel and manner; ends PASS or FAIL
python benchmark.py # serial vs threaded at several grid sizes

Documentation

Example data

data/dir_example.tif is the example basin of the paper: 292 rows by 614 columns at 3 arc-seconds, 93,432 valid cells, 731 km², cut from MERIT Hydro (Yamazaki et al., 2019). The GeoJSON files are the basin boundary and the outlet.

Any D8 GeoTIFF in the same convention works: codes are powers of two clockwise from east, with 0 and 255 terminal. The file's own nodata value is honoured too, which matters because 255 means a terminal here, not nodata.

python example.py --data my_dir.tif

Two ways to use this

Take the structures we release

The structures for the whole 90 m network are on Zenodo, one tile per hydrological region, ready to read. To compute anything on them you also need the flow-direction grid they are indexed against, and that is not redistributed here: get it from its authors at https://global-hydrodynamics.github.io/MERIT_Hydro/, under the CC BY-NC 4.0 terms they set.

Working out which of their files a region needs is the fiddly part, and https://fullhydro.org/fullbasin/regions/ does that for you. Pick a region on the map or in the table and it lists the MERIT Hydro tiles covering it, with the row and column offset to place each one. It does not host MERIT Hydro, only the list. The same page has all 96 region outlines as one GeoJSON, tile names and offsets included, for doing it in a script. Those 96 are 65 continental regions, two that straddle the antimeridian and 29 island groups; the products below cover the 65. Region boxes sit on whole degrees and one degree is exactly 1,200 cells at 3 arc-seconds, so a region cuts out of the global rasters by integer arithmetic, with no resampling.

You do not need a whole region. Clip whatever you like out of one and the structures clip with it: filter a released sequence to the cells you kept and it is still a topological sort of them, and a filtered layering keeps its layers mutually independent, the conflict-free guarantee included. That holds for any subset, because dropping cells from a valid order cannot put a cell before something it depends on, and dropping cells from a layer cannot make two of the survivors depend on each other. Checked on a 12,809-cell subbasin of the bundled example: all three orderings stayed valid, all three layerings kept their layers independent, cfds kept zero conflicts, and recomputing from scratch on the clip reproduced all 12,809 values bit for bit.

Clip however suits you: a basin, a rectangle, a country. Two things behave differently and are worth keeping apart.

The structures describe the network you hand over. Clip it and the structures of the clip are exact: still a topological sort, still layers of independent cells, still zero conflicts under cfds. Any clip, no exceptions.

A kernel answers a question about that same network. Drainage area asks how much area drains into a cell, and how much area is however much you supplied. Clip a catchment in half and the answer halves, not because the computation slipped but because you asked about a smaller catchment. The number is exactly right for the data it was given.

So the only question is whether your clip contains the whole catchment of the cells you care about. A basin does, by definition: clipping one out of the bundled example and recomputing reproduces every cell to the last decimal. A rectangle does for most of them. The cells it gets wrong are exactly the ones with catchment outside the cut, so the share depends on how much drainage the cut intercepts rather than on any fixed number: across thirteen rectangles through the bundled example, between 92.7% and 99.6% of the kept cells came back identical, median 97.8%. For the rest, the ones below a channel the cut crossed, the number describes your rectangle rather than the world.

Clip a rectangle when the area you kept is what you are studying. Clip whole basins, or read the values out of MERIT-DrainAttr, when the numbers have to mean what they mean globally.

Bring your own flow directions

Any D8 grid in this convention works. Build the eight structures yourself and compute on them:

importflowtopotopo=flowtopo.FlowTopo.from_raster("my_dir.tif")
seq=topo.ordering("dfs") # one of three orderingslayers, n=topo.layering("cfds") # one of three layeringspart, load=topo.partition(n_parts=4) # one of two partitionsupa=topo.upstream_area(ordering="dfs") # or any kernel, on any structure

This is the same code that produced the released structures, so a region you build yourself and a region you download are the same thing.

Work a region or a basin at a time rather than the globe. A region encloses only complete basins and stays within 38° × 38°, which keeps its cell indices inside 32-bit integers, which is why this package uses int32 throughout. The region boundaries are in MERIT-FullBasin, below.

Global products

This repository holds the method and one example basin. Applied to the whole 90 m MERIT Hydro network, it produces two Zenodo records, both as per-region GeoTIFFs for 65 regions.

The structures

MERIT-FlowTopo (10.5281/zenodo.20653058) holds the traversal structures themselves. Every region carries the depth-first sequence, the conflict-free downstream and as-late-as-possible layerings, and the subbasin partition; Region 43 (South China) carries all eight, so the alternatives can be compared somewhere. 978 GB uncompressed, 49 GB compressed.

All eight structures, drawn over the whole network: three serial orderings, three parallel layerings, two spatial partitions. The release itself carries four of them per region, and all eight for Region 43.

Because the D8 field does not change, these are computed once and reused without limit. That is the point: a cost every tool currently pays on every run becomes a read.

What the structures compute

MERIT-DrainAttr (10.5281/zenodo.20686664) is the four kernels run over those structures: a baseline, so the variables almost everyone wants need not be recomputed either.

Flow length downstream, flow length upstream and Strahler stream order for every region; upstream drainage area for Region 43 only, since MERIT Hydro already distributes it globally. 622 GB uncompressed, 60 GB compressed.

Anything else is one pass over a structure you already have: a different accumulation, a routing state, a variable nobody has asked for yet. That is why the release is the structures and not the variables.

The input partition

MERIT-FullBasin (10.5281/zenodo.20344112) divides the network into the 65 hydrologically independent regions everything above is organised by. It comes from the companion dataset, not from here.

Acknowledgements

MERIT Hydro (Yamazaki et al., 2019; 10.1029/2019WR024873) provides the flow-direction field everything here traverses: the example basin is cut from it, and the global products are built on its 90 m network. The bundled excerpt keeps its CC BY-NC 4.0 terms.

The representation this package works on comes from pyflwdir (D. Eilander, Deltares and the Institute for Environmental Studies, Vrije Universiteit Amsterdam; MIT licence; 10.5281/zenodo.4287337). What FlowTopo takes from it:

  • the flat downstream-pointer array — for every cell, the linear index of the cell it drains into, with a pit pointing at itself. Every structure and every kernel here is derived from that one array;
  • the D8 decoding conventions — codes as powers of two clockwise from east, 0 and 255 terminal, 247 nodata, and a cell draining off the grid or into nodata treated as a pit;
  • the donor-count array and its sentinel convention;
  • the chain-tracing rank and the breadth-first sequence builder, which appear here in the variants set out in docs/methods.md.

No pyflwdir source is included. The code here was written against those conventions rather than copied from them, and FlowTopo.from_d8 takes the same array pyflwdir.from_array(d8, ftype="d8") takes, so the two read the same rasters.

The three layerings, the conflict-free downstream rule, the propagation manners, the locality metrics and the benchmark drivers are this project's own.

Claude (Anthropic) helped prepare this repository: the Python implementation, the tests, the documentation and the packaging were drafted with it and checked by the authors, and the commit history records where. The structures, the algorithms and the results they produce are the authors' own work, described in the companion manuscript.

Verification

Expected outputs for the example basin are stored with the tests, so a fresh clone verifies itself:

pytest

170 tests. Every ordering, layering, partition, kernel and manner is cross-checked on the example basin, write-conflict counts included. The structures are checked against their definitions directly: an ordering is a topological sort, a layer is an antichain, a receiver comes after its donors, and all of that survives clipping a basin out of a region. Degenerate inputs get their own: an empty grid, a lone cell, networks with cycles. So do the API's promises: cached arrays are read-only, repeated calls agree bit for bit, accumulation keeps the precision it was given.

Those tests and example.py run on Python 3.10, 3.11 and 3.12 on every push. The badge at the top links to the runs.

Contact

Questions, problems and suggestions are welcome by email at lulu_jiang@pku.edu.cn, or as a GitHub issue. Email is the surer way to reach us.

Citing

The companion manuscript is MERIT-FlowTopo v1.0: a reusable computational foundation for hyperresolution hydrology on the global 90 m drainage network (Jiang et al., in preparation). A citation file will be added once it appears; until then, cite the manuscript.

Licence

MIT for the code; see LICENSE. The bundled MERIT Hydro excerpt keeps its own CC BY-NC 4.0 terms; see DATA_NOTICE.md.

About

Serial orderings, parallel layerings and spatial partitions for D8 flow networks, computed once from the flow-direction grid and reused by any drainage-order computation. Python reference implementation.

Topics

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

FlowTopo

testslicence: MIT

Orderings, layerings and partitions for D8 flow networks, in Python.

A D8 flow-direction grid fixes the order in which cells must be visited: a cell can be processed only after its upstream neighbours, or, for some kernels, only after its downstream one. FlowTopo computes that order once, from the flow-direction grid alone, and stores it as a reusable structure. Any routine that walks the network in drainage order (drainage area, flow length, stream order, routing) can then reuse the structure instead of rebuilding the traversal each time.

Three kinds of structure are provided:

  • serial orderings (three): a topological sort of the cells, walked in one pass on one core;
  • parallel layerings (three): cells grouped into layers so that cells in the same layer are independent of each other and can run on several threads;
  • spatial partitions (two): the network cut into independent subregions, one per processor.

Four kernels are bundled to exercise the structures: upstream drainage area, distance to outlet, longest upstream path and Strahler stream order. They are test cases, not the purpose of the package. Each kernel runs on every structure and the results are cross-checked.

In the snippets below, topo is a FlowTopo object built once from a flow-direction raster; Quick start shows how.

This repository holds the Python reference implementation and one worked example basin. The global 90 m products computed with the same method are released separately on Zenodo; see Global products. The companion paper (Jiang et al.) is in preparation and has no link yet.

A narrated video walks through the structures and the three ways of moving a value from a cell to its receiver: https://youtu.be/tE5K2wM3TTY. The animations in this README are reduced previews. Each has a full-size MP4 link beneath it, and all files are in docs/media.

Serial orderings

An ordering is a topological sort of the valid cells. One pass over it computes any kernel, because every cell is visited after whatever it needs. Which end that is depends on the kernel: drainage area needs a cell's donors first, flow length needs its receiver first. A sequence is stored in one direction and reversed on demand, and the kernels do the reversing.

topological sort from the sources
ordering="topo"
breadth-first from the pit
ordering="bfs"
depth-first from the pit
ordering="dfs"
a cell is appended once all its donors are donecells in order of hop count from the pitone tributary subtree at a time
full-size MP4full-size MP4full-size MP4

The three differ in memory access pattern. Depth-first has the lowest simulated L1 miss rate on the example basin: 10.55%, against 21.83% (breadth-first) and 37.02% (topological sort). Those are one alignment of the two arrays the traversal reads, the one where they collide in every cache set. Shift them apart and the numbers fall to about 8.8%, 14.2% and 36.9%. The order does not move, at any alignment tried.

Direction. An ordering is built in one direction and reversed on demand. Depth-first and breadth-first start at the pit, so they come out downstream to upstream (d2u, position 0 is a pit); the topological sort starts at the headwaters, so it comes out upstream to downstream (u2d, position 0 is a headwater). The two are reverses of each other.

Which one a kernel needs follows from the way its values travel. Drainage area, longest upstream path and Strahler order accumulate into the receiver and need u2d; distance to outlet reads from the receiver and needs d2u. The kernels flip the sequence for you, so topo.upstream_area(ordering="dfs") walks the depth-first order upstream to downstream even though it was built the other way. Ask for a direction explicitly with topo.ordering("dfs", "u2d").

Parallel layerings

A layering assigns every cell a layer index such that cells in the same layer are independent of each other. Layers run in order; cells within a layer run in parallel. Layer 0 holds the headwaters.

as soon as possible
layering="asap"
conflict-free downstream
layering="cfds"
as late as possible
layering="alap"
every cell in the earliest layer its donors allowas soon as possible, plus one rule: no two cells in a layer share a receiverevery cell in the latest layer possible
full-size MP4full-size MP4full-size MP4

The minimum layer count is set by the longest flow path. The conflict-free rule may add a few layers; on the example basin it adds none (949 layers for all three).

Layerings are built u2d, layer 0 at the headwaters. The downstream-propagating kernel needs them the other way round, and flips them itself; topo.decomposition("cfds", "d2u") gives that view directly.

Spatial partitions

A layering spreads work across threads that share memory. Splitting the network across processors needs a second cut, along the drainage hierarchy, so that no value crosses a subregion boundary while a kernel runs.

Set n_parts to the number of processors: one subregion each, so a subregion's working set stays in its own memory. The paper's benchmark uses four, because the server has four Xeon Platinum 8270 processors, each a NUMA node with 26 cores, and runs about 13 threads inside each subregion.

Two basins mapped onto two subregions.level="basin" keeps each basin whole, so the 62-cell basin and the 11-cell basin cannot be balanced. level="subbasin" walks up the dominant basin's mainstem, and a tributary subtree moves to the lighter subregion.

Whole basins cannot be split, so one large basin leaves the other processors idle. The bundled example is one basin, so it shows this plainly:

topo.partition(n_parts=4, level="basin")[1] # [93432, 0, 0, 0]topo.partition(n_parts=4, level="subbasin")[1] # [23121, 23121, 23121, 23120]

Subbasin-level walks upstream from the outlet, taking the larger tributary at each confluence. That isolates the mainstem; the tributary subtrees hanging off it are dealt to the lighter subregions, and the mainstem runs in a second stage once they finish. Its cells are marked flowtopo.MAINSTEM. That is why the four subregion loads above come to 92,483 rather than 93,432: the 949 mainstem cells are held back for the second stage and do not count as first-stage work.

Write conflicts

Whichever structure carries the traversal, a kernel still has to move a value from a cell to its receiver. There are three ways to do it:

pullatomic pushpush
each receiver reads its donors; needs the upstream tabledonors write through atomics; correct, but float sums are not reproducibledonors write directly; deterministic, no locks; requires the conflict-free layering
full-size MP4full-size MP4full-size MP4

A push is only safe if no two cells in a layer write to the same receiver. Conflict counts on the example basin (93,432 cells):

layeringconflicting writes inside a layer
as soon as possible12,122
conflict-free downstream0
as late as possible39,130

The count is a property of the layering and can be checked before running. A test run is not a reliable check: a race does not always trigger. Strahler order has no atomic form, because its confluence rule is a comparison rather than an addition, so its only parallel push is under the conflict-free layering.

Because no two cells in a layer share a receiver, the sums always happen in the same order: the conflict-free push returns bit-identical results at any thread count, which an atomic push cannot promise. If manner is not given, FlowTopo picks a safe one for the layering.

Which structure to use

From the paper's benchmark on the full 90 m network (22.2 billion cells, 65 regions):

  • One sweep, one core — the depth-first ordering. Up to 5.1× faster than the slowest serial ordering; subtree contiguity drops the L3 miss rate from 37% to about 6%. It is also the baseline to measure parallel speedup against; a slower baseline overstates the speedup.
  • Repeated traversal (calibration, ensembles) — the as-late-as-possible layering with pull. Fastest in parallel: each layer's working set stays in cache. Pull must store the donor table.
  • Non-linear kernels, or when RAM is tight — the conflict-free downstream layering with push. Lock-free and deterministic, stores only the receiver pointer, and the only parallel option for Strahler order.
  • Across processors — the subbasin partition, run at about 13 threads each, beyond which memory bandwidth rather than the algorithm bounds the speedup.

The structures are computed once from the static D8 field and reused without limit.

Those rankings come from the paper's C run at continental scale. This package is numba over numpy at a much smaller size, and it does not reproduce them term for term. On a 16-million-cell grid here, push under cfds is the fastest parallel form, and pull loses even to the serial sequence: building and reading the donor table costs more than ten threads save. The structures are the same either way. Which one wins depends on your machine and your grid, so measure with benchmark.py before choosing.

Install

pip install -e .# numpy + rasterio
pip install -e ".[speed]"# + numba, for threaded kernels

Needs Python 3.10 or newer. The tests run on 3.10, 3.11 and 3.12. Without numba everything still runs, in pure Python.

Quick start

importflowtopotopo=flowtopo.FlowTopo.from_raster("data/dir_example.tif")
upa=topo.upstream_area(ordering="dfs") # serialupa=topo.upstream_area(layering="cfds", manner="push") # a layer at a timeldn=topo.distance_to_outlet(ordering="dfs")
lup=topo.longest_upstream_path(ldn, ordering="dfs")
strord=topo.strahler_order(ordering="dfs",
channel_mask=topo.channel_mask(upa, 10.0))
part, load=topo.partition(n_parts=4, level="subbasin") # across processorstopo.to_2d(upa) # back on the grid

With numba, the same kernels with threads:

fromflowtopoimportparallelupa=parallel.upstream_area(topo, layering="cfds", manner="push")

Run everything on the example basin:

python example.py # every structure, kernel and manner; ends PASS or FAIL
python benchmark.py # serial vs threaded at several grid sizes

Documentation

Example data

data/dir_example.tif is the example basin of the paper: 292 rows by 614 columns at 3 arc-seconds, 93,432 valid cells, 731 km², cut from MERIT Hydro (Yamazaki et al., 2019). The GeoJSON files are the basin boundary and the outlet.

Any D8 GeoTIFF in the same convention works: codes are powers of two clockwise from east, with 0 and 255 terminal. The file's own nodata value is honoured too, which matters because 255 means a terminal here, not nodata.

python example.py --data my_dir.tif

Two ways to use this

Take the structures we release

The structures for the whole 90 m network are on Zenodo, one tile per hydrological region, ready to read. To compute anything on them you also need the flow-direction grid they are indexed against, and that is not redistributed here: get it from its authors at https://global-hydrodynamics.github.io/MERIT_Hydro/, under the CC BY-NC 4.0 terms they set.

Working out which of their files a region needs is the fiddly part, and https://fullhydro.org/fullbasin/regions/ does that for you. Pick a region on the map or in the table and it lists the MERIT Hydro tiles covering it, with the row and column offset to place each one. It does not host MERIT Hydro, only the list. The same page has all 96 region outlines as one GeoJSON, tile names and offsets included, for doing it in a script. Those 96 are 65 continental regions, two that straddle the antimeridian and 29 island groups; the products below cover the 65. Region boxes sit on whole degrees and one degree is exactly 1,200 cells at 3 arc-seconds, so a region cuts out of the global rasters by integer arithmetic, with no resampling.

You do not need a whole region. Clip whatever you like out of one and the structures clip with it: filter a released sequence to the cells you kept and it is still a topological sort of them, and a filtered layering keeps its layers mutually independent, the conflict-free guarantee included. That holds for any subset, because dropping cells from a valid order cannot put a cell before something it depends on, and dropping cells from a layer cannot make two of the survivors depend on each other. Checked on a 12,809-cell subbasin of the bundled example: all three orderings stayed valid, all three layerings kept their layers independent, cfds kept zero conflicts, and recomputing from scratch on the clip reproduced all 12,809 values bit for bit.

Clip however suits you: a basin, a rectangle, a country. Two things behave differently and are worth keeping apart.

The structures describe the network you hand over. Clip it and the structures of the clip are exact: still a topological sort, still layers of independent cells, still zero conflicts under cfds. Any clip, no exceptions.

A kernel answers a question about that same network. Drainage area asks how much area drains into a cell, and how much area is however much you supplied. Clip a catchment in half and the answer halves, not because the computation slipped but because you asked about a smaller catchment. The number is exactly right for the data it was given.

So the only question is whether your clip contains the whole catchment of the cells you care about. A basin does, by definition: clipping one out of the bundled example and recomputing reproduces every cell to the last decimal. A rectangle does for most of them. The cells it gets wrong are exactly the ones with catchment outside the cut, so the share depends on how much drainage the cut intercepts rather than on any fixed number: across thirteen rectangles through the bundled example, between 92.7% and 99.6% of the kept cells came back identical, median 97.8%. For the rest, the ones below a channel the cut crossed, the number describes your rectangle rather than the world.

Clip a rectangle when the area you kept is what you are studying. Clip whole basins, or read the values out of MERIT-DrainAttr, when the numbers have to mean what they mean globally.

Bring your own flow directions

Any D8 grid in this convention works. Build the eight structures yourself and compute on them:

importflowtopotopo=flowtopo.FlowTopo.from_raster("my_dir.tif")
seq=topo.ordering("dfs") # one of three orderingslayers, n=topo.layering("cfds") # one of three layeringspart, load=topo.partition(n_parts=4) # one of two partitionsupa=topo.upstream_area(ordering="dfs") # or any kernel, on any structure

This is the same code that produced the released structures, so a region you build yourself and a region you download are the same thing.

Work a region or a basin at a time rather than the globe. A region encloses only complete basins and stays within 38° × 38°, which keeps its cell indices inside 32-bit integers, which is why this package uses int32 throughout. The region boundaries are in MERIT-FullBasin, below.

Global products

This repository holds the method and one example basin. Applied to the whole 90 m MERIT Hydro network, it produces two Zenodo records, both as per-region GeoTIFFs for 65 regions.

The structures

MERIT-FlowTopo (10.5281/zenodo.20653058) holds the traversal structures themselves. Every region carries the depth-first sequence, the conflict-free downstream and as-late-as-possible layerings, and the subbasin partition; Region 43 (South China) carries all eight, so the alternatives can be compared somewhere. 978 GB uncompressed, 49 GB compressed.

All eight structures, drawn over the whole network: three serial orderings, three parallel layerings, two spatial partitions. The release itself carries four of them per region, and all eight for Region 43.

Because the D8 field does not change, these are computed once and reused without limit. That is the point: a cost every tool currently pays on every run becomes a read.

What the structures compute

MERIT-DrainAttr (10.5281/zenodo.20686664) is the four kernels run over those structures: a baseline, so the variables almost everyone wants need not be recomputed either.

Flow length downstream, flow length upstream and Strahler stream order for every region; upstream drainage area for Region 43 only, since MERIT Hydro already distributes it globally. 622 GB uncompressed, 60 GB compressed.

Anything else is one pass over a structure you already have: a different accumulation, a routing state, a variable nobody has asked for yet. That is why the release is the structures and not the variables.

The input partition

MERIT-FullBasin (10.5281/zenodo.20344112) divides the network into the 65 hydrologically independent regions everything above is organised by. It comes from the companion dataset, not from here.

Acknowledgements

MERIT Hydro (Yamazaki et al., 2019; 10.1029/2019WR024873) provides the flow-direction field everything here traverses: the example basin is cut from it, and the global products are built on its 90 m network. The bundled excerpt keeps its CC BY-NC 4.0 terms.

The representation this package works on comes from pyflwdir (D. Eilander, Deltares and the Institute for Environmental Studies, Vrije Universiteit Amsterdam; MIT licence; 10.5281/zenodo.4287337). What FlowTopo takes from it:

  • the flat downstream-pointer array — for every cell, the linear index of the cell it drains into, with a pit pointing at itself. Every structure and every kernel here is derived from that one array;
  • the D8 decoding conventions — codes as powers of two clockwise from east, 0 and 255 terminal, 247 nodata, and a cell draining off the grid or into nodata treated as a pit;
  • the donor-count array and its sentinel convention;
  • the chain-tracing rank and the breadth-first sequence builder, which appear here in the variants set out in docs/methods.md.

No pyflwdir source is included. The code here was written against those conventions rather than copied from them, and FlowTopo.from_d8 takes the same array pyflwdir.from_array(d8, ftype="d8") takes, so the two read the same rasters.

The three layerings, the conflict-free downstream rule, the propagation manners, the locality metrics and the benchmark drivers are this project's own.

Claude (Anthropic) helped prepare this repository: the Python implementation, the tests, the documentation and the packaging were drafted with it and checked by the authors, and the commit history records where. The structures, the algorithms and the results they produce are the authors' own work, described in the companion manuscript.

Verification

Expected outputs for the example basin are stored with the tests, so a fresh clone verifies itself:

pytest

170 tests. Every ordering, layering, partition, kernel and manner is cross-checked on the example basin, write-conflict counts included. The structures are checked against their definitions directly: an ordering is a topological sort, a layer is an antichain, a receiver comes after its donors, and all of that survives clipping a basin out of a region. Degenerate inputs get their own: an empty grid, a lone cell, networks with cycles. So do the API's promises: cached arrays are read-only, repeated calls agree bit for bit, accumulation keeps the precision it was given.

Those tests and example.py run on Python 3.10, 3.11 and 3.12 on every push. The badge at the top links to the runs.

Contact

Questions, problems and suggestions are welcome by email at lulu_jiang@pku.edu.cn, or as a GitHub issue. Email is the surer way to reach us.

Citing

The companion manuscript is MERIT-FlowTopo v1.0: a reusable computational foundation for hyperresolution hydrology on the global 90 m drainage network (Jiang et al., in preparation). A citation file will be added once it appears; until then, cite the manuscript.

Licence

MIT for the code; see LICENSE. The bundled MERIT Hydro excerpt keeps its own CC BY-NC 4.0 terms; see DATA_NOTICE.md.

About

Serial orderings, parallel layerings and spatial partitions for D8 flow networks, computed once from the flow-direction grid and reused by any drainage-order computation. Python reference implementation.

Topics

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

FlowTopo

testslicence: MIT

Orderings, layerings and partitions for D8 flow networks, in Python.

A D8 flow-direction grid fixes the order in which cells must be visited: a cell can be processed only after its upstream neighbours, or, for some kernels, only after its downstream one. FlowTopo computes that order once, from the flow-direction grid alone, and stores it as a reusable structure. Any routine that walks the network in drainage order (drainage area, flow length, stream order, routing) can then reuse the structure instead of rebuilding the traversal each time.

Three kinds of structure are provided:

  • serial orderings (three): a topological sort of the cells, walked in one pass on one core;
  • parallel layerings (three): cells grouped into layers so that cells in the same layer are independent of each other and can run on several threads;
  • spatial partitions (two): the network cut into independent subregions, one per processor.

Four kernels are bundled to exercise the structures: upstream drainage area, distance to outlet, longest upstream path and Strahler stream order. They are test cases, not the purpose of the package. Each kernel runs on every structure and the results are cross-checked.

In the snippets below, topo is a FlowTopo object built once from a flow-direction raster; Quick start shows how.

This repository holds the Python reference implementation and one worked example basin. The global 90 m products computed with the same method are released separately on Zenodo; see Global products. The companion paper (Jiang et al.) is in preparation and has no link yet.

A narrated video walks through the structures and the three ways of moving a value from a cell to its receiver: https://youtu.be/tE5K2wM3TTY. The animations in this README are reduced previews. Each has a full-size MP4 link beneath it, and all files are in docs/media.

Serial orderings

An ordering is a topological sort of the valid cells. One pass over it computes any kernel, because every cell is visited after whatever it needs. Which end that is depends on the kernel: drainage area needs a cell's donors first, flow length needs its receiver first. A sequence is stored in one direction and reversed on demand, and the kernels do the reversing.

topological sort from the sources
ordering="topo"
breadth-first from the pit
ordering="bfs"
depth-first from the pit
ordering="dfs"
a cell is appended once all its donors are donecells in order of hop count from the pitone tributary subtree at a time
full-size MP4full-size MP4full-size MP4

The three differ in memory access pattern. Depth-first has the lowest simulated L1 miss rate on the example basin: 10.55%, against 21.83% (breadth-first) and 37.02% (topological sort). Those are one alignment of the two arrays the traversal reads, the one where they collide in every cache set. Shift them apart and the numbers fall to about 8.8%, 14.2% and 36.9%. The order does not move, at any alignment tried.

Direction. An ordering is built in one direction and reversed on demand. Depth-first and breadth-first start at the pit, so they come out downstream to upstream (d2u, position 0 is a pit); the topological sort starts at the headwaters, so it comes out upstream to downstream (u2d, position 0 is a headwater). The two are reverses of each other.

Which one a kernel needs follows from the way its values travel. Drainage area, longest upstream path and Strahler order accumulate into the receiver and need u2d; distance to outlet reads from the receiver and needs d2u. The kernels flip the sequence for you, so topo.upstream_area(ordering="dfs") walks the depth-first order upstream to downstream even though it was built the other way. Ask for a direction explicitly with topo.ordering("dfs", "u2d").

Parallel layerings

A layering assigns every cell a layer index such that cells in the same layer are independent of each other. Layers run in order; cells within a layer run in parallel. Layer 0 holds the headwaters.

as soon as possible
layering="asap"
conflict-free downstream
layering="cfds"
as late as possible
layering="alap"
every cell in the earliest layer its donors allowas soon as possible, plus one rule: no two cells in a layer share a receiverevery cell in the latest layer possible
full-size MP4full-size MP4full-size MP4

The minimum layer count is set by the longest flow path. The conflict-free rule may add a few layers; on the example basin it adds none (949 layers for all three).

Layerings are built u2d, layer 0 at the headwaters. The downstream-propagating kernel needs them the other way round, and flips them itself; topo.decomposition("cfds", "d2u") gives that view directly.

Spatial partitions

A layering spreads work across threads that share memory. Splitting the network across processors needs a second cut, along the drainage hierarchy, so that no value crosses a subregion boundary while a kernel runs.

Set n_parts to the number of processors: one subregion each, so a subregion's working set stays in its own memory. The paper's benchmark uses four, because the server has four Xeon Platinum 8270 processors, each a NUMA node with 26 cores, and runs about 13 threads inside each subregion.

Two basins mapped onto two subregions.level="basin" keeps each basin whole, so the 62-cell basin and the 11-cell basin cannot be balanced. level="subbasin" walks up the dominant basin's mainstem, and a tributary subtree moves to the lighter subregion.

Whole basins cannot be split, so one large basin leaves the other processors idle. The bundled example is one basin, so it shows this plainly:

topo.partition(n_parts=4, level="basin")[1] # [93432, 0, 0, 0]topo.partition(n_parts=4, level="subbasin")[1] # [23121, 23121, 23121, 23120]

Subbasin-level walks upstream from the outlet, taking the larger tributary at each confluence. That isolates the mainstem; the tributary subtrees hanging off it are dealt to the lighter subregions, and the mainstem runs in a second stage once they finish. Its cells are marked flowtopo.MAINSTEM. That is why the four subregion loads above come to 92,483 rather than 93,432: the 949 mainstem cells are held back for the second stage and do not count as first-stage work.

Write conflicts

Whichever structure carries the traversal, a kernel still has to move a value from a cell to its receiver. There are three ways to do it:

pullatomic pushpush
each receiver reads its donors; needs the upstream tabledonors write through atomics; correct, but float sums are not reproducibledonors write directly; deterministic, no locks; requires the conflict-free layering
full-size MP4full-size MP4full-size MP4

A push is only safe if no two cells in a layer write to the same receiver. Conflict counts on the example basin (93,432 cells):

layeringconflicting writes inside a layer
as soon as possible12,122
conflict-free downstream0
as late as possible39,130

The count is a property of the layering and can be checked before running. A test run is not a reliable check: a race does not always trigger. Strahler order has no atomic form, because its confluence rule is a comparison rather than an addition, so its only parallel push is under the conflict-free layering.

Because no two cells in a layer share a receiver, the sums always happen in the same order: the conflict-free push returns bit-identical results at any thread count, which an atomic push cannot promise. If manner is not given, FlowTopo picks a safe one for the layering.

Which structure to use

From the paper's benchmark on the full 90 m network (22.2 billion cells, 65 regions):

  • One sweep, one core — the depth-first ordering. Up to 5.1× faster than the slowest serial ordering; subtree contiguity drops the L3 miss rate from 37% to about 6%. It is also the baseline to measure parallel speedup against; a slower baseline overstates the speedup.
  • Repeated traversal (calibration, ensembles) — the as-late-as-possible layering with pull. Fastest in parallel: each layer's working set stays in cache. Pull must store the donor table.
  • Non-linear kernels, or when RAM is tight — the conflict-free downstream layering with push. Lock-free and deterministic, stores only the receiver pointer, and the only parallel option for Strahler order.
  • Across processors — the subbasin partition, run at about 13 threads each, beyond which memory bandwidth rather than the algorithm bounds the speedup.

The structures are computed once from the static D8 field and reused without limit.

Those rankings come from the paper's C run at continental scale. This package is numba over numpy at a much smaller size, and it does not reproduce them term for term. On a 16-million-cell grid here, push under cfds is the fastest parallel form, and pull loses even to the serial sequence: building and reading the donor table costs more than ten threads save. The structures are the same either way. Which one wins depends on your machine and your grid, so measure with benchmark.py before choosing.

Install

pip install -e .# numpy + rasterio
pip install -e ".[speed]"# + numba, for threaded kernels

Needs Python 3.10 or newer. The tests run on 3.10, 3.11 and 3.12. Without numba everything still runs, in pure Python.

Quick start

importflowtopotopo=flowtopo.FlowTopo.from_raster("data/dir_example.tif")
upa=topo.upstream_area(ordering="dfs") # serialupa=topo.upstream_area(layering="cfds", manner="push") # a layer at a timeldn=topo.distance_to_outlet(ordering="dfs")
lup=topo.longest_upstream_path(ldn, ordering="dfs")
strord=topo.strahler_order(ordering="dfs",
channel_mask=topo.channel_mask(upa, 10.0))
part, load=topo.partition(n_parts=4, level="subbasin") # across processorstopo.to_2d(upa) # back on the grid

With numba, the same kernels with threads:

fromflowtopoimportparallelupa=parallel.upstream_area(topo, layering="cfds", manner="push")

Run everything on the example basin:

python example.py # every structure, kernel and manner; ends PASS or FAIL
python benchmark.py # serial vs threaded at several grid sizes

Documentation

Example data

data/dir_example.tif is the example basin of the paper: 292 rows by 614 columns at 3 arc-seconds, 93,432 valid cells, 731 km², cut from MERIT Hydro (Yamazaki et al., 2019). The GeoJSON files are the basin boundary and the outlet.

Any D8 GeoTIFF in the same convention works: codes are powers of two clockwise from east, with 0 and 255 terminal. The file's own nodata value is honoured too, which matters because 255 means a terminal here, not nodata.

python example.py --data my_dir.tif

Two ways to use this

Take the structures we release

The structures for the whole 90 m network are on Zenodo, one tile per hydrological region, ready to read. To compute anything on them you also need the flow-direction grid they are indexed against, and that is not redistributed here: get it from its authors at https://global-hydrodynamics.github.io/MERIT_Hydro/, under the CC BY-NC 4.0 terms they set.

Working out which of their files a region needs is the fiddly part, and https://fullhydro.org/fullbasin/regions/ does that for you. Pick a region on the map or in the table and it lists the MERIT Hydro tiles covering it, with the row and column offset to place each one. It does not host MERIT Hydro, only the list. The same page has all 96 region outlines as one GeoJSON, tile names and offsets included, for doing it in a script. Those 96 are 65 continental regions, two that straddle the antimeridian and 29 island groups; the products below cover the 65. Region boxes sit on whole degrees and one degree is exactly 1,200 cells at 3 arc-seconds, so a region cuts out of the global rasters by integer arithmetic, with no resampling.

You do not need a whole region. Clip whatever you like out of one and the structures clip with it: filter a released sequence to the cells you kept and it is still a topological sort of them, and a filtered layering keeps its layers mutually independent, the conflict-free guarantee included. That holds for any subset, because dropping cells from a valid order cannot put a cell before something it depends on, and dropping cells from a layer cannot make two of the survivors depend on each other. Checked on a 12,809-cell subbasin of the bundled example: all three orderings stayed valid, all three layerings kept their layers independent, cfds kept zero conflicts, and recomputing from scratch on the clip reproduced all 12,809 values bit for bit.

Clip however suits you: a basin, a rectangle, a country. Two things behave differently and are worth keeping apart.

The structures describe the network you hand over. Clip it and the structures of the clip are exact: still a topological sort, still layers of independent cells, still zero conflicts under cfds. Any clip, no exceptions.

A kernel answers a question about that same network. Drainage area asks how much area drains into a cell, and how much area is however much you supplied. Clip a catchment in half and the answer halves, not because the computation slipped but because you asked about a smaller catchment. The number is exactly right for the data it was given.

So the only question is whether your clip contains the whole catchment of the cells you care about. A basin does, by definition: clipping one out of the bundled example and recomputing reproduces every cell to the last decimal. A rectangle does for most of them. The cells it gets wrong are exactly the ones with catchment outside the cut, so the share depends on how much drainage the cut intercepts rather than on any fixed number: across thirteen rectangles through the bundled example, between 92.7% and 99.6% of the kept cells came back identical, median 97.8%. For the rest, the ones below a channel the cut crossed, the number describes your rectangle rather than the world.

Clip a rectangle when the area you kept is what you are studying. Clip whole basins, or read the values out of MERIT-DrainAttr, when the numbers have to mean what they mean globally.

Bring your own flow directions

Any D8 grid in this convention works. Build the eight structures yourself and compute on them:

importflowtopotopo=flowtopo.FlowTopo.from_raster("my_dir.tif")
seq=topo.ordering("dfs") # one of three orderingslayers, n=topo.layering("cfds") # one of three layeringspart, load=topo.partition(n_parts=4) # one of two partitionsupa=topo.upstream_area(ordering="dfs") # or any kernel, on any structure

This is the same code that produced the released structures, so a region you build yourself and a region you download are the same thing.

Work a region or a basin at a time rather than the globe. A region encloses only complete basins and stays within 38° × 38°, which keeps its cell indices inside 32-bit integers, which is why this package uses int32 throughout. The region boundaries are in MERIT-FullBasin, below.

Global products

This repository holds the method and one example basin. Applied to the whole 90 m MERIT Hydro network, it produces two Zenodo records, both as per-region GeoTIFFs for 65 regions.

The structures

MERIT-FlowTopo (10.5281/zenodo.20653058) holds the traversal structures themselves. Every region carries the depth-first sequence, the conflict-free downstream and as-late-as-possible layerings, and the subbasin partition; Region 43 (South China) carries all eight, so the alternatives can be compared somewhere. 978 GB uncompressed, 49 GB compressed.

All eight structures, drawn over the whole network: three serial orderings, three parallel layerings, two spatial partitions. The release itself carries four of them per region, and all eight for Region 43.

Because the D8 field does not change, these are computed once and reused without limit. That is the point: a cost every tool currently pays on every run becomes a read.

What the structures compute

MERIT-DrainAttr (10.5281/zenodo.20686664) is the four kernels run over those structures: a baseline, so the variables almost everyone wants need not be recomputed either.

Flow length downstream, flow length upstream and Strahler stream order for every region; upstream drainage area for Region 43 only, since MERIT Hydro already distributes it globally. 622 GB uncompressed, 60 GB compressed.

Anything else is one pass over a structure you already have: a different accumulation, a routing state, a variable nobody has asked for yet. That is why the release is the structures and not the variables.

The input partition

MERIT-FullBasin (10.5281/zenodo.20344112) divides the network into the 65 hydrologically independent regions everything above is organised by. It comes from the companion dataset, not from here.

Acknowledgements

MERIT Hydro (Yamazaki et al., 2019; 10.1029/2019WR024873) provides the flow-direction field everything here traverses: the example basin is cut from it, and the global products are built on its 90 m network. The bundled excerpt keeps its CC BY-NC 4.0 terms.

The representation this package works on comes from pyflwdir (D. Eilander, Deltares and the Institute for Environmental Studies, Vrije Universiteit Amsterdam; MIT licence; 10.5281/zenodo.4287337). What FlowTopo takes from it:

  • the flat downstream-pointer array — for every cell, the linear index of the cell it drains into, with a pit pointing at itself. Every structure and every kernel here is derived from that one array;
  • the D8 decoding conventions — codes as powers of two clockwise from east, 0 and 255 terminal, 247 nodata, and a cell draining off the grid or into nodata treated as a pit;
  • the donor-count array and its sentinel convention;
  • the chain-tracing rank and the breadth-first sequence builder, which appear here in the variants set out in docs/methods.md.

No pyflwdir source is included. The code here was written against those conventions rather than copied from them, and FlowTopo.from_d8 takes the same array pyflwdir.from_array(d8, ftype="d8") takes, so the two read the same rasters.

The three layerings, the conflict-free downstream rule, the propagation manners, the locality metrics and the benchmark drivers are this project's own.

Claude (Anthropic) helped prepare this repository: the Python implementation, the tests, the documentation and the packaging were drafted with it and checked by the authors, and the commit history records where. The structures, the algorithms and the results they produce are the authors' own work, described in the companion manuscript.

Verification

Expected outputs for the example basin are stored with the tests, so a fresh clone verifies itself:

pytest

170 tests. Every ordering, layering, partition, kernel and manner is cross-checked on the example basin, write-conflict counts included. The structures are checked against their definitions directly: an ordering is a topological sort, a layer is an antichain, a receiver comes after its donors, and all of that survives clipping a basin out of a region. Degenerate inputs get their own: an empty grid, a lone cell, networks with cycles. So do the API's promises: cached arrays are read-only, repeated calls agree bit for bit, accumulation keeps the precision it was given.

Those tests and example.py run on Python 3.10, 3.11 and 3.12 on every push. The badge at the top links to the runs.

Contact

Questions, problems and suggestions are welcome by email at lulu_jiang@pku.edu.cn, or as a GitHub issue. Email is the surer way to reach us.

Citing

The companion manuscript is MERIT-FlowTopo v1.0: a reusable computational foundation for hyperresolution hydrology on the global 90 m drainage network (Jiang et al., in preparation). A citation file will be added once it appears; until then, cite the manuscript.

Licence

MIT for the code; see LICENSE. The bundled MERIT Hydro excerpt keeps its own CC BY-NC 4.0 terms; see DATA_NOTICE.md.

About

Serial orderings, parallel layerings and spatial partitions for D8 flow networks, computed once from the flow-direction grid and reused by any drainage-order computation. Python reference implementation.

Topics

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

FlowTopo

testslicence: MIT

Orderings, layerings and partitions for D8 flow networks, in Python.

A D8 flow-direction grid fixes the order in which cells must be visited: a cell can be processed only after its upstream neighbours, or, for some kernels, only after its downstream one. FlowTopo computes that order once, from the flow-direction grid alone, and stores it as a reusable structure. Any routine that walks the network in drainage order (drainage area, flow length, stream order, routing) can then reuse the structure instead of rebuilding the traversal each time.

Three kinds of structure are provided:

  • serial orderings (three): a topological sort of the cells, walked in one pass on one core;
  • parallel layerings (three): cells grouped into layers so that cells in the same layer are independent of each other and can run on several threads;
  • spatial partitions (two): the network cut into independent subregions, one per processor.

Four kernels are bundled to exercise the structures: upstream drainage area, distance to outlet, longest upstream path and Strahler stream order. They are test cases, not the purpose of the package. Each kernel runs on every structure and the results are cross-checked.

In the snippets below, topo is a FlowTopo object built once from a flow-direction raster; Quick start shows how.

This repository holds the Python reference implementation and one worked example basin. The global 90 m products computed with the same method are released separately on Zenodo; see Global products. The companion paper (Jiang et al.) is in preparation and has no link yet.

A narrated video walks through the structures and the three ways of moving a value from a cell to its receiver: https://youtu.be/tE5K2wM3TTY. The animations in this README are reduced previews. Each has a full-size MP4 link beneath it, and all files are in docs/media.

Serial orderings

An ordering is a topological sort of the valid cells. One pass over it computes any kernel, because every cell is visited after whatever it needs. Which end that is depends on the kernel: drainage area needs a cell's donors first, flow length needs its receiver first. A sequence is stored in one direction and reversed on demand, and the kernels do the reversing.

topological sort from the sources
ordering="topo"
breadth-first from the pit
ordering="bfs"
depth-first from the pit
ordering="dfs"
a cell is appended once all its donors are donecells in order of hop count from the pitone tributary subtree at a time
full-size MP4full-size MP4full-size MP4

The three differ in memory access pattern. Depth-first has the lowest simulated L1 miss rate on the example basin: 10.55%, against 21.83% (breadth-first) and 37.02% (topological sort). Those are one alignment of the two arrays the traversal reads, the one where they collide in every cache set. Shift them apart and the numbers fall to about 8.8%, 14.2% and 36.9%. The order does not move, at any alignment tried.

Direction. An ordering is built in one direction and reversed on demand. Depth-first and breadth-first start at the pit, so they come out downstream to upstream (d2u, position 0 is a pit); the topological sort starts at the headwaters, so it comes out upstream to downstream (u2d, position 0 is a headwater). The two are reverses of each other.

Which one a kernel needs follows from the way its values travel. Drainage area, longest upstream path and Strahler order accumulate into the receiver and need u2d; distance to outlet reads from the receiver and needs d2u. The kernels flip the sequence for you, so topo.upstream_area(ordering="dfs") walks the depth-first order upstream to downstream even though it was built the other way. Ask for a direction explicitly with topo.ordering("dfs", "u2d").

Parallel layerings

A layering assigns every cell a layer index such that cells in the same layer are independent of each other. Layers run in order; cells within a layer run in parallel. Layer 0 holds the headwaters.

as soon as possible
layering="asap"
conflict-free downstream
layering="cfds"
as late as possible
layering="alap"
every cell in the earliest layer its donors allowas soon as possible, plus one rule: no two cells in a layer share a receiverevery cell in the latest layer possible
full-size MP4full-size MP4full-size MP4

The minimum layer count is set by the longest flow path. The conflict-free rule may add a few layers; on the example basin it adds none (949 layers for all three).

Layerings are built u2d, layer 0 at the headwaters. The downstream-propagating kernel needs them the other way round, and flips them itself; topo.decomposition("cfds", "d2u") gives that view directly.

Spatial partitions

A layering spreads work across threads that share memory. Splitting the network across processors needs a second cut, along the drainage hierarchy, so that no value crosses a subregion boundary while a kernel runs.

Set n_parts to the number of processors: one subregion each, so a subregion's working set stays in its own memory. The paper's benchmark uses four, because the server has four Xeon Platinum 8270 processors, each a NUMA node with 26 cores, and runs about 13 threads inside each subregion.

Two basins mapped onto two subregions.level="basin" keeps each basin whole, so the 62-cell basin and the 11-cell basin cannot be balanced. level="subbasin" walks up the dominant basin's mainstem, and a tributary subtree moves to the lighter subregion.

Whole basins cannot be split, so one large basin leaves the other processors idle. The bundled example is one basin, so it shows this plainly:

topo.partition(n_parts=4, level="basin")[1] # [93432, 0, 0, 0]topo.partition(n_parts=4, level="subbasin")[1] # [23121, 23121, 23121, 23120]

Subbasin-level walks upstream from the outlet, taking the larger tributary at each confluence. That isolates the mainstem; the tributary subtrees hanging off it are dealt to the lighter subregions, and the mainstem runs in a second stage once they finish. Its cells are marked flowtopo.MAINSTEM. That is why the four subregion loads above come to 92,483 rather than 93,432: the 949 mainstem cells are held back for the second stage and do not count as first-stage work.

Write conflicts

Whichever structure carries the traversal, a kernel still has to move a value from a cell to its receiver. There are three ways to do it:

pullatomic pushpush
each receiver reads its donors; needs the upstream tabledonors write through atomics; correct, but float sums are not reproducibledonors write directly; deterministic, no locks; requires the conflict-free layering
full-size MP4full-size MP4full-size MP4

A push is only safe if no two cells in a layer write to the same receiver. Conflict counts on the example basin (93,432 cells):

layeringconflicting writes inside a layer
as soon as possible12,122
conflict-free downstream0
as late as possible39,130

The count is a property of the layering and can be checked before running. A test run is not a reliable check: a race does not always trigger. Strahler order has no atomic form, because its confluence rule is a comparison rather than an addition, so its only parallel push is under the conflict-free layering.

Because no two cells in a layer share a receiver, the sums always happen in the same order: the conflict-free push returns bit-identical results at any thread count, which an atomic push cannot promise. If manner is not given, FlowTopo picks a safe one for the layering.

Which structure to use

From the paper's benchmark on the full 90 m network (22.2 billion cells, 65 regions):

  • One sweep, one core — the depth-first ordering. Up to 5.1× faster than the slowest serial ordering; subtree contiguity drops the L3 miss rate from 37% to about 6%. It is also the baseline to measure parallel speedup against; a slower baseline overstates the speedup.
  • Repeated traversal (calibration, ensembles) — the as-late-as-possible layering with pull. Fastest in parallel: each layer's working set stays in cache. Pull must store the donor table.
  • Non-linear kernels, or when RAM is tight — the conflict-free downstream layering with push. Lock-free and deterministic, stores only the receiver pointer, and the only parallel option for Strahler order.
  • Across processors — the subbasin partition, run at about 13 threads each, beyond which memory bandwidth rather than the algorithm bounds the speedup.

The structures are computed once from the static D8 field and reused without limit.

Those rankings come from the paper's C run at continental scale. This package is numba over numpy at a much smaller size, and it does not reproduce them term for term. On a 16-million-cell grid here, push under cfds is the fastest parallel form, and pull loses even to the serial sequence: building and reading the donor table costs more than ten threads save. The structures are the same either way. Which one wins depends on your machine and your grid, so measure with benchmark.py before choosing.

Install

pip install -e .# numpy + rasterio
pip install -e ".[speed]"# + numba, for threaded kernels

Needs Python 3.10 or newer. The tests run on 3.10, 3.11 and 3.12. Without numba everything still runs, in pure Python.

Quick start

importflowtopotopo=flowtopo.FlowTopo.from_raster("data/dir_example.tif")
upa=topo.upstream_area(ordering="dfs") # serialupa=topo.upstream_area(layering="cfds", manner="push") # a layer at a timeldn=topo.distance_to_outlet(ordering="dfs")
lup=topo.longest_upstream_path(ldn, ordering="dfs")
strord=topo.strahler_order(ordering="dfs",
channel_mask=topo.channel_mask(upa, 10.0))
part, load=topo.partition(n_parts=4, level="subbasin") # across processorstopo.to_2d(upa) # back on the grid

With numba, the same kernels with threads:

fromflowtopoimportparallelupa=parallel.upstream_area(topo, layering="cfds", manner="push")

Run everything on the example basin:

python example.py # every structure, kernel and manner; ends PASS or FAIL
python benchmark.py # serial vs threaded at several grid sizes

Documentation

Example data

data/dir_example.tif is the example basin of the paper: 292 rows by 614 columns at 3 arc-seconds, 93,432 valid cells, 731 km², cut from MERIT Hydro (Yamazaki et al., 2019). The GeoJSON files are the basin boundary and the outlet.

Any D8 GeoTIFF in the same convention works: codes are powers of two clockwise from east, with 0 and 255 terminal. The file's own nodata value is honoured too, which matters because 255 means a terminal here, not nodata.

python example.py --data my_dir.tif

Two ways to use this

Take the structures we release

The structures for the whole 90 m network are on Zenodo, one tile per hydrological region, ready to read. To compute anything on them you also need the flow-direction grid they are indexed against, and that is not redistributed here: get it from its authors at https://global-hydrodynamics.github.io/MERIT_Hydro/, under the CC BY-NC 4.0 terms they set.

Working out which of their files a region needs is the fiddly part, and https://fullhydro.org/fullbasin/regions/ does that for you. Pick a region on the map or in the table and it lists the MERIT Hydro tiles covering it, with the row and column offset to place each one. It does not host MERIT Hydro, only the list. The same page has all 96 region outlines as one GeoJSON, tile names and offsets included, for doing it in a script. Those 96 are 65 continental regions, two that straddle the antimeridian and 29 island groups; the products below cover the 65. Region boxes sit on whole degrees and one degree is exactly 1,200 cells at 3 arc-seconds, so a region cuts out of the global rasters by integer arithmetic, with no resampling.

You do not need a whole region. Clip whatever you like out of one and the structures clip with it: filter a released sequence to the cells you kept and it is still a topological sort of them, and a filtered layering keeps its layers mutually independent, the conflict-free guarantee included. That holds for any subset, because dropping cells from a valid order cannot put a cell before something it depends on, and dropping cells from a layer cannot make two of the survivors depend on each other. Checked on a 12,809-cell subbasin of the bundled example: all three orderings stayed valid, all three layerings kept their layers independent, cfds kept zero conflicts, and recomputing from scratch on the clip reproduced all 12,809 values bit for bit.

Clip however suits you: a basin, a rectangle, a country. Two things behave differently and are worth keeping apart.

The structures describe the network you hand over. Clip it and the structures of the clip are exact: still a topological sort, still layers of independent cells, still zero conflicts under cfds. Any clip, no exceptions.

A kernel answers a question about that same network. Drainage area asks how much area drains into a cell, and how much area is however much you supplied. Clip a catchment in half and the answer halves, not because the computation slipped but because you asked about a smaller catchment. The number is exactly right for the data it was given.

So the only question is whether your clip contains the whole catchment of the cells you care about. A basin does, by definition: clipping one out of the bundled example and recomputing reproduces every cell to the last decimal. A rectangle does for most of them. The cells it gets wrong are exactly the ones with catchment outside the cut, so the share depends on how much drainage the cut intercepts rather than on any fixed number: across thirteen rectangles through the bundled example, between 92.7% and 99.6% of the kept cells came back identical, median 97.8%. For the rest, the ones below a channel the cut crossed, the number describes your rectangle rather than the world.

Clip a rectangle when the area you kept is what you are studying. Clip whole basins, or read the values out of MERIT-DrainAttr, when the numbers have to mean what they mean globally.

Bring your own flow directions

Any D8 grid in this convention works. Build the eight structures yourself and compute on them:

importflowtopotopo=flowtopo.FlowTopo.from_raster("my_dir.tif")
seq=topo.ordering("dfs") # one of three orderingslayers, n=topo.layering("cfds") # one of three layeringspart, load=topo.partition(n_parts=4) # one of two partitionsupa=topo.upstream_area(ordering="dfs") # or any kernel, on any structure

This is the same code that produced the released structures, so a region you build yourself and a region you download are the same thing.

Work a region or a basin at a time rather than the globe. A region encloses only complete basins and stays within 38° × 38°, which keeps its cell indices inside 32-bit integers, which is why this package uses int32 throughout. The region boundaries are in MERIT-FullBasin, below.

Global products

This repository holds the method and one example basin. Applied to the whole 90 m MERIT Hydro network, it produces two Zenodo records, both as per-region GeoTIFFs for 65 regions.

The structures

MERIT-FlowTopo (10.5281/zenodo.20653058) holds the traversal structures themselves. Every region carries the depth-first sequence, the conflict-free downstream and as-late-as-possible layerings, and the subbasin partition; Region 43 (South China) carries all eight, so the alternatives can be compared somewhere. 978 GB uncompressed, 49 GB compressed.

All eight structures, drawn over the whole network: three serial orderings, three parallel layerings, two spatial partitions. The release itself carries four of them per region, and all eight for Region 43.

Because the D8 field does not change, these are computed once and reused without limit. That is the point: a cost every tool currently pays on every run becomes a read.

What the structures compute

MERIT-DrainAttr (10.5281/zenodo.20686664) is the four kernels run over those structures: a baseline, so the variables almost everyone wants need not be recomputed either.

Flow length downstream, flow length upstream and Strahler stream order for every region; upstream drainage area for Region 43 only, since MERIT Hydro already distributes it globally. 622 GB uncompressed, 60 GB compressed.

Anything else is one pass over a structure you already have: a different accumulation, a routing state, a variable nobody has asked for yet. That is why the release is the structures and not the variables.

The input partition

MERIT-FullBasin (10.5281/zenodo.20344112) divides the network into the 65 hydrologically independent regions everything above is organised by. It comes from the companion dataset, not from here.

Acknowledgements

MERIT Hydro (Yamazaki et al., 2019; 10.1029/2019WR024873) provides the flow-direction field everything here traverses: the example basin is cut from it, and the global products are built on its 90 m network. The bundled excerpt keeps its CC BY-NC 4.0 terms.

The representation this package works on comes from pyflwdir (D. Eilander, Deltares and the Institute for Environmental Studies, Vrije Universiteit Amsterdam; MIT licence; 10.5281/zenodo.4287337). What FlowTopo takes from it:

  • the flat downstream-pointer array — for every cell, the linear index of the cell it drains into, with a pit pointing at itself. Every structure and every kernel here is derived from that one array;
  • the D8 decoding conventions — codes as powers of two clockwise from east, 0 and 255 terminal, 247 nodata, and a cell draining off the grid or into nodata treated as a pit;
  • the donor-count array and its sentinel convention;
  • the chain-tracing rank and the breadth-first sequence builder, which appear here in the variants set out in docs/methods.md.

No pyflwdir source is included. The code here was written against those conventions rather than copied from them, and FlowTopo.from_d8 takes the same array pyflwdir.from_array(d8, ftype="d8") takes, so the two read the same rasters.

The three layerings, the conflict-free downstream rule, the propagation manners, the locality metrics and the benchmark drivers are this project's own.

Claude (Anthropic) helped prepare this repository: the Python implementation, the tests, the documentation and the packaging were drafted with it and checked by the authors, and the commit history records where. The structures, the algorithms and the results they produce are the authors' own work, described in the companion manuscript.

Verification

Expected outputs for the example basin are stored with the tests, so a fresh clone verifies itself:

pytest

170 tests. Every ordering, layering, partition, kernel and manner is cross-checked on the example basin, write-conflict counts included. The structures are checked against their definitions directly: an ordering is a topological sort, a layer is an antichain, a receiver comes after its donors, and all of that survives clipping a basin out of a region. Degenerate inputs get their own: an empty grid, a lone cell, networks with cycles. So do the API's promises: cached arrays are read-only, repeated calls agree bit for bit, accumulation keeps the precision it was given.

Those tests and example.py run on Python 3.10, 3.11 and 3.12 on every push. The badge at the top links to the runs.

Contact

Questions, problems and suggestions are welcome by email at lulu_jiang@pku.edu.cn, or as a GitHub issue. Email is the surer way to reach us.

Citing

The companion manuscript is MERIT-FlowTopo v1.0: a reusable computational foundation for hyperresolution hydrology on the global 90 m drainage network (Jiang et al., in preparation). A citation file will be added once it appears; until then, cite the manuscript.

Licence

MIT for the code; see LICENSE. The bundled MERIT Hydro excerpt keeps its own CC BY-NC 4.0 terms; see DATA_NOTICE.md.

About

Serial orderings, parallel layerings and spatial partitions for D8 flow networks, computed once from the flow-direction grid and reused by any drainage-order computation. Python reference implementation.

Topics

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

FlowTopo

testslicence: MIT

Orderings, layerings and partitions for D8 flow networks, in Python.

A D8 flow-direction grid fixes the order in which cells must be visited: a cell can be processed only after its upstream neighbours, or, for some kernels, only after its downstream one. FlowTopo computes that order once, from the flow-direction grid alone, and stores it as a reusable structure. Any routine that walks the network in drainage order (drainage area, flow length, stream order, routing) can then reuse the structure instead of rebuilding the traversal each time.

Three kinds of structure are provided:

  • serial orderings (three): a topological sort of the cells, walked in one pass on one core;
  • parallel layerings (three): cells grouped into layers so that cells in the same layer are independent of each other and can run on several threads;
  • spatial partitions (two): the network cut into independent subregions, one per processor.

Four kernels are bundled to exercise the structures: upstream drainage area, distance to outlet, longest upstream path and Strahler stream order. They are test cases, not the purpose of the package. Each kernel runs on every structure and the results are cross-checked.

In the snippets below, topo is a FlowTopo object built once from a flow-direction raster; Quick start shows how.

This repository holds the Python reference implementation and one worked example basin. The global 90 m products computed with the same method are released separately on Zenodo; see Global products. The companion paper (Jiang et al.) is in preparation and has no link yet.

A narrated video walks through the structures and the three ways of moving a value from a cell to its receiver: https://youtu.be/tE5K2wM3TTY. The animations in this README are reduced previews. Each has a full-size MP4 link beneath it, and all files are in docs/media.

Serial orderings

An ordering is a topological sort of the valid cells. One pass over it computes any kernel, because every cell is visited after whatever it needs. Which end that is depends on the kernel: drainage area needs a cell's donors first, flow length needs its receiver first. A sequence is stored in one direction and reversed on demand, and the kernels do the reversing.

topological sort from the sources
ordering="topo"
breadth-first from the pit
ordering="bfs"
depth-first from the pit
ordering="dfs"
a cell is appended once all its donors are donecells in order of hop count from the pitone tributary subtree at a time
full-size MP4full-size MP4full-size MP4

The three differ in memory access pattern. Depth-first has the lowest simulated L1 miss rate on the example basin: 10.55%, against 21.83% (breadth-first) and 37.02% (topological sort). Those are one alignment of the two arrays the traversal reads, the one where they collide in every cache set. Shift them apart and the numbers fall to about 8.8%, 14.2% and 36.9%. The order does not move, at any alignment tried.

Direction. An ordering is built in one direction and reversed on demand. Depth-first and breadth-first start at the pit, so they come out downstream to upstream (d2u, position 0 is a pit); the topological sort starts at the headwaters, so it comes out upstream to downstream (u2d, position 0 is a headwater). The two are reverses of each other.

Which one a kernel needs follows from the way its values travel. Drainage area, longest upstream path and Strahler order accumulate into the receiver and need u2d; distance to outlet reads from the receiver and needs d2u. The kernels flip the sequence for you, so topo.upstream_area(ordering="dfs") walks the depth-first order upstream to downstream even though it was built the other way. Ask for a direction explicitly with topo.ordering("dfs", "u2d").

Parallel layerings

A layering assigns every cell a layer index such that cells in the same layer are independent of each other. Layers run in order; cells within a layer run in parallel. Layer 0 holds the headwaters.

as soon as possible
layering="asap"
conflict-free downstream
layering="cfds"
as late as possible
layering="alap"
every cell in the earliest layer its donors allowas soon as possible, plus one rule: no two cells in a layer share a receiverevery cell in the latest layer possible
full-size MP4full-size MP4full-size MP4

The minimum layer count is set by the longest flow path. The conflict-free rule may add a few layers; on the example basin it adds none (949 layers for all three).

Layerings are built u2d, layer 0 at the headwaters. The downstream-propagating kernel needs them the other way round, and flips them itself; topo.decomposition("cfds", "d2u") gives that view directly.

Spatial partitions

A layering spreads work across threads that share memory. Splitting the network across processors needs a second cut, along the drainage hierarchy, so that no value crosses a subregion boundary while a kernel runs.

Set n_parts to the number of processors: one subregion each, so a subregion's working set stays in its own memory. The paper's benchmark uses four, because the server has four Xeon Platinum 8270 processors, each a NUMA node with 26 cores, and runs about 13 threads inside each subregion.

Two basins mapped onto two subregions.level="basin" keeps each basin whole, so the 62-cell basin and the 11-cell basin cannot be balanced. level="subbasin" walks up the dominant basin's mainstem, and a tributary subtree moves to the lighter subregion.

Whole basins cannot be split, so one large basin leaves the other processors idle. The bundled example is one basin, so it shows this plainly:

topo.partition(n_parts=4, level="basin")[1] # [93432, 0, 0, 0]topo.partition(n_parts=4, level="subbasin")[1] # [23121, 23121, 23121, 23120]

Subbasin-level walks upstream from the outlet, taking the larger tributary at each confluence. That isolates the mainstem; the tributary subtrees hanging off it are dealt to the lighter subregions, and the mainstem runs in a second stage once they finish. Its cells are marked flowtopo.MAINSTEM. That is why the four subregion loads above come to 92,483 rather than 93,432: the 949 mainstem cells are held back for the second stage and do not count as first-stage work.

Write conflicts

Whichever structure carries the traversal, a kernel still has to move a value from a cell to its receiver. There are three ways to do it:

pullatomic pushpush
each receiver reads its donors; needs the upstream tabledonors write through atomics; correct, but float sums are not reproducibledonors write directly; deterministic, no locks; requires the conflict-free layering
full-size MP4full-size MP4full-size MP4

A push is only safe if no two cells in a layer write to the same receiver. Conflict counts on the example basin (93,432 cells):

layeringconflicting writes inside a layer
as soon as possible12,122
conflict-free downstream0
as late as possible39,130

The count is a property of the layering and can be checked before running. A test run is not a reliable check: a race does not always trigger. Strahler order has no atomic form, because its confluence rule is a comparison rather than an addition, so its only parallel push is under the conflict-free layering.

Because no two cells in a layer share a receiver, the sums always happen in the same order: the conflict-free push returns bit-identical results at any thread count, which an atomic push cannot promise. If manner is not given, FlowTopo picks a safe one for the layering.

Which structure to use

From the paper's benchmark on the full 90 m network (22.2 billion cells, 65 regions):

  • One sweep, one core — the depth-first ordering. Up to 5.1× faster than the slowest serial ordering; subtree contiguity drops the L3 miss rate from 37% to about 6%. It is also the baseline to measure parallel speedup against; a slower baseline overstates the speedup.
  • Repeated traversal (calibration, ensembles) — the as-late-as-possible layering with pull. Fastest in parallel: each layer's working set stays in cache. Pull must store the donor table.
  • Non-linear kernels, or when RAM is tight — the conflict-free downstream layering with push. Lock-free and deterministic, stores only the receiver pointer, and the only parallel option for Strahler order.
  • Across processors — the subbasin partition, run at about 13 threads each, beyond which memory bandwidth rather than the algorithm bounds the speedup.

The structures are computed once from the static D8 field and reused without limit.

Those rankings come from the paper's C run at continental scale. This package is numba over numpy at a much smaller size, and it does not reproduce them term for term. On a 16-million-cell grid here, push under cfds is the fastest parallel form, and pull loses even to the serial sequence: building and reading the donor table costs more than ten threads save. The structures are the same either way. Which one wins depends on your machine and your grid, so measure with benchmark.py before choosing.

Install

pip install -e .# numpy + rasterio
pip install -e ".[speed]"# + numba, for threaded kernels

Needs Python 3.10 or newer. The tests run on 3.10, 3.11 and 3.12. Without numba everything still runs, in pure Python.

Quick start

importflowtopotopo=flowtopo.FlowTopo.from_raster("data/dir_example.tif")
upa=topo.upstream_area(ordering="dfs") # serialupa=topo.upstream_area(layering="cfds", manner="push") # a layer at a timeldn=topo.distance_to_outlet(ordering="dfs")
lup=topo.longest_upstream_path(ldn, ordering="dfs")
strord=topo.strahler_order(ordering="dfs",
channel_mask=topo.channel_mask(upa, 10.0))
part, load=topo.partition(n_parts=4, level="subbasin") # across processorstopo.to_2d(upa) # back on the grid

With numba, the same kernels with threads:

fromflowtopoimportparallelupa=parallel.upstream_area(topo, layering="cfds", manner="push")

Run everything on the example basin:

python example.py # every structure, kernel and manner; ends PASS or FAIL
python benchmark.py # serial vs threaded at several grid sizes

Documentation

Example data

data/dir_example.tif is the example basin of the paper: 292 rows by 614 columns at 3 arc-seconds, 93,432 valid cells, 731 km², cut from MERIT Hydro (Yamazaki et al., 2019). The GeoJSON files are the basin boundary and the outlet.

Any D8 GeoTIFF in the same convention works: codes are powers of two clockwise from east, with 0 and 255 terminal. The file's own nodata value is honoured too, which matters because 255 means a terminal here, not nodata.

python example.py --data my_dir.tif

Two ways to use this

Take the structures we release

The structures for the whole 90 m network are on Zenodo, one tile per hydrological region, ready to read. To compute anything on them you also need the flow-direction grid they are indexed against, and that is not redistributed here: get it from its authors at https://global-hydrodynamics.github.io/MERIT_Hydro/, under the CC BY-NC 4.0 terms they set.

Working out which of their files a region needs is the fiddly part, and https://fullhydro.org/fullbasin/regions/ does that for you. Pick a region on the map or in the table and it lists the MERIT Hydro tiles covering it, with the row and column offset to place each one. It does not host MERIT Hydro, only the list. The same page has all 96 region outlines as one GeoJSON, tile names and offsets included, for doing it in a script. Those 96 are 65 continental regions, two that straddle the antimeridian and 29 island groups; the products below cover the 65. Region boxes sit on whole degrees and one degree is exactly 1,200 cells at 3 arc-seconds, so a region cuts out of the global rasters by integer arithmetic, with no resampling.

You do not need a whole region. Clip whatever you like out of one and the structures clip with it: filter a released sequence to the cells you kept and it is still a topological sort of them, and a filtered layering keeps its layers mutually independent, the conflict-free guarantee included. That holds for any subset, because dropping cells from a valid order cannot put a cell before something it depends on, and dropping cells from a layer cannot make two of the survivors depend on each other. Checked on a 12,809-cell subbasin of the bundled example: all three orderings stayed valid, all three layerings kept their layers independent, cfds kept zero conflicts, and recomputing from scratch on the clip reproduced all 12,809 values bit for bit.

Clip however suits you: a basin, a rectangle, a country. Two things behave differently and are worth keeping apart.

The structures describe the network you hand over. Clip it and the structures of the clip are exact: still a topological sort, still layers of independent cells, still zero conflicts under cfds. Any clip, no exceptions.

A kernel answers a question about that same network. Drainage area asks how much area drains into a cell, and how much area is however much you supplied. Clip a catchment in half and the answer halves, not because the computation slipped but because you asked about a smaller catchment. The number is exactly right for the data it was given.

So the only question is whether your clip contains the whole catchment of the cells you care about. A basin does, by definition: clipping one out of the bundled example and recomputing reproduces every cell to the last decimal. A rectangle does for most of them. The cells it gets wrong are exactly the ones with catchment outside the cut, so the share depends on how much drainage the cut intercepts rather than on any fixed number: across thirteen rectangles through the bundled example, between 92.7% and 99.6% of the kept cells came back identical, median 97.8%. For the rest, the ones below a channel the cut crossed, the number describes your rectangle rather than the world.

Clip a rectangle when the area you kept is what you are studying. Clip whole basins, or read the values out of MERIT-DrainAttr, when the numbers have to mean what they mean globally.

Bring your own flow directions

Any D8 grid in this convention works. Build the eight structures yourself and compute on them:

importflowtopotopo=flowtopo.FlowTopo.from_raster("my_dir.tif")
seq=topo.ordering("dfs") # one of three orderingslayers, n=topo.layering("cfds") # one of three layeringspart, load=topo.partition(n_parts=4) # one of two partitionsupa=topo.upstream_area(ordering="dfs") # or any kernel, on any structure

This is the same code that produced the released structures, so a region you build yourself and a region you download are the same thing.

Work a region or a basin at a time rather than the globe. A region encloses only complete basins and stays within 38° × 38°, which keeps its cell indices inside 32-bit integers, which is why this package uses int32 throughout. The region boundaries are in MERIT-FullBasin, below.

Global products

This repository holds the method and one example basin. Applied to the whole 90 m MERIT Hydro network, it produces two Zenodo records, both as per-region GeoTIFFs for 65 regions.

The structures

MERIT-FlowTopo (10.5281/zenodo.20653058) holds the traversal structures themselves. Every region carries the depth-first sequence, the conflict-free downstream and as-late-as-possible layerings, and the subbasin partition; Region 43 (South China) carries all eight, so the alternatives can be compared somewhere. 978 GB uncompressed, 49 GB compressed.

All eight structures, drawn over the whole network: three serial orderings, three parallel layerings, two spatial partitions. The release itself carries four of them per region, and all eight for Region 43.

Because the D8 field does not change, these are computed once and reused without limit. That is the point: a cost every tool currently pays on every run becomes a read.

What the structures compute

MERIT-DrainAttr (10.5281/zenodo.20686664) is the four kernels run over those structures: a baseline, so the variables almost everyone wants need not be recomputed either.

Flow length downstream, flow length upstream and Strahler stream order for every region; upstream drainage area for Region 43 only, since MERIT Hydro already distributes it globally. 622 GB uncompressed, 60 GB compressed.

Anything else is one pass over a structure you already have: a different accumulation, a routing state, a variable nobody has asked for yet. That is why the release is the structures and not the variables.

The input partition

MERIT-FullBasin (10.5281/zenodo.20344112) divides the network into the 65 hydrologically independent regions everything above is organised by. It comes from the companion dataset, not from here.

Acknowledgements

MERIT Hydro (Yamazaki et al., 2019; 10.1029/2019WR024873) provides the flow-direction field everything here traverses: the example basin is cut from it, and the global products are built on its 90 m network. The bundled excerpt keeps its CC BY-NC 4.0 terms.

The representation this package works on comes from pyflwdir (D. Eilander, Deltares and the Institute for Environmental Studies, Vrije Universiteit Amsterdam; MIT licence; 10.5281/zenodo.4287337). What FlowTopo takes from it:

  • the flat downstream-pointer array — for every cell, the linear index of the cell it drains into, with a pit pointing at itself. Every structure and every kernel here is derived from that one array;
  • the D8 decoding conventions — codes as powers of two clockwise from east, 0 and 255 terminal, 247 nodata, and a cell draining off the grid or into nodata treated as a pit;
  • the donor-count array and its sentinel convention;
  • the chain-tracing rank and the breadth-first sequence builder, which appear here in the variants set out in docs/methods.md.

No pyflwdir source is included. The code here was written against those conventions rather than copied from them, and FlowTopo.from_d8 takes the same array pyflwdir.from_array(d8, ftype="d8") takes, so the two read the same rasters.

The three layerings, the conflict-free downstream rule, the propagation manners, the locality metrics and the benchmark drivers are this project's own.

Claude (Anthropic) helped prepare this repository: the Python implementation, the tests, the documentation and the packaging were drafted with it and checked by the authors, and the commit history records where. The structures, the algorithms and the results they produce are the authors' own work, described in the companion manuscript.

Verification

Expected outputs for the example basin are stored with the tests, so a fresh clone verifies itself:

pytest

170 tests. Every ordering, layering, partition, kernel and manner is cross-checked on the example basin, write-conflict counts included. The structures are checked against their definitions directly: an ordering is a topological sort, a layer is an antichain, a receiver comes after its donors, and all of that survives clipping a basin out of a region. Degenerate inputs get their own: an empty grid, a lone cell, networks with cycles. So do the API's promises: cached arrays are read-only, repeated calls agree bit for bit, accumulation keeps the precision it was given.

Those tests and example.py run on Python 3.10, 3.11 and 3.12 on every push. The badge at the top links to the runs.

Contact

Questions, problems and suggestions are welcome by email at lulu_jiang@pku.edu.cn, or as a GitHub issue. Email is the surer way to reach us.

Citing

The companion manuscript is MERIT-FlowTopo v1.0: a reusable computational foundation for hyperresolution hydrology on the global 90 m drainage network (Jiang et al., in preparation). A citation file will be added once it appears; until then, cite the manuscript.

Licence

MIT for the code; see LICENSE. The bundled MERIT Hydro excerpt keeps its own CC BY-NC 4.0 terms; see DATA_NOTICE.md.

About

Serial orderings, parallel layerings and spatial partitions for D8 flow networks, computed once from the flow-direction grid and reused by any drainage-order computation. Python reference implementation.

Topics

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages