Source code for hydromt_wflow.workflows.nbs

"""Nature-Based Solutions (NBS) workflows for Wflow plugin."""

import logging

import numpy as np
import pyflwdir
import xarray as xr
from hydromt.gis import flw
from hydromt.gis.raster_utils import full_like

logger = logging.getLogger(f"hydromt.{__name__}")


__all__ = [
    "nbs_suitability_from_thresholds",
    "ponding_level_from_suitability",
]


def _compute_landslope(elevtn: xr.DataArray) -> xr.DataArray:
    """Compute land slope from elevation data."""
    crs = elevtn.raster.crs
    nodata = elevtn.raster.nodata
    slope = xr.DataArray(
        name="lndslp",
        data=pyflwdir.dem.slope(
            elevtn=elevtn.values,
            nodata=nodata,
            latlon=crs is not None and crs.to_epsg() == 4326,
            transform=elevtn.raster.transform,
        ),
        coords=elevtn.raster.coords,
        dims=elevtn.raster.dims,
        attrs={
            "long_name": "land slope",
            "unit": "m.m-1",
            "_FillValue": nodata,
        },
    )
    # Mask possible negative values for slope
    slope = slope.where(slope >= 0, 0)

    return slope


def _compute_hand(
    hydro_data: xr.Dataset,
    river_upa: float = 30,
) -> xr.DataArray:
    """Compute hand from elevation and flow direction data."""
    # Get flwdir
    if "flwdir" not in hydro_data:
        raise ValueError("Flow direction data ('flwdir') is required to compute hand.")
    flwdir = flw.flwdir_from_da(hydro_data["flwdir"], ftype="infer")

    # Get drain mask based on river_upa
    if "uparea" not in hydro_data:
        uparea = flwdir.upstream_area("km2")
        attrs = {"_FillValue": -9999, "unit": "km2"}
        uparea = xr.DataArray(
            name="uparea",
            data=uparea,
            coords=hydro_data.coords,
            dims=hydro_data.raster.dims,
            attrs=attrs,
        )
    else:
        uparea = hydro_data["uparea"]

    # Compute hand
    hand = flwdir.hand(
        drain=uparea.values > river_upa,
        elevtn=hydro_data["elevtn"].values,
    )
    attrs = {
        "_FillValue": -9999,
        "long_name": "height above nearest drainage",
        "units": "m",
    }

    hand = xr.DataArray(
        name="hand",
        data=hand,
        coords=hydro_data.coords,
        dims=hydro_data.raster.dims,
        attrs=attrs,
    )

    return hand


def _compute_nbs_coverage(
    nbs_map: xr.DataArray,
    basin: xr.DataArray,
    min_value: float = 0.0,
):
    """
    Compute NBS coverage of the basin.

    Parameters
    ----------
    nbs_map : xr.DataArray
        Map with NBS suitability or measures (e.g. ponding level).
    basin : xr.DataArray
        Map of basin areas (non-NaN values indicate basin area).
    min_value : float, optional
        Minimum value to consider as NBS coverage, by default 0.0.
    """
    # Reproject basin to nbs_map grid if needed
    basin_mask = basin.raster.reproject_like(nbs_map, method="nearest")

    basin_mask = basin_mask.where(basin_mask != basin_mask.raster.nodata, -9999)
    basin_mask.raster.set_nodata(-9999)

    area = basin_mask.raster.area_grid()
    valid_basin = basin_mask != basin_mask.raster.nodata
    area_basin = area.where(valid_basin, np.nan).sum().values / 1e6
    area_nbs = round(
        area.where(valid_basin & (nbs_map > min_value), np.nan).sum().values / 1e6
    )
    if area_basin == 0:
        raise ValueError(
            "Basin mask contains no valid pixels; cannot compute NBS coverage."
        )
    area_nbs_perc = round(area_nbs / area_basin * 100, 2)
    logger.info(
        f"NBS coverage: {round(area_nbs, 2)} km2 ({area_nbs_perc} % of basin area)"
    )


def _landuse_suitability(
    landuse: xr.DataArray, lulc_classes: list[int]
) -> xr.DataArray:
    """Derive land use suitability based on specified land use classes."""
    # need -1 as nodata and not 0 for mode resampling later on
    suitability = full_like(landuse, nodata=-1, fill_value=0, dtype=np.int8)
    for lulc_class in lulc_classes:
        suitability = suitability.where(landuse != lulc_class, 1)
    return suitability


def _hydrography_suitability(
    hydro_data: xr.Dataset,
    elevtn_range: tuple[float, float] | None = None,
    slope_range: tuple[float, float] | None = None,
    hand_range: tuple[float, float] | None = None,
    river_upa: float = 30,
) -> xr.DataArray:
    """Derive hydrography suitability based on elevation, slope, and hand criteria."""
    suitability_hydro = full_like(
        hydro_data["elevtn"], nodata=-1, fill_value=1, dtype=np.int8
    )
    if elevtn_range is not None:
        suitability_hydro = xr.where(
            (
                (hydro_data["elevtn"] >= elevtn_range[0])
                & (hydro_data["elevtn"] <= elevtn_range[1])
                & (suitability_hydro == 1)
            ),
            1,
            0,
        )
    if slope_range is not None:
        if "lndslp" not in hydro_data:
            logger.info("Slope data ('lndslp') not found. Derive from elevation map.")
            hydro_data["lndslp"] = _compute_landslope(hydro_data["elevtn"])

        suitability_hydro = xr.where(
            (
                (hydro_data["lndslp"] >= slope_range[0])
                & (hydro_data["lndslp"] <= slope_range[1])
                & (suitability_hydro == 1)
            ),
            1,
            0,
        )
    if hand_range is not None:
        if "hand" not in hydro_data:
            logger.info(
                "Hand data ('hand') not found. "
                "Derive from elevation and flow direction maps."
            )
            hydro_data["hand"] = _compute_hand(hydro_data, river_upa=river_upa)
        suitability_hydro = xr.where(
            (
                (hydro_data["hand"] >= hand_range[0])
                & (hydro_data["hand"] <= hand_range[1])
                & (suitability_hydro == 1)
            ),
            1,
            0,
        )
    suitability_hydro.raster.set_crs(hydro_data.raster.crs)
    suitability_hydro.raster.set_nodata(-1)
    suitability_hydro = suitability_hydro.astype(np.int8)

    return suitability_hydro


