Source code for pyflwdir.gis_utils

"""Utilities for geospatial data and raster grids."""

import heapq
import math
from typing import Literal

import numpy as np
from affine import Affine
from numba import njit

_R = 6371e3  # Radius of earth in m. Use 3956e3 for miles
AREA_FACTORS = {"m2": 1.0, "ha": 1e4, "km2": 1e6, "cell": 1}
# changed to N->S orientation in v0.5 TODO check if used in hydromt?
_IDENTITY: np.ndarray = np.array([1.0, 0.0, 0.0, 0.0, -1.0, 0.0])
IDENTITY = Affine(*_IDENTITY)  # Affine transformation for identity

__all__ = [
    "affine_to_coords",
    "array_bounds",
    "get_edge",
    "idxs_to_coords",
    "reggrid_area",
    "reggrid_dx",
    "reggrid_dy",
    "rowcol",
    "spread2d",
    "transform_from_bounds",
    "transform_from_origin",
    "xy",
]


[docs] @njit(cache=True) def spread2d( obs: np.ndarray, msk: np.ndarray | None = None, nodata: float = 0, frc: np.ndarray | None = None, latlon: bool = False, transform: np.ndarray = _IDENTITY, ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """Fill no-data cells with the nearest observation and return source and distance maps. Distances are accumulated through valid cells. The default friction is 1 per cell; diagonal steps use the hypotenuse of the horizontal and vertical distances. Parameters ---------- obs : 2D array Input observations. Cells equal to `nodata` are candidates for filling. msk : 2D array of bool, optional Valid-cell mask. Observations and fill paths are restricted to True cells. nodata : int or float, optional Missing-data value in `obs`, by default 0. frc : 2D array of float, optional Per-cell friction multiplier for distance accumulation, by default 1 everywhere. latlon : bool, optional Whether coordinates are geographic. If True, transform units are interpreted as degrees and distances are converted to metres, by default False. transform : np.ndarray, optional Six affine transform coefficients mapping pixel coordinates to map coordinates. Returns ------- out: 2D array of obs.dtype Copy of `obs` with fillable no-data cells assigned the nearest observation. src: 2D array of int32 Linear index of the nearest observation, or -1 where no observation is reachable. dst: 2D array of float32 Accumulated friction distance to the nearest observation. """ nrow, ncol = obs.shape xres, yres, north = transform[0], abs(transform[4]), transform[5] if latlon: lats = north + (np.arange(nrow) + 0.5) * yres dys = degree_metres_y(lats) * yres dxs = degree_metres_x(lats) * xres else: dx, dy = xres, yres # output out = obs.copy() src = np.full(obs.shape, -1, dtype=np.int32) # linear index of source dst = np.full(obs.shape, 0, dtype=np.float32) # distance from source # initiate queue with correct dtype # heapq is faster when fifo loop not in order of ascending distance from source; # otherwise a fixed length numpy array queue is up to ~2x faster q = [(np.float32(0), np.uint32(0), np.uint32(0)) for _ in range(0)] heapq.heapify(q) for r in range(nrow): for c in range(ncol): if obs[r, c] != nodata: if msk is None or msk[r, c]: heapq.heappush(q, (np.float32(0), np.uint32(r), np.uint32(c))) src[r, c] = r * ncol + c obs = obs.ravel() while len(q) > 0: d0, r, c = heapq.heappop(q) # type: ignore[assignment] if dst[r, c] < d0: continue f0 = 1.0 if frc is None else frc[r, c] if latlon: dx, dy = dxs[r], dys[r] for dr in range(-1, 2): for dc in range(-1, 2): if dr == 0 and dc == 0: continue r1, c1 = int(r) + dr, int(c) + dc outside = r1 < 0 or r1 >= nrow or c1 < 0 or c1 >= ncol if outside or (msk is not None and not msk[r1, c1]): continue d = d0 + np.hypot(dr * dy, dc * dx) * f0 if src[r1, c1] == -1 or d < dst[r1, c1]: idx0 = src[r, c] src[r1, c1] = idx0 dst[r1, c1] = d out[r1, c1] = obs[idx0] heapq.heappush(q, (np.float32(d), np.uint32(r1), np.uint32(c1))) return out, src, dst
[docs] def get_edge(a: np.ndarray, structure: np.ndarray | None = None) -> np.ndarray: """Get edge of valid cells. Parameters ---------- a: 2D array of bool Boolean array valid cells. structure: 2D array with shape (3,3) of bool, optional Structuring element used to define which cells are neighbors. If None, a 3x3 square is used. The center cell is ignored. Returns ------- edge: 2D array of bool Boolean array edge cells. """ if structure is None: struct = np.ones((3, 3), dtype=np.bool_) elif ( not isinstance(structure, np.ndarray) or structure.shape != (3, 3) or structure.dtype != bool ): raise ValueError("structure must be a 3x3 boolean array") else: struct = structure return _get_edge(a, struct)
@njit(cache=True) def _get_edge(a: np.ndarray, struct: np.ndarray) -> np.ndarray: s = np.where(struct.ravel())[0] edge = a.copy() nrow, ncol = a.shape for r in range(nrow): for c in range(ncol): if not a[r, c] or r == 0 or r == nrow - 1 or c == 0 or c == ncol - 1: continue a0 = a[slice(r - 1, r + 2), slice(c - 1, c + 2)].ravel() if np.all(a0[s]): edge[r, c] = False return edge ## TRANSFORM # Adapted from https://github.com/rasterio/rasterio/blob/main/rasterio/transform.py # changed xy and rowcol to work directly on numpy arrays # avoid gdal dependency def transform_from_origin( west: float, north: float, xsize: float, ysize: float ) -> Affine: """Return an affine transform from the upper-left corner and pixel sizes. Parameters ---------- west, north : float Coordinates of the upper-left corner. xsize, ysize : float Pixel width and height in coordinate units. Returns ------- Affine Transform mapping pixel coordinates to map coordinates. """ return Affine(xsize, 0.0, west, 0.0, -ysize, north) def transform_from_bounds( west: float, south: float, east: float, north: float, width: int, height: int, ) -> Affine: """Return an affine transform from raster bounds and dimensions. Parameters ---------- west, south, east, north : float Raster bounds in coordinate units. width, height : int Raster dimensions in pixels. Returns ------- Affine Transform mapping pixel coordinates to map coordinates. """ return Affine( (east - west) / width, 0.0, west, 0.0, (south - north) / height, north, ) def array_bounds( height: int, width: int, transform: Affine ) -> tuple[float, float, float, float]: """Return the west, south, east, and north bounds of an array. Parameters ---------- height, width : int Array dimensions in pixels. transform : Affine Transform mapping pixel coordinates to map coordinates. Returns ------- tuple of float Bounds in `(west, south, east, north)` order. """ w, n = transform.xoff, transform.yoff e = transform.a * width + transform.b * height + transform.c s = transform.d * width + transform.e * height + transform.f return w, s, e, n def xy( transform: Affine, rows: np.ndarray | int, cols: np.ndarray | int, offset: Literal["center", "ul", "ur", "ll", "lr"] = "center", ) -> tuple[np.ndarray, np.ndarray]: """Return the x and y coordinates of pixels at `rows` and `cols`. The pixel's center is returned by default, but a corner can be returned by setting `offset` to one of `ul, ur, ll, lr`. Parameters ---------- transform : Affine Transform mapping pixel coordinates to map coordinates. rows : ndarray or int Pixel rows. cols : ndarray or int Pixel columns. offset : {'center', 'ul', 'ur', 'll', 'lr'} Determines if the returned coordinates are for the center of the pixel or for a corner. Returns ------- xs : ndarray of float x coordinates in coordinate reference system ys : ndarray of float y coordinates in coordinate reference system """ rows, cols = np.asarray(rows), np.asarray(cols) if offset == "center": coff, roff = (0.5, 0.5) elif offset == "ul": coff, roff = (0, 0) elif offset == "ur": coff, roff = (1, 0) elif offset == "ll": coff, roff = (0, 1) elif offset == "lr": coff, roff = (1, 1) else: raise ValueError("Invalid offset") xs = transform.a * (cols + coff) + transform.b * (rows + roff) + transform.c ys = transform.d * (cols + coff) + transform.e * (rows + roff) + transform.f return xs, ys def rowcol( transform: Affine, xs: np.ndarray | float, ys: np.ndarray | float, op=np.floor, precision: int | None = None, ) -> tuple[np.ndarray, np.ndarray]: """ Returns the rows and cols of the pixels containing (x, y) given a coordinate reference system. Use an epsilon, magnitude determined by the precision parameter and sign determined by the op function: positive for floor, negative for ceil. Parameters ---------- transform: Affine Coefficients mapping pixel coordinates to coordinate reference system. xs : ndarray or float x values in coordinate reference system ys : ndarray or float y values in coordinate reference system op : function {numpy.floor, numpy.ceil, numpy.round} Function to convert fractional pixels to whole numbers precision : int, optional Decimal places of precision in indexing, as in `round()`. Returns ------- rows : ndarray of ints array of row indices cols : ndarray of ints array of column indices """ xs, ys = np.asarray(xs), np.asarray(ys) if precision is None: eps = 0.0 else: eps = 10.0**-precision * (1.0 - 2.0 * op(0.1)) invtransform = ~transform fcols = invtransform.a * (xs + eps) + invtransform.b * (ys - eps) + invtransform.c frows = invtransform.d * (xs + eps) + invtransform.e * (ys - eps) + invtransform.f cols, rows = op(fcols).astype(int), op(frows).astype(int) return rows, cols def idxs_to_coords( idxs: np.ndarray, transform: Affine, shape: tuple[int, int], offset: Literal["center", "ul", "ur", "ll", "lr"] = "center", ) -> tuple[np.ndarray, np.ndarray]: """Return map coordinates for linear raster indices. Parameters ---------- idxs : ndarray of int linear indices transform : Affine Transform mapping pixel coordinates to map coordinates. shape : tuple of int Raster dimensions as `(height, width)`. offset : {'center', 'ul', 'ur', 'll', 'lr'} Determines if the returned coordinates are for the center of the pixel or for a corner. Returns ------- xs : ndarray of float x coordinates in coordinate reference system ys : ndarray of float y coordinates in coordinate reference system Raises ------ IndexError if any linear index outside domain. """ idxs = np.asarray(idxs).astype(int) size = np.multiply(*shape) if np.any(np.logical_or(idxs < 0, idxs >= size)): raise IndexError("idxs coordinates outside domain") ncol = shape[1] rows = idxs // ncol cols = idxs % ncol return xy(transform, rows, cols, offset=offset) def coords_to_idxs( xs: np.ndarray | float, ys: np.ndarray | float, transform: Affine, shape: tuple[int, int], op=np.floor, precision: int | None = None, ) -> np.ndarray: """Return linear raster indices for map coordinates. Parameters ---------- xs : ndarray or float x values in coordinate reference system ys : ndarray or float y values in coordinate reference system transform : Affine Transform mapping pixel coordinates to map coordinates. shape : tuple of int Raster dimensions as `(height, width)`. op : function {numpy.floor, numpy.ceil, numpy.round} Function to convert fractional pixels to whole numbers precision : int, optional Decimal places of precision in indexing, as in `round()`. Returns ------- idxs : ndarray of ints array of linear indices Raises ------ IndexError if any coordinate outside domain. """ nrow, ncol = shape rows, cols = rowcol(transform, xs, ys, op=op, precision=precision) if not np.all( np.logical_and( np.logical_and(rows >= 0, rows < nrow), np.logical_and(cols >= 0, cols < ncol), ) ): raise IndexError("XY coordinates outside domain") return rows * ncol + cols # TODO: rename to transform_to_coords & correct upstream use in pyflwdir and hydromt def affine_to_coords( affine: Affine, shape: tuple[int, int] ) -> tuple[np.ndarray, np.ndarray]: """Return the x and y pixel-center coordinate arrays for an affine transform. Parameters ---------- affine : Affine Transform mapping pixel coordinates to map coordinates. shape : tuple of int Raster dimensions as `(height, width)`. Returns ------- tuple of 1D arrays The x coordinates for columns and y coordinates for rows. Returns ------- x, y coordinate arrays : tuple of ndarray of float """ height, width = shape x_coords = affine.a * (np.arange(width) + 0.5) + affine.b * 0.5 + affine.c y_coords = affine.d * 0.5 + affine.e * (np.arange(height) + 0.5) + affine.f return x_coords, y_coords ## DISTANCES // AREAS def reggrid_dx(lats: np.ndarray, lons: np.ndarray) -> np.ndarray: """Return cell widths [m] for a regular geographic grid. Parameters ---------- lats, lons : 1D arrays of float Latitude and longitude coordinates at cell centers, in degrees. Returns ------- 2D array of float Cell widths, with shape `(len(lats), len(lons))`. """ xres = np.abs(np.mean(np.diff(lons))) dx = degree_metres_x(lats) * xres return dx[:, None] * np.ones((lats.size, lons.size), dtype=lats.dtype) def reggrid_dy(lats: np.ndarray, lons: np.ndarray) -> np.ndarray: """Return cell heights [m] for a regular geographic grid. Parameters ---------- lats, lons : 1D arrays of float Latitude and longitude coordinates at cell centers, in degrees. Returns ------- 2D array of float Cell heights, with shape `(len(lats), len(lons))`. """ yres = np.abs(np.mean(np.diff(lats))) dy = degree_metres_y(lats) * yres return dy[:, None] * np.ones((lats.size, lons.size), dtype=lats.dtype)
[docs] def reggrid_area(lats: np.ndarray, lons: np.ndarray) -> np.ndarray: """Return cell areas [m2] for a regular geographic grid. Parameters ---------- lats, lons : 1D arrays of float Latitude and longitude coordinates at cell centers, in degrees. Returns ------- 2D array of float Cell areas, with shape `(len(lats), len(lons))`. """ xres = np.abs(np.mean(np.diff(lons))) yres = np.abs(np.mean(np.diff(lats))) area = np.ones((lats.size, lons.size), dtype=np.float32) return cellarea(lats, xres, yres)[:, None] * area
def area_grid( transform: Affine, shape: tuple[int, int], latlon: bool = False, unit: str = "m2", ) -> np.ndarray: """Return a raster of cell areas. Parameters ---------- transform : Affine Affine transform for the raster. shape : tuple of int Raster dimensions `(height, width)`. latlon : bool, optional Whether coordinates are geographic, by default False. unit : {'m2', 'ha', 'km2', 'cell'}, optional Area units, by default 'm2'. Returns ------- 2D array Cell areas in `unit`. """ unit = str(unit).lower() if unit not in AREA_FACTORS: fstr = '", "'.join(AREA_FACTORS.keys()) raise ValueError(f'Unknown unit: {unit}, select from "{fstr}".') area: np.ndarray if unit == "cell": area = np.ones(shape, dtype=np.int32) elif latlon: lon, lat = affine_to_coords(transform, shape) area = reggrid_area(lat, lon) / AREA_FACTORS[unit] elif not latlon: area0 = abs(transform[0] * transform[4]) / AREA_FACTORS[unit] area = np.full(shape, area0, dtype=np.float32) return area @njit(cache=True) def cellarea(lat: np.ndarray | float, xres: float, yres: float) -> np.ndarray | float: """Return the area [m2] of a cell at a given center latitude. Parameters ---------- lat : float or array-like Cell-center latitude in degrees. xres, yres : float Cell width and height in degrees. Returns ------- float or array-like Cell area in square metres. """ l1 = np.radians(lat - np.abs(yres) / 2.0) l2 = np.radians(lat + np.abs(yres) / 2.0) dx = np.radians(np.abs(xres)) return _R**2 * dx * (np.sin(l2) - np.sin(l1)) @njit(cache=True) def degree_metres_y(lat: np.ndarray | float) -> np.ndarray | float: """Return the north-south length of one degree [m] at a given latitude. Parameters ---------- lat : float or array-like Latitude in degrees. Returns ------- float or array-like Length of one degree of latitude in metres. """ m1 = 111132.92 # latitude calculation term 1 m2 = -559.82 # latitude calculation term 2 m3 = 1.175 # latitude calculation term 3 m4 = -0.0023 # latitude calculation term 4 # # Calculate the length of a degree of latitude and longitude in meters radlat = np.radians(lat) latlen = ( m1 + (m2 * np.cos(2.0 * radlat)) + (m3 * np.cos(4.0 * radlat)) + (m4 * np.cos(6.0 * radlat)) ) return latlen @njit(cache=True) def degree_metres_x(lat: np.ndarray | float) -> np.ndarray | float: """Return the east-west length of one degree [m] at a given latitude. Parameters ---------- lat : float or array-like Latitude in degrees. Returns ------- float or array-like Length of one degree of longitude in metres. """ p1 = 111412.84 # longitude calculation term 1 p2 = -93.5 # longitude calculation term 2 p3 = 0.118 # longitude calculation term 3 # # Calculate the length of a degree of latitude and longitude in meters radlat = np.radians(lat) longlen = ( (p1 * np.cos(radlat)) + (p2 * np.cos(3.0 * radlat)) + (p3 * np.cos(5.0 * radlat)) ) return longlen @njit(cache=True) def distance( idx0: int, idx1: int, ncol: int, latlon: bool = False, transform: np.ndarray = _IDENTITY, ) -> float: """Return the distance between two linear raster indices. The raster is assumed to have regular spacing defined by `transform`. Parameters ---------- idx0, idx1 : int index of start, end cell ncol : int number of columns in raster latlon : bool, optional True for geographic CRS, False for projected CRS. If True, the transform units are assumed to be degrees and converted to metric distances. transform : Affine, optional Transform mapping pixel coordinates to map coordinates. Returns ------- float Distance in map units, or metres for geographic coordinates. """ xres, yres, north = transform[0], transform[4], transform[5] # compute delta row, col r0 = int(idx0 // ncol) r1 = int(idx1 // ncol) dr = abs(r1 - r0) dc = abs(int(idx1 % ncol) - int(idx0 % ncol)) if latlon: # calculate cell size in metres lat = north + (r0 + r1) / 2.0 * yres dy = 0.0 if dr == 0 else degree_metres_y(lat) * yres dx = 0.0 if dc == 0 else degree_metres_x(lat) * xres else: dy = xres dx = yres return math.hypot(dy * dr, dx * dc) # length ## VECTORIZE def features( flowpaths: list[np.ndarray], xs: np.ndarray | None = None, ys: np.ndarray | None = None, transform: Affine | None = None, shape: tuple[int, int] | None = None, **kwargs, ) -> list[dict]: """Return one LineString feature for each flow path. Parameters ---------- flowpaths : list of 1D-arrays of intp linear indices of flowpaths xs, ys : 1D-array of float x, y coordinates transform : Affine, optional Transform mapping pixel coordinates to map coordinates. Required when `xs` or `ys` is omitted. shape : tuple of int The height, width of the raster. **kwargs : 2D array-like Additional maps sampled at each flow path's most downstream cell and included as feature properties, for example `strord=flw.stream_order()`. Returns ------- feats : list of dict Geofeatures, to be parsed by e.g. geopandas.GeoDataFrame.from_features """ if xs is None or ys is None: if transform is None or shape is None: raise ValueError( "transform and shape should be provided if xs and ys are None" ) _size = shape[0] * shape[1] else: _size = xs.size for key, value in kwargs.items(): if not isinstance(value, np.ndarray) or value.size != _size: raise ValueError( f'Kwargs map "{key}" should be ndarrays of same size as coordinates' ) feats = [] for j, idxs in enumerate(flowpaths): n = len(idxs) if n < 2: continue idx0 = idxs[0] pit = idxs[-1] == idxs[-2] props = {key: kwargs[key].flat[idx0] for key in kwargs} if xs is None or ys is None: xi, yi = idxs_to_coords(idxs, transform, shape) # type: ignore[arg-type] coordinates = list(zip(xi, yi)) else: coordinates = [(xs[i], ys[i]) for i in idxs] feats.append( { "type": "Feature", "geometry": { "type": "LineString", "coordinates": coordinates, }, "properties": {"idx": idx0, "idx_ds": idxs[-1], "pit": pit, **props}, } ) return feats