Source code for mhm_tools.pre.subdomain_masks

"""Create subdomain masks for parallel mRM routing domains.

The module splits a basin ID river network into independent subdomains from a
cluster definition, fills gaps by nearest-neighbor assignment, and writes mask
files that can be used to run regional parts of a larger routing network.

Authors
-------
- Simon Lüdke
Based on a script by
-------
- Robert Schweppe
- Matthias Kelbling
"""

import logging
from pathlib import Path

import matplotlib as mpl
import numpy as np
import xarray as xr
from scipy.interpolate import NearestNDInterpolator

from mhm_tools.common.file_handler import get_xarray_ds_from_file, write_xarray_to_file
from mhm_tools.common.logger import ErrorLogger, log_arguments

logger = logging.getLogger(__name__)

# GLOBAL VARIABLES
# Coordinate arrays for the shape of Greenland in lons, lats
REF_FILE_ENCODING = {
    "elevtn": {"dtype": "float32", "_FillValue": -9999.0, "zlib": True, "complevel": 4},
    "basin": {"dtype": "int32", "_FillValue": -9999, "zlib": True, "complevel": 4},
    "flwdir": {"dtype": "int32", "_FillValue": -9999, "zlib": True, "complevel": 4},
    # "upgrid": {"dtype": "int32", "_FillValue": -9999, "zlib": True, "complevel": 4},
    "uparea_grid": {
        "dtype": "float32",
        "_FillValue": -9999.0,
        "zlib": True,
        "complevel": 4,
    },
    "lat": {"dtype": "float32", "_FillValue": -9999.0, "zlib": True, "complevel": 4},
    "lon": {"dtype": "float32", "_FillValue": -9999.0, "zlib": True, "complevel": 4},
}
COMPRESSION_DICT = {"zlib": True, "complevel": 4}
GREENLAND_COORDS = np.array(
    [
        [-43.65, -17.25, -7.05, -56.05, -60.05, -69.85, -73.65, -71.85, -46.65],
        [58.25, 69.85, 84.65, 85.85, 82.65, 79.45, 79.05, 75.25, 57.65],
    ]
).T
FILL_VALUE = -9999

# FUNCTIONS


