Skip to content

Commit 583883e

Browse files
authored
Add MFD flow direction and accumulation (#946) (#956)
* Add MFD flow direction with adaptive exponent (#946) Implements Qin et al. (2007) multiple flow direction algorithm that partitions flow from each cell to all downslope neighbors. The adaptive exponent adjusts based on the ratio of maximum to mean downslope gradient. Supports all four backends. * Add tests for flow_direction_mfd (#946) 46 tests covering correctness, NaN handling, boundary modes, fixed vs adaptive exponent, cross-backend equality (numpy, dask, cupy, dask+cupy), dtype acceptance, and Dataset support. * Add MFD docs, README entry, and user guide section (#946) Adds flow_direction_mfd to the hydrology reference docs, the README feature matrix, and the hydrology user guide notebook with adaptive vs fixed exponent comparison plots. * Add D8/Dinf/MFD comparison to hydrology notebook (#946) Adds side-by-side comparison of all three flow direction algorithms and MFD exponent mode comparison (p=1 vs adaptive vs p=8). * Add MFD flow accumulation (#946) Accumulates upstream contributing area through all MFD flow paths, weighted by directional fractions from flow_direction_mfd. Supports numpy, cupy, dask+numpy, and dask+cupy backends.
1 parent 214fca7 commit 583883e

File tree

9 files changed

+2159
-10
lines changed

9 files changed

+2159
-10
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,9 @@ In the GIS world, rasters are used for representing continuous phenomena (e.g. e
271271
|:----------:|:------------|:----------------------:|:--------------------:|:-------------------:|:------:|
272272
| [Flow Direction (D8)](xrspatial/flow_direction.py) | Computes D8 flow direction from each cell toward the steepest downhill neighbor | ✅️ | ✅️ | ✅️ | ✅️ |
273273
| [Flow Direction (Dinf)](xrspatial/flow_direction_dinf.py) | Computes D-infinity flow direction as a continuous angle toward the steepest downslope facet | ✅️ | ✅️ | ✅️ | ✅️ |
274+
| [Flow Direction (MFD)](xrspatial/flow_direction_mfd.py) | Partitions flow to all downslope neighbors with an adaptive exponent (Qin et al. 2007) | ✅️ | ✅️ | ✅️ | ✅️ |
274275
| [Flow Accumulation (D8)](xrspatial/flow_accumulation.py) | Counts upstream cells draining through each cell in a D8 flow direction grid | ✅️ | ✅️ | ✅️ | 🔄 |
276+
| [Flow Accumulation (MFD)](xrspatial/flow_accumulation_mfd.py) | Accumulates upstream area through all MFD flow paths weighted by directional fractions | ✅️ | ✅️ | ✅️ | 🔄 |
275277
| [Watershed](xrspatial/watershed.py) | Labels each cell with the pour point it drains to via D8 flow direction | ✅️ | ✅️ | ✅️ | 🔄 |
276278
| [Basins](xrspatial/watershed.py) | Delineates drainage basins by labeling each cell with its outlet ID | ✅️ | ✅️ | ✅️ | 🔄 |
277279
| [Stream Order](xrspatial/stream_order.py) | Assigns Strahler or Shreve stream order to cells in a drainage network | ✅️ | ✅️ | ✅️ | 🔄 |

docs/source/reference/hydrology.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,27 @@ Flow Direction (D-infinity)
1818

1919
xrspatial.flow_direction_dinf.flow_direction_dinf
2020

21+
Flow Direction (MFD)
22+
====================
23+
.. autosummary::
24+
:toctree: _autosummary
25+
26+
xrspatial.flow_direction_mfd.flow_direction_mfd
27+
2128
Flow Accumulation
2229
=================
2330
.. autosummary::
2431
:toctree: _autosummary
2532

2633
xrspatial.flow_accumulation.flow_accumulation
2734

35+
Flow Accumulation (MFD)
36+
=======================
37+
.. autosummary::
38+
:toctree: _autosummary
39+
40+
xrspatial.flow_accumulation_mfd.flow_accumulation_mfd
41+
2842
Flow Length
2943
===========
3044
.. autosummary::

examples/user_guide/11_Hydrology.ipynb

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"cell_type": "markdown",
55
"id": "title",
66
"metadata": {},
7-
"source": "# Xarray-spatial\n### User Guide: Hydrology tools\n-----\nThe Hydrology tools provide a complete workflow for analyzing drainage patterns and water flow across a terrain surface. Starting from a digital elevation model (DEM), you can compute flow directions, accumulate flow, delineate watersheds, and extract stream networks.\n\nThis guide walks through each tool in the order you would typically use them:\n\n[Sink Detection](#Sink-Detection): Identifies and labels depression cells in a flow direction grid.\n\n[Depression Filling](#Depression-Filling): Removes sinks from a DEM so water can flow continuously to the edges.\n\n[Flow Direction](#Flow-Direction): Computes D8 flow direction for each cell based on steepest descent.\n\n[Flow Accumulation](#Flow-Accumulation): Counts how many upstream cells drain through each cell.\n\n[Drainage Basins](#Drainage-Basins): Labels every cell with the ID of the outlet it drains to.\n\n[Stream Order](#Stream-Order): Assigns Strahler or Shreve stream order to cells in the drainage network.\n\n[Stream Link](#Stream-Link): Segments the stream network into individually labeled links.\n\n[Snap Pour Point](#Snap-Pour-Point): Moves user-placed pour points onto the nearest high-accumulation cell.\n\n[Watershed Delineation](#Watershed-Delineation): Labels every cell with the pour point it drains to.\n\n[Flow Path Tracing](#Flow-Path-Tracing): Traces downstream paths from selected start points to their outlets.\n\n-----------"
7+
"source": "# Xarray-spatial\n### User Guide: Hydrology tools\n-----\nThe Hydrology tools provide a complete workflow for analyzing drainage patterns and water flow across a terrain surface. Starting from a digital elevation model (DEM), you can compute flow directions, accumulate flow, delineate watersheds, and extract stream networks.\n\nThis guide walks through each tool in the order you would typically use them:\n\n[Sink Detection](#Sink-Detection): Identifies and labels depression cells in a flow direction grid.\n\n[Depression Filling](#Depression-Filling): Removes sinks from a DEM so water can flow continuously to the edges.\n\n[Flow Direction](#Flow-Direction): Computes D8 flow direction for each cell based on steepest descent.\n\n[Multiple Flow Direction (MFD)](#Multiple-Flow-Direction-(MFD)): Partitions flow to all downslope neighbors with an adaptive exponent.\n\n[Flow Accumulation](#Flow-Accumulation): Counts how many upstream cells drain through each cell.\n\n[Drainage Basins](#Drainage-Basins): Labels every cell with the ID of the outlet it drains to.\n\n[Stream Order](#Stream-Order): Assigns Strahler or Shreve stream order to cells in the drainage network.\n\n[Stream Link](#Stream-Link): Segments the stream network into individually labeled links.\n\n[Snap Pour Point](#Snap-Pour-Point): Moves user-placed pour points onto the nearest high-accumulation cell.\n\n[Watershed Delineation](#Watershed-Delineation): Labels every cell with the pour point it drains to.\n\n[Flow Path Tracing](#Flow-Path-Tracing): Traces downstream paths from selected start points to their outlets.\n\n-----------"
88
},
99
{
1010
"cell_type": "code",
@@ -296,6 +296,44 @@
296296
}
297297
]
298298
},
299+
{
300+
"cell_type": "markdown",
301+
"id": "nbozw06cw1",
302+
"source": "## Multiple Flow Direction (MFD)\n\nD8 sends all flow to a single neighbor, which works well for channelized flow but not for dispersive hillslope flow. The `flow_direction_mfd` function partitions flow from each cell to **all** downslope neighbors. An adaptive exponent from Qin et al. (2007) adjusts per-cell: steep convergent terrain concentrates flow (closer to D8), while gentle slopes spread it out.\n\nThe output is a 3-D DataArray `(8, H, W)` where each band holds the fraction of flow directed to one of the 8 neighbors (E, SE, S, SW, W, NW, N, NE). Fractions sum to 1.0 at each cell.\n\nParameters:\n- **p**: flow-partition exponent. `None` (default) uses the adaptive exponent. A positive float sets a fixed exponent (e.g. `p=1.0` for Quinn et al. 1991).\n- **boundary**: same edge-handling options as `flow_direction`.",
303+
"metadata": {}
304+
},
305+
{
306+
"cell_type": "code",
307+
"id": "48fq6hhjuwj",
308+
"source": "mfd_fracs = xrspatial.flow_direction_mfd(dem_filled)\nprint(f\"MFD output shape: {mfd_fracs.shape}\")\nprint(f\"Dims: {mfd_fracs.dims}\")\nprint(f\"Neighbors: {list(mfd_fracs.coords['neighbor'].values)}\")\n\n# Show the maximum fraction per cell as a measure of flow concentration.\n# Values near 1.0 = almost all flow to one neighbor (D8-like).\n# Values near 0.125 = flow spread nearly evenly across 8 neighbors.\nimport matplotlib.pyplot as plt\n\nmax_frac = mfd_fracs.max(dim='neighbor')\nn_receivers = (mfd_fracs > 0).sum(dim='neighbor').astype(float)\nn_receivers = n_receivers.where(~np.isnan(mfd_fracs.isel(neighbor=0)))\n\nfig, axes = plt.subplots(1, 2, figsize=(14, 5))\n\nmax_frac.plot(ax=axes[0], cmap='YlOrRd', vmin=0, vmax=1)\naxes[0].set_title('Max flow fraction (higher = more concentrated)')\n\nn_receivers.plot(ax=axes[1], cmap='viridis', vmin=0, vmax=8)\naxes[1].set_title('Number of receiving neighbors')\n\nplt.tight_layout()\nplt.show()",
309+
"metadata": {},
310+
"execution_count": null,
311+
"outputs": []
312+
},
313+
{
314+
"cell_type": "markdown",
315+
"id": "8ua76wuod0q",
316+
"source": "### Comparing D8, D-infinity, and MFD\n\nThe three flow direction algorithms represent different trade-offs between simplicity and physical realism:\n\n- **D8**: all flow goes to the single steepest neighbor. Produces clean, deterministic channels but can't represent dispersive hillslope flow.\n- **D-infinity** (Tarboton 1997): flow direction is a continuous angle toward the steepest downslope facet. Splits flow between at most two neighbors. Better for smooth surfaces but still limited to two receivers.\n- **MFD** (Qin et al. 2007): partitions flow to all downslope neighbors. Most realistic for hillslope processes but produces 8 output bands instead of one.\n\nBelow we compare all three on the same DEM.",
317+
"metadata": {},
318+
"execution_count": null,
319+
"outputs": []
320+
},
321+
{
322+
"cell_type": "code",
323+
"id": "tz72zbmimfk",
324+
"source": "# D-infinity flow direction (continuous angle in radians)\nflow_dir_dinf = xrspatial.flow_direction_dinf(dem_filled)\n\nfig, axes = plt.subplots(1, 3, figsize=(18, 5))\n\n# D8: color-code the 9 direction codes\nshade_d8 = flow_dir.copy()\nshade_d8.plot(ax=axes[0], cmap='hsv', add_colorbar=True)\naxes[0].set_title('D8 direction code\\n(one of 9 discrete values)')\n\n# Dinf: continuous angle 0-2pi\nflow_dir_dinf.plot(ax=axes[1], cmap='hsv', vmin=0, vmax=2*np.pi, add_colorbar=True)\naxes[1].set_title('D-infinity angle (radians)\\n(continuous 0 to 2pi)')\n\n# MFD: show max fraction as a proxy for how concentrated the flow is\nmax_frac.plot(ax=axes[2], cmap='YlOrRd', vmin=0, vmax=1, add_colorbar=True)\naxes[2].set_title('MFD max fraction\\n(1.0 = all flow to one neighbor)')\n\nfor ax in axes:\n ax.set_xlabel('')\n ax.set_ylabel('')\n\nplt.suptitle('Flow direction comparison: D8 vs D-infinity vs MFD')\nplt.tight_layout()\nplt.show()\n\n# Key differences in a small summary\nd8_codes = flow_dir.values[~np.isnan(flow_dir.values)]\nmfd_max = max_frac.values[~np.isnan(max_frac.values)]\nmfd_max_nonzero = mfd_max[mfd_max > 0]\n\nprint(f\"D8: {len(np.unique(d8_codes))} unique direction codes (always 1 receiver)\")\nprint(f\"Dinf: continuous angle, splits flow between at most 2 neighbors\")\nprint(f\"MFD: median max fraction = {np.median(mfd_max_nonzero):.3f}, \"\n f\"mean receivers = {(n_receivers.values[~np.isnan(n_receivers.values)]).mean():.1f}\")",
325+
"metadata": {},
326+
"execution_count": null,
327+
"outputs": []
328+
},
329+
{
330+
"cell_type": "code",
331+
"id": "n9gfafn0u4b",
332+
"source": "# Compare MFD exponent modes\nmfd_p1 = xrspatial.flow_direction_mfd(dem_filled, p=1.0)\nmfd_p8 = xrspatial.flow_direction_mfd(dem_filled, p=8.0)\n\nfig, axes = plt.subplots(1, 3, figsize=(16, 5))\nfor ax, data, title in zip(\n axes,\n [mfd_p1.max(dim='neighbor'), max_frac, mfd_p8.max(dim='neighbor')],\n ['p=1.0 (dispersive)', 'Adaptive (Qin 2007)', 'p=8.0 (concentrated)'],\n):\n data.plot(ax=ax, cmap='YlOrRd', vmin=0, vmax=1)\n ax.set_title(title)\n ax.set_xlabel('')\n ax.set_ylabel('')\n\nplt.suptitle('MFD exponent comparison: lower p spreads flow, higher p concentrates it')\nplt.tight_layout()\nplt.show()",
333+
"metadata": {},
334+
"execution_count": null,
335+
"outputs": []
336+
},
299337
{
300338
"cell_type": "markdown",
301339
"id": "flowacc-md",
@@ -423,6 +461,20 @@
423461
")"
424462
]
425463
},
464+
{
465+
"cell_type": "markdown",
466+
"id": "httgn03vcco",
467+
"source": "## Flow Accumulation (MFD)\n\n`flow_accumulation_mfd` takes the 3-D fractional output from\n`flow_direction_mfd` and routes upstream contributing area through all\ndownslope paths at once. Where D8 accumulation produces sharp, single-pixel\ndrainage lines, MFD accumulation spreads flow across the landscape and\nproduces smoother contributing-area fields.",
468+
"metadata": {}
469+
},
470+
{
471+
"cell_type": "code",
472+
"id": "btr592uiuve",
473+
"source": "flow_accum_mfd = xrspatial.flow_accumulation_mfd(mfd)\n\nfig, axes = plt.subplots(1, 2, figsize=(14, 5))\n\n# D8 accumulation (log scale)\nim0 = axes[0].imshow(np.log1p(flow_accum.values), cmap='Blues')\naxes[0].set_title('D8 flow accumulation (log)')\nfig.colorbar(im0, ax=axes[0], shrink=0.6)\n\n# MFD accumulation (log scale)\nim1 = axes[1].imshow(np.log1p(flow_accum_mfd.values), cmap='Blues')\naxes[1].set_title('MFD flow accumulation (log)')\nfig.colorbar(im1, ax=axes[1], shrink=0.6)\n\nplt.tight_layout()\nplt.show()",
474+
"metadata": {},
475+
"execution_count": null,
476+
"outputs": []
477+
},
426478
{
427479
"cell_type": "markdown",
428480
"id": "basin-md",
@@ -911,15 +963,7 @@
911963
"cell_type": "markdown",
912964
"id": "references",
913965
"metadata": {},
914-
"source": [
915-
"## References\n",
916-
"\n",
917-
"- Jenson, S.K. and Domingue, J.O. (1988). Extracting Topographic Structure from Digital Elevation Data for Geographic Information System Analysis. *Photogrammetric Engineering and Remote Sensing*, 54(11), 1593-1600.\n",
918-
"- Planchon, O. and Darboux, F. (2001). A fast, simple and versatile algorithm to fill the depressions of digital elevation models. *Catena*, 46(2-3), 159-176.\n",
919-
"- Strahler, A.N. (1957). Quantitative analysis of watershed geomorphology. *Transactions of the American Geophysical Union*, 38(6), 913-920.\n",
920-
"- Copernicus DEM on AWS: https://registry.opendata.aws/copernicus-dem/\n",
921-
"- ESRI Hydrology Toolset: https://pro.arcgis.com/en/pro-app/tool-reference/spatial-analyst/an-overview-of-the-hydrology-tools.htm"
922-
]
966+
"source": "## References\n\n- Jenson, S.K. and Domingue, J.O. (1988). Extracting Topographic Structure from Digital Elevation Data for Geographic Information System Analysis. *Photogrammetric Engineering and Remote Sensing*, 54(11), 1593-1600.\n- Planchon, O. and Darboux, F. (2001). A fast, simple and versatile algorithm to fill the depressions of digital elevation models. *Catena*, 46(2-3), 159-176.\n- Quinn, P., Beven, K., Chevallier, P., and Planchon, O. (1991). The prediction of hillslope flow paths for distributed hydrological modelling using digital terrain models. *Hydrological Processes*, 5(1), 59-79.\n- Qin, C., Zhu, A.X., Pei, T., Li, B., Zhou, C., and Yang, L. (2007). An adaptive approach to selecting a flow-partition exponent for a multiple-flow-direction algorithm. *International Journal of Geographical Information Science*, 21(4), 443-458.\n- Strahler, A.N. (1957). Quantitative analysis of watershed geomorphology. *Transactions of the American Geophysical Union*, 38(6), 913-920.\n- Tarboton, D.G. (1997). A new method for the determination of flow directions and upslope areas in grid digital elevation models. *Water Resources Research*, 33(2), 309-319.\n- Copernicus DEM on AWS: https://registry.opendata.aws/copernicus-dem/\n- ESRI Hydrology Toolset: https://pro.arcgis.com/en/pro-app/tool-reference/spatial-analyst/an-overview-of-the-hydrology-tools.htm"
923967
}
924968
],
925969
"metadata": {

xrspatial/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,10 @@
3636
from xrspatial.flood import inundation # noqa
3737
from xrspatial.flood import travel_time # noqa
3838
from xrspatial.flow_accumulation import flow_accumulation # noqa
39+
from xrspatial.flow_accumulation_mfd import flow_accumulation_mfd # noqa
3940
from xrspatial.flow_direction import flow_direction # noqa
4041
from xrspatial.flow_direction_dinf import flow_direction_dinf # noqa
42+
from xrspatial.flow_direction_mfd import flow_direction_mfd # noqa
4143
from xrspatial.flow_length import flow_length # noqa
4244
from xrspatial.flow_path import flow_path # noqa
4345
from xrspatial.focal import mean # noqa

xrspatial/accessor.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,18 @@ def flow_direction_dinf(self, **kwargs):
6363
from .flow_direction_dinf import flow_direction_dinf
6464
return flow_direction_dinf(self._obj, **kwargs)
6565

66+
def flow_direction_mfd(self, **kwargs):
67+
from .flow_direction_mfd import flow_direction_mfd
68+
return flow_direction_mfd(self._obj, **kwargs)
69+
6670
def flow_accumulation(self, **kwargs):
6771
from .flow_accumulation import flow_accumulation
6872
return flow_accumulation(self._obj, **kwargs)
6973

74+
def flow_accumulation_mfd(self, **kwargs):
75+
from .flow_accumulation_mfd import flow_accumulation_mfd
76+
return flow_accumulation_mfd(self._obj, **kwargs)
77+
7078
def watershed(self, pour_points, **kwargs):
7179
from .watershed import watershed
7280
return watershed(self._obj, pour_points, **kwargs)
@@ -399,10 +407,18 @@ def flow_direction_dinf(self, **kwargs):
399407
from .flow_direction_dinf import flow_direction_dinf
400408
return flow_direction_dinf(self._obj, **kwargs)
401409

410+
def flow_direction_mfd(self, **kwargs):
411+
from .flow_direction_mfd import flow_direction_mfd
412+
return flow_direction_mfd(self._obj, **kwargs)
413+
402414
def flow_accumulation(self, **kwargs):
403415
from .flow_accumulation import flow_accumulation
404416
return flow_accumulation(self._obj, **kwargs)
405417

418+
def flow_accumulation_mfd(self, **kwargs):
419+
from .flow_accumulation_mfd import flow_accumulation_mfd
420+
return flow_accumulation_mfd(self._obj, **kwargs)
421+
406422
def watershed(self, pour_points, **kwargs):
407423
from .watershed import watershed
408424
return watershed(self._obj, pour_points, **kwargs)

0 commit comments

Comments
 (0)