[docs] def nbs_suitability_from_thresholds( landuse: xr.DataArray | None = None, hydro_data: xr.Dataset | None = None, lulc_classes: list[int] | None = None, elevtn_range: tuple[float, float] | None = None, slope_range: tuple[float, float] | None = None, hand_range: tuple[float, float] | None = None, river_upa: float = 30, basin_mask: xr.DataArray | None = None, ) -> xr.DataArray: """Derive NBS suitability based on threshold criteria. Parameters ---------- landuse : xr.DataArray, optional Land use/cover map with a "landuse" variable, by default None. hydro_data : xr.Dataset, optional Dataset containing hydrography data arrays ('elevtn', 'lndslp', 'hand'), by default None. Requires `flwdir` and optionally `uparea` for hand calculation. lulc_classes : list[int], optional List of land use/cover classes to consider for NBS suitability, by default None. elevtn_range : tuple[float, float], optional Elevation range for NBS suitability, by default None. slope_range : tuple[float, float], optional Slope range for NBS suitability, by default None. hand_range : tuple[float, float], optional Height above nearest drainage range for NBS suitability, by default None. river_upa : float, optional Minimum upstream area threshold for the river map [km2]. By default 30. Used to define drains for hand calculation. Returns ------- xr.DataArray Boolean array indicating NBS suitability based on the specified criteria. """ # Start with masking landuse if landuse is not None and lulc_classes is not None: suitability_lulc = _landuse_suitability(landuse, lulc_classes) else: suitability_lulc = None # Now elevation related criteria if any([elevtn_range, slope_range, hand_range]) and hydro_data is not None: suitability_hydro = _hydrography_suitability( hydro_data, elevtn_range=elevtn_range, slope_range=slope_range, hand_range=hand_range, river_upa=river_upa, ) else: suitability_hydro = None # Combine suitability criteria if suitability_lulc is not None and suitability_hydro is not None: # Reproject landuse suitability to hydro data grid if needed suitability_lulc = suitability_lulc.raster.reproject_like( suitability_hydro, method="mode" ) suitability = ((suitability_lulc == 1) & (suitability_hydro == 1)).astype( np.int8 ) elif suitability_lulc is not None: suitability = suitability_lulc elif suitability_hydro is not None: suitability = suitability_hydro else: raise ValueError( "At least one of landuse or hydro_data with criteria must be provided." ) suitability.name = "nbs_suitability" suitability.attrs = { "long_name": ( f"NBS suitability based on threshold criteria: " f"elevation range {elevtn_range}, " f"slope range {slope_range}, " f"HAND range {hand_range}, " f"land use classes {lulc_classes}." ) } if hydro_data is not None: suitability.raster.set_crs(hydro_data.raster.crs) elif landuse is not None: suitability.raster.set_crs(landuse.raster.crs) suitability.raster.set_nodata(0) if basin_mask is not None: logger.info("Calculating NBS suitability coverage at high resolution.") _compute_nbs_coverage(suitability, basin_mask, min_value=0) return suitability
[docs] def ponding_level_from_suitability( suitability: xr.DataArray, ds_like: xr.Dataset, pond_level: float = 0.2, basin_mask_name: str = "basins", ) -> xr.DataArray: """Assign ponding level based on NBS suitability map. Parameters ---------- suitability : xr.DataArray Boolean array indicating NBS suitability. ds_like : xr.Dataset Wflow staticmaps for reprojection. pond_level : float, optional Ponding level to assign to suitable areas, by default 0.2 Returns ------- xr.DataArray Array with assigned ponding levels based on suitability. """ # Create ponding level map based on suitability ponding_level = xr.where(suitability == 1, pond_level, 0.0) ponding_level.name = "ponding_level" ponding_level.attrs["long_name"] = "ponding level" if "long_name" in suitability.attrs: ponding_level.attrs["description"] = suitability.attrs["long_name"] src_crs = suitability.raster.crs or ds_like.raster.crs ponding_level.raster.set_crs(src_crs) ponding_level.raster.set_nodata(-9999) # Reproject to match Wflow staticmaps grid ponding_level = ponding_level.raster.reproject_like(ds_like, method="average") # Ponding coverage # calculate extent of the area where changes occur if basin_mask_name in ds_like: logger.info("Calculating ponding coverage at model resolution.") basin_mask = ds_like[basin_mask_name].copy() _compute_nbs_coverage(ponding_level, basin_mask, min_value=0.0) return ponding_level