[docs] class CreateSubdomainMasks: """A class for creating subdomain masks based on input data. Parameters ---------- output_dir : str The directory path where the output files will be saved. output_file_name : str The name of the output file. basin_id_file : str The file name of the reference basin IDs. basin_clusters : str The file name of the basin cluster IDs. land_mask : str The file name of the land mask and grid of target resolution. Raises ------ ValueError If the input path is not a directory. """ def __init__( self, output_dir, output_file_name, basin_id_file, basin_clusters, land_mask, land_mask_variable="land_mask", ): # unique basins ids, need to be in variable 'basin' self.ref_file = basin_id_file # clustered basins ids, need to be in variable 'mask', can be any resolution self.pgb_file = basin_clusters # land mask and grid of target resolution, need to be in integer variable 'land_mask' self.land_file = land_mask self.output_dir = Path(output_dir) self.land_mask_variable = land_mask_variable out_dir_path = self.output_dir if not out_dir_path.is_dir(): out_dir_path.mkdir(parents=True, exist_ok=True) self.out_file_name = str(out_dir_path / Path(output_file_name).stem)
[docs] @staticmethod def read_var(fname, var_name): """Read a variable from a netcdf file.""" logger.info(f"reading variable {var_name} from {fname}") with get_xarray_ds_from_file(fname) as ds: return ds[var_name]
[docs] @staticmethod def get_mask_from_polygon(arr, vertices): """Create a boolean mask for points inside a polygon. The input `arr` is a 2D array with `lat` and `lon` coordinates; the mask is True for cells whose (lon, lat) fall inside the polygon defined by `vertices`. Parameters ---------- arr : xarray.DataArray 2D data array with coordinates `lat` and `lon`. vertices : sequence[tuple[float, float]] Polygon vertices as (lon, lat) pairs. Returns ------- numpy.ndarray Boolean mask with the same shape as `arr`, True inside the polygon. """ polygon = mpl.path.Path(vertices) # mask out only the values in arr that fall within bbox of polygon, convert them to points bbox = polygon.get_extents() bbox_lon_mask = (bbox.xmin < arr.lon) & (bbox.xmax > arr.lon) bbox_lat_mask = (bbox.ymin < arr.lat) & (bbox.ymax > arr.lat) lon2d, lat2d = np.meshgrid(arr.lon[bbox_lon_mask], arr.lat[bbox_lat_mask]) points = np.hstack((lon2d.reshape(-1, 1), lat2d.reshape(-1, 1))) # mask out the values bbox_mask = polygon.contains_points(points).reshape( int(bbox_lat_mask.sum()), int(bbox_lon_mask.sum()) ) # global mask, set to False mask = np.zeros_like(arr.data, dtype=bool) # insert the local mask into the global one mask[np.ix_(bbox_lat_mask, bbox_lon_mask)] = bbox_mask return mask
[docs] def create_subdomains(self): """Create subdomain masks based on the input data. Returns ------- None """ logger.info("Global domain selected. Creating subdomains...") if self.pgb_file is None: msg = "Basin cluser file not provided even tho the input is global." with ErrorLogger(logger): raise ValueError(msg) new_ids = self.read_var(fname=self.ref_file, var_name="basin") orig_ids = self.read_var(fname=self.pgb_file, var_name="mask") land_mask = self.read_var( fname=self.land_file, var_name=self.land_mask_variable ).astype(bool) ds_ref_file = get_xarray_ds_from_file(self.ref_file).sel( lat=land_mask.lat, lon=land_mask.lon, method="nearest" ) for coord in ["latitude", "longitude"]: if coord in ds_ref_file: ds_ref_file = ds_ref_file.drop(coord) file_basins_remapped = ( self.output_dir / "unique_basin_ids_03min_agg54classes.nc" ) if not file_basins_remapped.is_file(): # map the 53 subbasins from PGB reference onto target grid logger.info( "remapping orignal subbasins to target grid and adding Greenland id" ) orig_remapped = orig_ids.sel( lat=land_mask.lat, lon=land_mask.lon, method="nearest" ) # add one additional subbasin for greenland greenland_mask = self.get_mask_from_polygon(orig_remapped, GREENLAND_COORDS) orig_remapped.values[greenland_mask] = 54 # for all subbasins where the land_mask is nan, also set nan logger.info("remapping new subbasins to target grid") new_ids_remapped = new_ids.sel( lat=land_mask.lat, lon=land_mask.lon, method="nearest" ) new_ids_remapped.values[np.isnan(land_mask)] = np.nan # for all original subbasins that do not cover new subbasins, interpolate # https://stackoverflow.com/questions/68197762/fill-nan-with-nearest-neighbor-in-numpy-array # in contrast to previous version, we interpolate the whole globe to ensure we do not use a nan as fill value logger.info("interpolating missing values in original subbasins") mask_fill = np.where(~np.isnan(orig_remapped.values)) interp = NearestNDInterpolator( np.transpose(mask_fill), orig_remapped.values[mask_fill] ) filled_data = interp(*np.indices(orig_remapped.values.shape)) # 2nd step --- set exactly one subdomain mask for each river logger.info( "assigning each new subbasin to class defined in original subbasins" ) basin_ids = np.unique( new_ids_remapped.values[~np.isnan(new_ids_remapped.values)] ) for basin_id in basin_ids: # get mask of all cells for this river river_mask = new_ids_remapped.values == basin_id # get all ids of the original basins for this id sel_ids, sel_counts = np.unique( filled_data[river_mask], return_counts=True ) # select the most commonly occurring one new_ids_remapped.values[river_mask] = sel_ids[sel_counts.argmax()] # 3rd step --- write masks # read land_mask to intersect with subdomain_mask write_xarray_to_file( ds=new_ids_remapped, file_path=file_basins_remapped, encoding={ new_ids_remapped.name: {"_FillValue": FILL_VALUE, "dtype": "int16"} }, ) else: logger.info("using cached remapped basin ids") new_ids_remapped = xr.open_dataarray(file_basins_remapped) basin_ids = np.unique( new_ids_remapped.values[~np.isnan(new_ids_remapped.values)] ) logger.info("writing output files") for i, basin_id in enumerate(basin_ids, 1): logger.info(f"processing subdomain {i}") sub_mask = (new_ids_remapped.values == basin_id) & ~np.isnan(land_mask) fname = self.out_file_name + f"_{i:02}.nc" ds_sub_ref_file = ds_ref_file.copy() for data_var in ds_sub_ref_file.data_vars: ds_sub_ref_file[data_var].values[~sub_mask] = np.nan write_xarray_to_file( ds=ds_sub_ref_file, file_path=fname, encoding=REF_FILE_ENCODING ) logger.info(f"Wrote to {fname}")
[docs] def use_land_mask(self, lat, lon): """Reencode and mask the input files.""" logger.info("Non global file selected. Only reencoding and masking the input.") res = lon[1] - lon[0] logger.debug(f"lat={slice(lat.values[0], lat.values[-1])}") logger.debug(f"lon={slice(lon.values[0], lon.values[-1])}") # Read and slice the land mask land_mask = self.read_var(fname=self.land_file, var_name="land_mask").astype( bool ) land_mask = land_mask.sel( lat=slice(lat[0], lat[-1] - res), lon=slice(lon[0] - res, lon[-1]) ) # Read and slice the reference file based on the land mask coordinates logger.info(f"Reading {self.ref_file}") ds_ref_file = get_xarray_ds_from_file(self.ref_file).sel( lat=land_mask.lat, lon=land_mask.lon, method="nearest" ) # Drop any redundant coordinates ds_ref_file = ds_ref_file.drop_vars(["latitude", "longitude"], errors="ignore") # Apply the land mask to all variables in the dataset logger.info("Applying land mask to the dataset") logger.debug(f"land_mask {land_mask}") logger.debug(f"lon land_mask: {land_mask.lon}") logger.debug(f"ds_ref {ds_ref_file}") ds_sub_ref_file = ds_ref_file.copy() # first process fdir logger.info("processing fdir") data_var_values = ds_sub_ref_file["flwdir"].values data_var_values[np.isnan(land_mask)] = np.nan ds_sub_ref_file["flwdir"].values = data_var_values for data_var in ds_sub_ref_file.data_vars: if data_var == "flwdir": continue logger.info(f"processing {data_var}") data_var_values = ds_sub_ref_file[data_var].values logger.debug(f"lon {data_var}: {ds_sub_ref_file[data_var].lon}") # replace all values where land mask is nan with nan ds_sub_ref_file[data_var] = ds_sub_ref_file[data_var].where( land_mask != 0, np.nan ) # Write the output to a netCDF file fname = self.out_file_name + ".nc" logger.info(f"Writing to {fname}") write_xarray_to_file( ds=ds_sub_ref_file, file_path=fname, encoding=REF_FILE_ENCODING )
[docs] @log_arguments() def create_subdomain_masks( output_dir, output_file_name, basin_id_file, basin_clusters, land_mask, land_mask_variable, ): """Create subdomain masks based on the provided input parameters. Args: output_dir (str): The directory where the output files will be saved. output_file_name (str): The name of the output file. basin_id_file (str): The file containing the basin IDs. basin_clusters (str): The file containing the basin clusters. land_mask (str): The land mask file. Returns ------- None """ csm = CreateSubdomainMasks( output_dir=output_dir, output_file_name=output_file_name, basin_id_file=basin_id_file, basin_clusters=basin_clusters, land_mask=land_mask, land_mask_variable=land_mask_variable, ) with get_xarray_ds_from_file(basin_id_file) as ds: lat = ds.lat lon = ds.lon # if input is not global only create a file else create all subdomains if ( np.max(lon) - np.min(lon) != 360 and (np.max(lat) - np.min(lat) < 130 or np.max(lat) - np.min(lat) > 180) ) or basin_clusters is None: csm.use_land_mask(lat, lon) else: csm.create_subdomains()