Source code for hydromt_sfincs.plots

"""Plotting functions for SFINCS model data."""
from typing import Dict, List, Tuple

import numpy as np
import pandas as pd
import xarray as xr

from .utils import get_bounds_vector

__all__ = ["plot_forcing", "plot_basemap"]

geom_style = {
    "rivers": dict(linestyle="-", linewidth=1.0, color="darkblue"),
    "rivers_inflow": dict(linestyle=":", linewidth=1.0, color="darkblue"),
    "rivers_outflow": dict(linestyle=":", linewidth=1.0, color="darkgreen"),
    "msk2": dict(linestyle="-", linewidth=1.5, color="r"),
    "msk3": dict(linestyle="-", linewidth=1.5, color="m"),
    "thd": dict(linestyle="-", linewidth=1.0, color="k", annotate=False),
    "weir": dict(linestyle="--", linewidth=1.0, color="k", annotate=False),
    "bnd": dict(marker="^", markersize=75, c="w", edgecolor="k", annotate=True),
    "src": dict(marker=">", markersize=75, c="w", edgecolor="k", annotate=True),
    "obs": dict(marker="d", markersize=75, c="w", edgecolor="r", annotate=True),
    "crs": dict(linestyle="-", linewidth=1.5, color="deeppink", annotate=False),
    "region": dict(ls="--", linewidth=1, color="r"),
}


