"""Methods to derive topographic and hydrographic parameters from elevation data, in some cases
in combination with flow direction data."""
import heapq
import math
from typing import Literal
import numpy as np
from affine import Affine
from numba import njit
from . import core, core_d8, gis_utils
_mv = core._mv
__all__ = ["fill_depressions", "slope"]
[docs]
@njit(cache=True)
def fill_depressions(
elevtn: np.ndarray,
outlets: Literal["edge", "min"] = "edge",
idxs_pit: np.ndarray | None = None,
nodata: float = -9999.0,
max_depth: float = -1.0,
elv_max: float | None = None,
connectivity: int = 8,
) -> tuple[np.ndarray, np.ndarray]:
"""Fill local depressions in elevation data and derived local
D8 flow directions.
Outlets are assumed to occur at the edge of valid elevation cells `outlets='edge'`;
at the lowest valid edge cell to create one single outlet `outlets='min'`;
or at user provided outlet cells `idxs_pit`.
Depressions elsewhere are filled to their lowest pour-point elevation. If the pour
point depth is greater than or equal to `max_depth`, a pit is set at the depression's
local minimum elevation.
Based on: Wang, L., & Liu, H. (2006). https://doi.org/10.1080/13658810500433453
Parameters
----------
elevtn : 2D array
elevation raster
outlets : {'edge', 'min'}, optional
Initialize outlets at valid edge cells ('edge', default) or use only the
lowest-elevation valid edge cell ('min'). If `idxs_pit` is provided, `outlets`
controls whether all supplied outlets or only the lowest-elevation one are used.
idxs_pit : 1D array of int, optional
Linear indices of user-specified outlet cells. By default, outlets are selected
from the valid raster edge.
nodata : float, optional
No-data value, by default -9999.0.
max_depth : float, optional
Maximum pour point depth. Depressions with a larger pour point
depth are set as pits. A negative value (default) represents an infinitely
large pour point depth causing all depressions to be filled.
elv_max : float, optional
Maximum elevation for outlets, only used with `outlets='edge'`. By default None.
connectivity : {4, 8}, optional
Number of neighboring cells to consider.
Returns
-------
elevtn_out : 2D array
Depression-filled elevation raster.
d8 : 2D array of uint8
D8 flow directions, with no-data cells encoded as 247.
"""
nrow, ncol = elevtn.shape
delv = np.zeros_like(elevtn)
done = np.isnan(elevtn) if np.isnan(nodata) else elevtn == nodata
d8 = np.where(done, np.uint8(247), np.uint8(0))
if connectivity not in [4, 8]:
raise ValueError('"connectivity" should either be 4 or 8')
# pfff.. numba does not allow creation of numpy bool arrays using normal methods
struct = np.array([bool(1) for s in range(9)]).reshape((3, 3))
if connectivity == 4:
struct[0, 0], struct[-1, -1] = False, False
struct[0, -1], struct[-1, 0] = False, False
# initiate queue
if idxs_pit is None: # with edge cells
queued = gis_utils._get_edge(~done, struct)
if elv_max is not None:
queued = np.logical_and(queued, elevtn <= elv_max)
if not np.any(queued):
raise ValueError("No initial outlet cells found.")
else: # with user defined outlet cells
queued = np.array([bool(0) for s in range(elevtn.size)]).reshape((nrow, ncol))
for idx in idxs_pit:
queued.flat[idx] = True
# queue contains (elevation, boundary, row, col)
# boundary is included to favor non-boundary cells over boundary cells with same elevation
q = [
(np.float32(elevtn[0, 0]), np.uint8(1), np.uint32(0), np.uint32(0))
for _ in range(0)
]
heapq.heapify(q)
for r, c in zip(*np.where(queued)):
heapq.heappush(
q, (np.float32(elevtn[r, c]), np.uint8(1), np.uint32(r), np.uint32(c))
)
# restrict queue to the global edge minimum (single outlet)
if outlets == "min":
q = [heapq.heappop(q)]
queued[:, :] = False
queued[q[0][-2], q[0][-1]] = True
# loop over cells and neighbors with ascending cell elevation.
drs, dcs = np.where(struct)
drs, dcs = drs - 1, dcs - 1
while len(q) > 0:
z0, _, r0, c0 = heapq.heappop(q)
for dr, dc in zip(drs, dcs):
r = r0 + dr
c = c0 + dc
if r < 0 or r == nrow or c < 0 or c == ncol or done[r, c]:
continue
z1 = elevtn[r, c]
dz = z0 - z1 # local depression if dz > 0
if max_depth >= 0: # if positive max_depth: don't fill when dz > max_depth
if dz >= max_depth:
heapq.heappush(
q, (np.float32(z1), np.uint8(0), np.uint32(r), np.uint32(c))
)
queued[r, c] = True
for dr, dc in zip(drs, dcs): # (re)visit neighbors
done[r + dr, c + dc] = False
continue
elif delv[r, c] > 0: # reset cell if previously filled & revisited
queued[r, c] = False
delv[r, c] = 0
if dz > 0: # check if local depression (dz>0)
delv[r, c] = dz
z1 += dz
if ~queued[r, c]: # add to queue
heapq.heappush(
q, (np.float32(z1), np.uint8(0), np.uint32(r), np.uint32(c))
)
queued[r, c] = True
done[r, c] = True
d8[r, c] = core_d8._us[dr + 1, dc + 1]
return elevtn + delv, d8
@njit(cache=True)
def adjust_elevation(
idxs_ds: np.ndarray, seq: np.ndarray, elevtn: np.ndarray, mv: int = _mv
) -> np.ndarray:
"""Given a flow direction map, remove pits in the elevation map.
Algorithm based on Yamazaki et al. (2012)
Parameters
----------
idxs_ds : 1D array of int
Linear indices of the next downstream cell.
seq : 1D array of int
Valid cell indices ordered from downstream to upstream.
elevtn : 1D array of float
Flattened elevation raster.
mv : int, optional
Missing-index value, by default the package default.
Returns
-------
1D array of float
Adjusted flattened elevation values.
.. ref: Yamazaki, D., Baugh, C. A., Bates, P. D., Kanae, S., Alsdorf, D. E. and
Oki, T.: Adjustment of a spaceborne DEM for use in floodplain hydrodynamic
modeling, J. Hydrol., 436-437, 81-91, doi:10.1016/j.jhydrol.2012.02.045,
2012.
"""
elevtn_out = elevtn.copy()
mask = np.zeros(idxs_ds.size, dtype=np.bool_)
for idx0 in seq[::-1]: # from up- to downstream starting from longest stream paths
if mask[idx0] == False: # headwater cell
# get downstream indices up to earlier fixed stream path
idxs0 = core._trace(idx0, idxs_ds, mv=mv, mask=mask)[0]
# fix elevation
elevtn1 = _adjust_elevation(elevtn_out[idxs0])
# assert np.all(np.diff(elevtn1) <= 0), elevtn_out[idxs0]
elevtn_out[idxs0] = elevtn1
mask[idxs0] = True # update mask
return elevtn_out
@njit(cache=True)
def _adjust_elevation(elevtn: np.ndarray) -> np.ndarray:
"""fix elevation on single streamline based on minimum modification
elevtn ordered from upstream to downstream
"""
n = elevtn.size
imax, imin = -1, -1
zmax, zmin = elevtn[0], elevtn[0] # local max / min elevation
zi_min1, zi_min2 = zmin, zmin # initialize
# all elevtn should be larger than last value
elevtn = np.maximum(elevtn, elevtn[-1])
for i in range(elevtn.size):
zi = elevtn[i]
if zi >= zmax:
zmax = zi
imax = i
if (zi > zi_min1 and zi_min2 >= zi_min1) or (imin >= 0 and i + 1 == n): # pit
if imin >= 0: # starting from second pit or end of vector
# option 1: dig -> zmod = zmin, for all values larger than zmin, after imin
idxs = np.arange(imin, i, dtype=np.uint32)
zmod = np.minimum(zmin, elevtn[idxs])
cost = np.sum(np.abs(elevtn[idxs] - zmod))
# option 2: fill -> zmod = zmax, for all values smaller than zmax, previous to imax
idxs2 = np.arange(0, imax, dtype=np.uint32)
zmod2 = np.maximum(zmax, elevtn[idxs2])
cost2 = np.sum(np.abs(elevtn[idxs2] - zmod2))
if cost2 < cost:
cost, idxs, zmod = cost2, idxs2, zmod2
# option 3: dig & fill -> try all values between imin and imax
i0, j0, i1, j1 = 0, 0, imax, imax
zs = np.unique(elevtn[imin + 1 : i])[::-1]
for z in zs[1:]: # skip zmax
for j0 in range(i0, imin + 1): # start of zmod
if elevtn[j0] <= z:
break
for j1 in range(i1, i + 1): # end of zmod
if elevtn[j1] <= z:
break
i0, i1 = j0, j1
idxs2 = np.arange(j0, max(imax + 1, j1), dtype=np.uint32)
zmod2 = np.full(idxs2.size, z, dtype=elevtn.dtype)
cost2 = np.sum(np.abs(elevtn[idxs2] - zmod2))
if cost2 < cost:
cost, idxs, zmod = cost2, idxs2, zmod2
# update elevation
elevtn[idxs] = zmod
# update zmin & zmax
imax = i
zmax = elevtn[imax]
imin = max(0, i - 1)
zmin = elevtn[imin]
# update zi values
if zi_min2 != zi_min1:
zi_min2 = zi_min1
zi_min1 = zi
return elevtn
[docs]
@njit(cache=True)
def slope(
elevtn: np.ndarray,
nodata: float = -9999.0,
latlon: bool = False,
transform: np.ndarray = gis_utils._IDENTITY,
) -> np.ndarray:
"""Return the local slope magnitude.
The slope is calculated from the DEM in a 3-by-3-cell window using second-order
partial derivatives. It is the magnitude of the elevation gradient, in metres per
metre.
Parameters
----------
elevtn : 2D array of float
Elevation raster.
nodata : float, optional
No-data value, by default -9999.0.
latlon : bool, optional
True if coordinates use the WGS84 geographic coordinate system, by default False.
transform : np.ndarray, optional
2D array with 6 elements representing the affine transformation for raster,
By default, the identity transform `(1, 0, 0, 0, -1, 0)`.
Returns
-------
2D array of float
Slope magnitude [m/m].
"""
xres, yres, north = transform[0], transform[4], transform[5]
slope = np.zeros(elevtn.shape, dtype=np.float32)
nrow, ncol = elevtn.shape
elev = np.zeros((3, 3), dtype=elevtn.dtype)
for r in range(nrow):
for c in range(ncol):
if elevtn[r, c] != nodata:
# start with matrix based on central value (inside loop)
elev[:, :] = elevtn[r, c]
for dr in range(-1, 2):
row = r + dr
i = dr + 1
if row >= 0 and row < nrow:
for dc in range(-1, 2):
col = c + dc
j = dc + 1
# fill matrix with elevation, except when nodata
if 0 <= col < ncol and elevtn[row, col] != nodata:
elev[i, j] = elevtn[row, col]
dzdx = (
(elev[0, 0] + 2 * elev[1, 0] + elev[2, 0])
- (elev[0, 2] + 2 * elev[1, 2] + elev[2, 2])
) / (8 * abs(xres))
dzdy = (
(elev[0, 0] + 2 * elev[0, 1] + elev[0, 2])
- (elev[2, 0] + 2 * elev[2, 1] + elev[2, 2])
) / (8 * abs(yres))
if latlon:
lat = north + (r + 0.5) * yres
deg_y = gis_utils.degree_metres_y(lat)
deg_x = gis_utils.degree_metres_x(lat)
slp = math.hypot(dzdx / deg_x, dzdy / deg_y)
else:
slp = math.hypot(dzdx, dzdy)
else:
slp = nodata
slope[r, c] = slp
return slope
def height_above_nearest_drain(
idxs_ds: np.ndarray, seq: np.ndarray, drain: np.ndarray, elevtn: np.ndarray
) -> np.ndarray:
"""Returns the height above the nearest drain (HAND), i.e.: the relative vertical
distance (drop) to the nearest downstream river based on drainage-normalized
topography and flowpaths.
Nobre A D et al. (2016) HAND contour: a new proxy predictor of inundation extent
Hydrol. Process. 30 320–33
Parameters
----------
idxs_ds : 1D-array of intp
index of next downstream cell
seq : 1D array of int
ordered cell indices from down- to upstream
drain : 1D array of bool
flattened drainage mask
elevtn : 1D array of float
Flattened elevation raster.
Returns
-------
1D array of float
height above nearest drain
"""
hand = np.full(drain.size, -9999.0, dtype=np.float64)
hand[seq] = 0.0
for idx0 in seq:
if drain[idx0] != 1:
idx_ds = idxs_ds[idx0]
dz = elevtn[idx0] - elevtn[idx_ds]
hand[idx0] = hand[idx_ds] + dz
return hand
def floodplains(
idxs_ds: np.ndarray,
seq: np.ndarray,
elevtn: np.ndarray,
uparea: np.ndarray,
upa_min: float = 1000.0,
b: float = 0.3,
) -> np.ndarray:
"""Identify floodplain cells using an upstream-area-scaled HAND threshold.
Cells with upstream area at least `upa_min` define the drainage network. For each
such cell, the HAND threshold is its upstream area raised to `b`; upstream cells
are included when their elevation above the downstream drainage cell does not
exceed that threshold.
Nardi F et al (2019) GFPLAIN250m, a global high-resolution dataset of Earth's
floodplains Sci. Data 6 180309
Parameters
----------
idxs_ds : 1D-array of intp
index of next downstream cell
seq : 1D array of int
ordered cell indices from down- to upstream
elevtn : 1D array of float
Flattened elevation raster [m].
uparea : 1D array of float
flattened upstream area raster [km2]
upa_min : float, optional
Minimum upstream-area threshold for drainage cells [km2], by default 1000.
b : float
Exponent in the upstream-area scaling relationship, by default 0.3.
Returns
-------
1D array of int8
Floodplain mask: 1 for floodplain cells, 0 for valid non-floodplain cells, and
-1 for no-data cells.
"""
drainh = np.full(uparea.size, -9999.0, dtype=np.float32)
drainz = np.full(uparea.size, -9999.0, dtype=np.float32)
fldpln = np.full(uparea.size, -1, dtype=np.int8)
fldpln[seq] = 0
for idx0 in seq: # down- to upstream
if uparea[idx0] >= upa_min:
drainh[idx0] = uparea[idx0] ** b
drainz[idx0] = elevtn[idx0]
fldpln[idx0] = 1
else:
idx_ds = idxs_ds[idx0]
if fldpln[idx_ds] == 1:
z0 = drainz[idx_ds]
h0 = drainh[idx_ds]
dh = elevtn[idx0] - z0
if dh <= h0:
fldpln[idx0] = 1
drainz[idx0] = z0
drainh[idx0] = h0
return fldpln
@njit(cache=True)
def _local_d4(idx0: int, idx_ds: int, ncol: int) -> np.ndarray:
"""Return D4 neighbors for a diagonal D8 flow direction.
For example, a northwest flow direction returns the north and west neighbors.
"""
idxs_d4 = [
idx0 - ncol,
idx0 - 1,
idx0 + ncol,
idx0 + 1,
idx0 - ncol,
] # n, w, s, e, n
if idx_ds != idx0:
idxs_diag = [
idx0 - ncol - 1,
idx0 + ncol - 1,
idx0 + ncol + 1,
idx0 - ncol + 1,
] # nw, sw, se, ne
di = idxs_diag.index(idx_ds)
return np.asarray(idxs_d4[di : di + 2])
else:
return np.asarray(idxs_d4[1:])
@njit(cache=True)
def dig_4connectivity(
idxs_ds: np.ndarray,
seq: np.ndarray,
elv_flat: np.ndarray,
shape: tuple[int, int],
mask: np.ndarray | None = None,
nodata: float = -9999,
dz_min: float = 1e-3,
) -> np.ndarray:
"""Make sure that for every diagonal D8 downstream flow direction
there is an adjacent D4 cell with same or lower elevation"""
elv_out = elv_flat.copy()
nrow, ncol = shape
for idx0 in seq[::-1]: # up- to downstream
if mask is not None and not mask[idx0]:
continue
idx_ds = idxs_ds[idx0]
dd = abs(idx0 - idx_ds)
if dd > 1 and dd != ncol: # diagonal
idxs_d4 = _local_d4(idx0, idx_ds, ncol) # indices of adjacent d4 cells
z0 = elv_out[idx0] # elevtn of current cell
zs = elv_out[idxs_d4]
valid = zs != nodata
if not np.any(valid):
continue
# find adjacent with smallest dz and lower elevation to <= z0
idx_d4_min = idxs_d4[valid][np.argmin(zs[valid] - z0)]
# force small change to detect d4 river
elv_out[idx_d4_min] = min(elv_out[idx_d4_min] - dz_min, z0)
if idxs_ds[idx_ds] == idx_ds: # next pit because we need to know upstream cell
r = idx_ds // ncol
c = idx_ds % ncol
if r == 0 or r == nrow - 1 or c == 0 or c == ncol - 1: # edge
continue
idxs_d4 = _local_d4(idx_ds, idx_ds, ncol)
if np.any(elv_out[idxs_d4] == nodata): # D4 link with nodata
continue
idxs_d4 = np.asarray([idx for idx in idxs_d4 if idx != idx0])
elv_out[idxs_d4] = np.minimum(elv_out[idx_ds], elv_out[idxs_d4])
return elv_out