Skip to content

Commit 611da8a

Browse files
committed
Add D8 flow direction module: foundational hydrology operation
Implements D8 flow direction using ESRI power-of-2 encoding (compatible with GDAL/ArcGIS) across all four backends (numpy, cupy, dask+numpy, dask+cupy). Follows the slope.py pattern with explicit cellsize- parameterized backend wrappers. New files: - xrspatial/flow_direction.py — CPU kernel (@ngjit), GPU device/global kernels, four backend wrappers, public API with @supports_dataset - xrspatial/tests/test_flow_direction.py — 66 tests (flat, cardinal, diagonal, known bowl, NaN handling, cross-backend, boundary modes, dtype acceptance, dataset support, cellsize effect, valid codes) - benchmarks/benchmarks/flow_direction.py — ASV benchmark Modified files: - xrspatial/__init__.py — export flow_direction - xrspatial/accessor.py — add Hydrology section to both accessors - README.md — add Hydrology section with Flow Direction entry
1 parent 80e073f commit 611da8a

File tree

6 files changed

+755
-0
lines changed

6 files changed

+755
-0
lines changed

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,14 @@ In the GIS world, rasters are used for representing continuous phenomena (e.g. e
239239

240240
-----------
241241

242+
### **Hydrology**
243+
244+
| Name | Description | NumPy xr.DataArray | Dask xr.DataArray | CuPy GPU xr.DataArray | Dask GPU xr.DataArray |
245+
|:----------:|:------------|:----------------------:|:--------------------:|:-------------------:|:------:|
246+
| [Flow Direction (D8)](xrspatial/flow_direction.py) | Computes D8 flow direction from each cell toward the steepest downhill neighbor | ✅️ | ✅️ | ✅️ | ✅️ |
247+
248+
-----------
249+
242250
### **Zonal**
243251

244252
| Name | Description | NumPy xr.DataArray | Dask xr.DataArray | CuPy GPU xr.DataArray | Dask GPU xr.DataArray |
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from xrspatial import flow_direction
2+
3+
from .common import Benchmarking
4+
5+
6+
class FlowDirection(Benchmarking):
7+
def __init__(self):
8+
super().__init__(func=flow_direction)
9+
10+
def time_flow_direction(self, nx, type):
11+
return self.time(nx, type)

xrspatial/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from xrspatial.classify import reclassify # noqa
1515
from xrspatial.curvature import curvature # noqa
1616
from xrspatial.emerging_hotspots import emerging_hotspots # noqa
17+
from xrspatial.flow_direction import flow_direction # noqa
1718
from xrspatial.focal import mean # noqa
1819
from xrspatial.hillshade import hillshade # noqa
1920
from xrspatial.mahalanobis import mahalanobis # noqa

xrspatial/accessor.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@ def roughness(self, **kwargs):
5353
from .terrain_metrics import roughness
5454
return roughness(self._obj, **kwargs)
5555

56+
# ---- Hydrology ----
57+
58+
def flow_direction(self, **kwargs):
59+
from .flow_direction import flow_direction
60+
return flow_direction(self._obj, **kwargs)
61+
5662
def viewshed(self, x, y, **kwargs):
5763
from .viewshed import viewshed
5864
return viewshed(self._obj, x, y, **kwargs)
@@ -249,6 +255,12 @@ def roughness(self, **kwargs):
249255
from .terrain_metrics import roughness
250256
return roughness(self._obj, **kwargs)
251257

258+
# ---- Hydrology ----
259+
260+
def flow_direction(self, **kwargs):
261+
from .flow_direction import flow_direction
262+
return flow_direction(self._obj, **kwargs)
263+
252264
# ---- Classification ----
253265

254266
def natural_breaks(self, **kwargs):

xrspatial/flow_direction.py

Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
from __future__ import annotations
2+
3+
import math
4+
from functools import partial
5+
from typing import Union
6+
7+
try:
8+
import cupy
9+
except ImportError:
10+
class cupy(object):
11+
ndarray = False
12+
13+
try:
14+
import dask.array as da
15+
except ImportError:
16+
da = None
17+
18+
import numpy as np
19+
import xarray as xr
20+
from numba import cuda
21+
22+
from xrspatial.utils import ArrayTypeFunctionMapping
23+
from xrspatial.utils import _boundary_to_dask
24+
from xrspatial.utils import _pad_array
25+
from xrspatial.utils import _validate_boundary
26+
from xrspatial.utils import _validate_raster
27+
from xrspatial.utils import cuda_args
28+
from xrspatial.utils import get_dataarray_resolution
29+
from xrspatial.utils import ngjit
30+
from xrspatial.dataset_support import supports_dataset
31+
32+
33+
# =====================================================================
34+
# CPU kernel
35+
# =====================================================================
36+
37+
@ngjit
38+
def _cpu(data, cellsize_x, cellsize_y):
39+
out = np.empty(data.shape, np.float64)
40+
out[:] = np.nan
41+
rows, cols = data.shape
42+
43+
# 8 neighbor offsets: E, SE, S, SW, W, NW, N, NE
44+
dy = np.array([0, 1, 1, 1, 0, -1, -1, -1])
45+
dx = np.array([1, 1, 0, -1, -1, -1, 0, 1])
46+
codes = np.array([1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 128.0])
47+
48+
diag = math.sqrt(cellsize_x * cellsize_x + cellsize_y * cellsize_y)
49+
# distances: E=cx, SE=diag, S=cy, SW=diag, W=cx, NW=diag, N=cy, NE=diag
50+
dists = np.array([cellsize_x, diag, cellsize_y, diag,
51+
cellsize_x, diag, cellsize_y, diag])
52+
53+
for y in range(1, rows - 1):
54+
for x in range(1, cols - 1):
55+
center = data[y, x]
56+
if center != center: # NaN check
57+
continue
58+
59+
has_nan = False
60+
for k in range(8):
61+
v = data[y + dy[k], x + dx[k]]
62+
if v != v:
63+
has_nan = True
64+
break
65+
if has_nan:
66+
continue
67+
68+
max_slope = -math.inf
69+
direction = 0.0
70+
for k in range(8):
71+
v = data[y + dy[k], x + dx[k]]
72+
grad = (center - v) / dists[k]
73+
if grad > max_slope:
74+
max_slope = grad
75+
direction = codes[k]
76+
77+
if max_slope <= 0.0:
78+
out[y, x] = 0.0
79+
else:
80+
out[y, x] = direction
81+
82+
return out
83+
84+
85+
# =====================================================================
86+
# GPU kernels
87+
# =====================================================================
88+
89+
@cuda.jit(device=True)
90+
def _gpu(arr, cellsize_x, cellsize_y):
91+
center = arr[1, 1]
92+
if center != center:
93+
return center # NaN
94+
95+
cx = cellsize_x[0]
96+
cy = cellsize_y[0]
97+
diag = (cx * cx + cy * cy) ** 0.5
98+
99+
max_slope = -1.0e308
100+
direction = 0.0
101+
102+
# E: arr[1, 2], distance = cx, code = 1
103+
v = arr[1, 2]
104+
if v != v:
105+
return v
106+
grad = (center - v) / cx
107+
if grad > max_slope:
108+
max_slope = grad
109+
direction = 1.0
110+
111+
# SE: arr[2, 2], distance = diag, code = 2
112+
v = arr[2, 2]
113+
if v != v:
114+
return v
115+
grad = (center - v) / diag
116+
if grad > max_slope:
117+
max_slope = grad
118+
direction = 2.0
119+
120+
# S: arr[2, 1], distance = cy, code = 4
121+
v = arr[2, 1]
122+
if v != v:
123+
return v
124+
grad = (center - v) / cy
125+
if grad > max_slope:
126+
max_slope = grad
127+
direction = 4.0
128+
129+
# SW: arr[2, 0], distance = diag, code = 8
130+
v = arr[2, 0]
131+
if v != v:
132+
return v
133+
grad = (center - v) / diag
134+
if grad > max_slope:
135+
max_slope = grad
136+
direction = 8.0
137+
138+
# W: arr[1, 0], distance = cx, code = 16
139+
v = arr[1, 0]
140+
if v != v:
141+
return v
142+
grad = (center - v) / cx
143+
if grad > max_slope:
144+
max_slope = grad
145+
direction = 16.0
146+
147+
# NW: arr[0, 0], distance = diag, code = 32
148+
v = arr[0, 0]
149+
if v != v:
150+
return v
151+
grad = (center - v) / diag
152+
if grad > max_slope:
153+
max_slope = grad
154+
direction = 32.0
155+
156+
# N: arr[0, 1], distance = cy, code = 64
157+
v = arr[0, 1]
158+
if v != v:
159+
return v
160+
grad = (center - v) / cy
161+
if grad > max_slope:
162+
max_slope = grad
163+
direction = 64.0
164+
165+
# NE: arr[0, 2], distance = diag, code = 128
166+
v = arr[0, 2]
167+
if v != v:
168+
return v
169+
grad = (center - v) / diag
170+
if grad > max_slope:
171+
max_slope = grad
172+
direction = 128.0
173+
174+
if max_slope <= 0.0:
175+
return 0.0
176+
177+
return direction
178+
179+
180+
@cuda.jit
181+
def _run_gpu(arr, cellsize_x_arr, cellsize_y_arr, out):
182+
i, j = cuda.grid(2)
183+
di = 1
184+
dj = 1
185+
if (i - di >= 0 and i + di < out.shape[0] and
186+
j - dj >= 0 and j + dj < out.shape[1]):
187+
out[i, j] = _gpu(arr[i - di:i + di + 1, j - dj:j + dj + 1],
188+
cellsize_x_arr,
189+
cellsize_y_arr)
190+
191+
192+
# =====================================================================
193+
# Backend wrappers
194+
# =====================================================================
195+
196+
def _run_numpy(data: np.ndarray,
197+
cellsize_x: Union[int, float],
198+
cellsize_y: Union[int, float],
199+
boundary: str = 'nan') -> np.ndarray:
200+
data = data.astype(np.float64)
201+
if boundary == 'nan':
202+
return _cpu(data, cellsize_x, cellsize_y)
203+
padded = _pad_array(data, 1, boundary)
204+
result = _cpu(padded, cellsize_x, cellsize_y)
205+
return result[1:-1, 1:-1]
206+
207+
208+
def _run_dask_numpy(data: da.Array,
209+
cellsize_x: Union[int, float],
210+
cellsize_y: Union[int, float],
211+
boundary: str = 'nan') -> da.Array:
212+
data = data.astype(np.float64)
213+
_func = partial(_cpu,
214+
cellsize_x=cellsize_x,
215+
cellsize_y=cellsize_y)
216+
217+
out = data.map_overlap(_func,
218+
depth=(1, 1),
219+
boundary=_boundary_to_dask(boundary),
220+
meta=np.array(()))
221+
return out
222+
223+
224+
def _run_cupy(data: cupy.ndarray,
225+
cellsize_x: Union[int, float],
226+
cellsize_y: Union[int, float],
227+
boundary: str = 'nan') -> cupy.ndarray:
228+
if boundary != 'nan':
229+
padded = _pad_array(data, 1, boundary)
230+
result = _run_cupy(padded, cellsize_x, cellsize_y)
231+
return result[1:-1, 1:-1]
232+
233+
cellsize_x_arr = cupy.array([float(cellsize_x)], dtype='f8')
234+
cellsize_y_arr = cupy.array([float(cellsize_y)], dtype='f8')
235+
data = data.astype(cupy.float64)
236+
237+
griddim, blockdim = cuda_args(data.shape)
238+
out = cupy.empty(data.shape, dtype='f8')
239+
out[:] = cupy.nan
240+
241+
_run_gpu[griddim, blockdim](data,
242+
cellsize_x_arr,
243+
cellsize_y_arr,
244+
out)
245+
return out
246+
247+
248+
def _run_dask_cupy(data: da.Array,
249+
cellsize_x: Union[int, float],
250+
cellsize_y: Union[int, float],
251+
boundary: str = 'nan') -> da.Array:
252+
data = data.astype(cupy.float64)
253+
_func = partial(_run_cupy,
254+
cellsize_x=cellsize_x,
255+
cellsize_y=cellsize_y)
256+
257+
out = data.map_overlap(_func,
258+
depth=(1, 1),
259+
boundary=_boundary_to_dask(boundary, is_cupy=True),
260+
meta=cupy.array(()))
261+
return out
262+
263+
264+
# =====================================================================
265+
# Public API
266+
# =====================================================================
267+
268+
@supports_dataset
269+
def flow_direction(agg: xr.DataArray,
270+
name: str = 'flow_direction',
271+
boundary: str = 'nan') -> xr.DataArray:
272+
"""Compute D8 flow direction for each cell.
273+
274+
Determines which of the 8 neighbors has the steepest downhill
275+
gradient from the center cell. Uses the ESRI direction encoding
276+
(power-of-2), compatible with GDAL and ArcGIS::
277+
278+
32 64 128
279+
16 0 1
280+
8 4 2
281+
282+
Parameters
283+
----------
284+
agg : xarray.DataArray or xr.Dataset
285+
2D NumPy, CuPy, NumPy-backed Dask, or CuPy-backed Dask
286+
xarray DataArray of elevation values.
287+
If a Dataset is passed, the operation is applied to each
288+
data variable independently.
289+
name : str, default='flow_direction'
290+
Name of output DataArray.
291+
boundary : str, default='nan'
292+
How to handle edges where the kernel extends beyond the raster.
293+
``'nan'`` - fill missing neighbours with NaN (default).
294+
``'nearest'`` - repeat edge values.
295+
``'reflect'`` - mirror at boundary.
296+
``'wrap'`` - periodic / toroidal.
297+
298+
Returns
299+
-------
300+
xarray.DataArray or xr.Dataset
301+
2D array of D8 flow direction codes. Valid values are
302+
``{0, 1, 2, 4, 8, 16, 32, 64, 128}`` for interior cells
303+
(0 indicates a pit or flat with no downhill neighbor).
304+
Edge cells and cells with NaN in their 3x3 window are NaN.
305+
306+
References
307+
----------
308+
Jenson, S.K. and Domingue, J.O. (1988). Extracting Topographic
309+
Structure from Digital Elevation Data for Geographic Information
310+
System Analysis. Photogrammetric Engineering and Remote Sensing,
311+
54(11), 1593-1600.
312+
"""
313+
_validate_raster(agg, func_name='flow_direction', name='agg')
314+
_validate_boundary(boundary)
315+
316+
cellsize_x, cellsize_y = get_dataarray_resolution(agg)
317+
318+
mapper = ArrayTypeFunctionMapping(
319+
numpy_func=_run_numpy,
320+
cupy_func=_run_cupy,
321+
dask_func=_run_dask_numpy,
322+
dask_cupy_func=_run_dask_cupy,
323+
)
324+
out = mapper(agg)(agg.data, cellsize_x, cellsize_y, boundary)
325+
326+
return xr.DataArray(out,
327+
name=name,
328+
coords=agg.coords,
329+
dims=agg.dims,
330+
attrs=agg.attrs)

0 commit comments

Comments
 (0)