[docs] def plot_forcing(forcing: Dict, **kwargs): """Plot model timeseries forcing. For distributed forcing a spatial avarage is plotted. Parameters ---------- forcing : Dict of xr.DataArray Model forcing Returns ------- fig, axes Model fig and ax objects """ import matplotlib.dates as mdates import matplotlib.pyplot as plt n = len(forcing.keys()) kwargs0 = dict(sharex=True, figsize=(6, n * 3)) kwargs0.update(**kwargs) fig, axes = plt.subplots(n, 1, **kwargs0) axes = [axes] if n == 1 else axes for i, name in enumerate(forcing): da = forcing[name].transpose("time", ...) longname = da.attrs.get("standard_name", "") unit = da.attrs.get("unit", "") prefix = "" if da.ndim == 3: if name.startswith("press"): da = da.min(dim=[da.raster.x_dim, da.raster.y_dim]) prefix = "min " elif name.startswith("wind_u") or name.startswith("wind_v"): da = da.max(dim=[da.raster.x_dim, da.raster.y_dim]) prefix = "max " else: da = da.mean(dim=[da.raster.x_dim, da.raster.y_dim]) prefix = "mean " # convert to Single index dataframe (bar plots don't work with xarray) df = da.to_pandas() if isinstance(df.index, pd.MultiIndex): df = df.unstack(0) # convert dates a-priori as automatic conversion doesn't always work df.index = mdates.date2num(df.index) if name.startswith("precip"): df.plot(drawstyle="steps", ax=axes[i]) elif ( name.startswith("press") or name.startswith("wind_u") or name.startswith("wind_v") ): df.plot.line(ax=axes[i]) elif name.startswith("wnd"): df.plot(ax=axes[i], kind="line", secondary_y="dir", legend=False) # set tick color for y-axis of variable 1 axes[i].tick_params(axis="y", labelcolor="C0") axes[i].right_ax.set_ylabel("Wind direction [degrees]") # set tick color and label for y-axis of variable 2 axes[i].right_ax.tick_params(axis="y", labelcolor="C1") else: df.plot.line(ax=axes[i]).legend( title="index", bbox_to_anchor=(1.05, 1), loc="upper left", ncol=df.columns.size // 5 + 1, prop={"size": 8}, ) axes[i].set_ylabel(f"{prefix}{longname}\n[{unit}]") axes[i].set_title(f"SFINCS {longname} forcing ({name})") # use a concise date formatter for format date axis ticks locator = mdates.AutoDateLocator() formatter = mdates.ConciseDateFormatter(locator) axes[-1].xaxis.set_major_locator(locator) axes[-1].xaxis.set_major_formatter(formatter) return fig, axes
[docs] def plot_basemap( ds: xr.Dataset, geoms: Dict, variable: str = "dep", shaded: bool = False, plot_bounds: bool = True, plot_region: bool = False, plot_geoms: bool = True, bmap: str = None, zoomlevel: int = "auto", figsize: Tuple[int] = None, geom_names: List[str] = None, geom_kwargs: Dict = {}, legend_kwargs: Dict = {}, bmap_kwargs: Dict = {}, **kwargs, ): """Create basemap plot. Parameters ---------- ds : xr.Dataset Dataset with model maps geoms : Dict of geopandas.GeoDataFrame Model geometries variable : str, optional Map of variable in ds to plot, by default 'dep' shaded : bool, optional Add shade to variable (only for variable = 'dep'), by default False plot_bounds : bool, optional Add waterlevel (msk=2) and open (msk=3) boundary conditions to plot. plot_region : bool, optional If True, plot region outline. plot_geoms : bool, optional If True, plot available geoms. bmap : str, optional background map souce name, by default None Default image tiles "sat", and "osm" are fetched from cartopy image tiles. If contextily is installed, xyzproviders tiles can be used as well. zoomlevel : int, optional zoomlevel, by default 'auto' figsize : Tuple[int], optional figure size, by default None geom_names : List[str], optional list of model geometries to plot, by default all model geometries geom_kwargs : Dict of Dict, optional Model geometry styling per geometry, passed to geopandas.GeoDataFrame.plot method. For instance: {'src': {'markersize': 30}}. legend_kwargs : Dict, optional Legend kwargs, passed to ax.legend method. Returns ------- fig, axes Model fig and ax objects """ import cartopy.crs as ccrs import cartopy.io.img_tiles as cimgt import matplotlib.pyplot as plt from matplotlib import colors, patheffects try: import contextily as cx has_cx = True except ImportError: has_cx = False # read crs and utm zone > convert to cartopy proj_crs = ds.raster.crs proj_str = proj_crs.name if proj_crs.is_projected and proj_crs.to_epsg() is not None: crs = ccrs.epsg(ds.raster.crs.to_epsg()) unit = proj_crs.axis_info[0].unit_name unit = "m" if unit == "metre" else unit xlab, ylab = f"x [{unit}] - {proj_str}", f"y [{unit}]" elif proj_crs.is_geographic: crs = ccrs.PlateCarree() xlab, ylab = f"lon [deg] - {proj_str}", "lat [deg]" else: raise ValueError("Unsupported CRS") bounds = ds.raster.box.buffer(abs(ds.raster.res[0] * 1)).total_bounds extent = np.array(bounds)[[0, 2, 1, 3]] # create fig with geo-axis and set background if figsize is None: ratio = ds.raster.ycoords.size / (ds.raster.xcoords.size * 1.4) figsize = (6, 6 * ratio) fig = plt.figure(figsize=figsize) ax = plt.subplot(projection=crs) ax.set_extent(extent, crs=crs) if bmap is not None: if zoomlevel == "auto": # auto zoomlevel c = 2 * np.pi * 6378137 # Earth circumference lat = np.array(ds.raster.transform_bounds(4326))[[1, 3]].mean() # max 4 x 4 tiles per image tile_size = max(bounds[2] - bounds[0], bounds[3] - bounds[1]) / 4 if proj_crs.is_geographic: tile_size = tile_size * 111111 zoomlevel = int(np.log2(c * abs(np.cos(lat)) / tile_size)) # sensible range is 9 (large metropolitan area) - 16 (street) zoomlevel = min(16, max(9, zoomlevel)) # short names for cartopy image tiles bmap = {"sat": "QuadtreeTiles", "osm": "OSM"}.get(bmap, bmap) if has_cx and bmap in list(cx.providers.flatten()): bmap_kwargs = dict(zoom=zoomlevel, **bmap_kwargs) cx.add_basemap(ax, crs=crs, source=bmap, **bmap_kwargs) elif hasattr(cimgt, bmap): bmap_img = getattr(cimgt, bmap)(**bmap_kwargs) ax.add_image(bmap_img, zoomlevel) else: err = f"Unknown background map: {bmap}" if not has_cx: err += " (note that contextily is not installed)" raise ValueError(err) # by default colorbar on lower right & legend upper right kwargs0 = {"cbar_kwargs": {"shrink": 0.5, "anchor": (0, 0)}} kwargs0.update(kwargs) # make nice cmap if "cmap" not in kwargs or "norm" not in kwargs: if variable == "dep" and "dep" in ds: vmin, vmax = ds["dep"].raster.mask_nodata().quantile([0.0, 0.98]).values vmin, vmax = int(kwargs.pop("vmin", vmin)), int(kwargs.pop("vmax", vmax)) c_dem = plt.cm.terrain(np.linspace(0.25, 1, vmax)) if vmin < 0: c_bat = plt.cm.terrain(np.linspace(0, 0.17, max(1, abs(vmin)))) c_dem = np.vstack((c_bat, c_dem)) cmap = colors.LinearSegmentedColormap.from_list("dem", c_dem) norm = colors.Normalize(vmin=vmin, vmax=vmax) cmap, norm = kwargs.pop("cmap", cmap), kwargs.pop("norm", norm) kwargs0.update(norm=norm, cmap=cmap) elif variable == "msk" and "msk" in ds: cmap = colors.LinearSegmentedColormap.from_list( "Set1", ["grey", "r", "m"], N=3 ) norm = colors.BoundaryNorm([0.5, 1.5, 2.5, 3.5], 3) kwargs0.update(norm=norm, cmap=cmap) kwargs0["cbar_kwargs"].update(ticks=[1, 2, 3]) if variable in ds: da = ds[variable].raster.mask_nodata() if "msk" in ds and np.any(ds["msk"] > 0): da = da.where(ds["msk"] > 0) if da.raster.rotation != 0 and "xc" in da.coords and "yc" in da.coords: da.plot(transform=crs, x="xc", y="yc", ax=ax, zorder=1, **kwargs0) else: da.plot.imshow(transform=crs, ax=ax, zorder=1, **kwargs0) if shaded and variable == "dep" and da.raster.rotation == 0: ls = colors.LightSource(azdeg=315, altdeg=45) dx, dy = da.raster.res _rgb = ls.shade( da.fillna(0).values, norm=kwargs["norm"], cmap=kwargs["cmap"], blend_mode="soft", dx=dx, dy=dy, vert_exag=2, ) rgb = xr.DataArray( dims=("y", "x", "rgb"), data=_rgb, coords=da.raster.coords ) rgb = xr.where(np.isnan(da), np.nan, rgb) rgb.plot.imshow(transform=crs, ax=ax, zorder=1) # geometry plotting and annotate kwargs for k, d in geom_kwargs.items(): geom_style[k].update(**d) ann_kwargs = dict( xytext=(3, 3), textcoords="offset points", zorder=4, path_effects=[ patheffects.Stroke(linewidth=3, foreground="w"), patheffects.Normal(), ], ) # plot mask boundaries if plot_bounds and "msk" not in ds: raise ValueError( "No 'msk' (sfincs.msk) found in ds required to plot the model bounds " "Set plot_bounds=False or add 'msk' to ds" ) elif plot_bounds and (ds["msk"] >= 1).any(): gdf_msk = get_bounds_vector(ds["msk"]) gdf_msk2 = gdf_msk[gdf_msk["value"] == 2] gdf_msk3 = gdf_msk[gdf_msk["value"] == 3] if gdf_msk2.index.size > 0: gdf_msk2.plot(ax=ax, zorder=3, label="waterlevel bnd", **geom_style["msk2"]) if gdf_msk3.index.size > 0: gdf_msk3.plot(ax=ax, zorder=3, label="outflow bnd", **geom_style["msk3"]) # plot static geoms if plot_geoms: geom_names = geom_names if isinstance(geom_names, list) else list(geoms.keys()) for name in geom_names: gdf = geoms.get(name, None) if gdf is None or name in ["region", "bbox"]: continue # copy is important to keep annotate working if repeated kwargs = geom_style.get(name, {}).copy() annotate = kwargs.pop("annotate", False) gdf.plot(ax=ax, zorder=3, label=name, **kwargs) if annotate and np.all(gdf.geometry.type == "Point"): for label, row in gdf.iterrows(): x, y = row.geometry.x, row.geometry.y ax.annotate(label, xy=(x, y), **ann_kwargs) if "region" in geoms and plot_region: geoms["region"].boundary.plot( ax=ax, zorder=2, label="region", **geom_style["region"] ) # title, legend and labels ax.xaxis.set_visible(True) ax.yaxis.set_visible(True) ax.set_ylabel(ylab) ax.set_xlabel(xlab) variable = "base" if variable is None else variable ax.set_title(f"SFINCS {variable} map") # NOTE without defined loc it takes forever to find a 'best' location # by default outside plot if geom_names or plot_bounds: legend_kwargs0 = dict( bbox_to_anchor=(1.05, 1), title="Legend", loc="upper left", frameon=True, prop=dict(size=8), ) legend_kwargs0.update(**legend_kwargs) ax.legend(**legend_kwargs0) return fig, ax