Source code for mhm_tools.pre.fill_nearest

"""
Fill missing NetCDF cells from nearest valid neighbours.

This interpolator should not be used on global scale as it will be slow.

The module fills spatial gaps in gridded NetCDF variables by copying values
from the nearest valid source cell. For variables with a ``time`` dimension,
the nearest source cells are selected from the first time slice and then reused
for all time steps, preserving temporal variability at the selected source
locations.

An optional mask can reserve cells that must remain outside the filled domain.
Those masked cells are written with the configured fill value instead of being
nearest-neighbour filled.

Authors
- Simon Lüdke
- Sebastian Müller
"""

import logging
import shutil
import tempfile
from pathlib import Path

import numpy as np
import xarray as xr
from joblib import Parallel, delayed
from scipy.spatial import KDTree

from mhm_tools.common.logger import ErrorLogger, log_arguments
from mhm_tools.common.xarray_utils import get_single_data_var

logger = logging.getLogger(__name__)


def _coordinate_mesh(coords, names):
    """Return coordinate position arrays for spatial nearest-neighbour lookup.

    Parameters
    ----------
    coords : xarray.core.coordinates.DataArrayCoordinates
        Coordinate mapping from the target data array.
    names : tuple[str, ...]
        Dimension names used to construct the KD-tree positions.

    Returns
    -------
    list[numpy.ndarray] or tuple[numpy.ndarray, ...]
        Coordinate arrays aligned with the dimensions in ``names``.
    """
    generate_mesh = all(name in coords and name in coords[name].dims for name in names)
    positions = [coords[name].data for name in names]
    return np.meshgrid(*positions, indexing="ij") if generate_mesh else positions


[docs] def nearest_indices(input_positions, target_positions): """Return nearest source indices for target coordinate positions. Parameters ---------- input_positions : numpy.ndarray Two-dimensional array of valid source positions with shape ``(n_source, n_dimensions)``. target_positions : numpy.ndarray Two-dimensional array of target positions with shape ``(n_target, n_dimensions)``. Returns ------- numpy.ndarray Indices into ``input_positions`` for the nearest source cell of each target position. """ tree = KDTree(input_positions) return tree.query(target_positions)[1]
def _valid_mask(array, along_time, missing_value): """Build a boolean mask for cells that can be used as fill sources. Parameters ---------- array : xarray.DataArray Data array whose finite non-missing cells are potential source cells. along_time : bool If true, derive source validity from the first time slice only. missing_value : float or None Explicit missing-value marker to exclude in addition to NaN. Returns ------- numpy.ndarray Boolean mask with true values at valid source cells. """ values = array[{"time": 0}].data if along_time else array.data valid = ~np.isnan(values) if missing_value is not None and not np.isnan(missing_value): valid &= ~np.isclose(values, missing_value) return valid def _valid_values(values, missing_value): valid = ~np.isnan(values) if missing_value is not None and not np.isnan(missing_value): valid &= ~np.isclose(values, missing_value) return valid def _fill_diagnostics(array, along_time, missing_value, mask): values = array.transpose("time", ...).data if along_time else array.data valid = _valid_values(values, missing_value) missing = ~valid target = missing.copy() masked_count = 0 if mask is not None: mask_for_values = np.broadcast_to(mask, values.shape) if along_time else mask masked_count = int(np.sum(mask_for_values)) target &= ~mask_for_values return { "total": int(valid.size), "valid": int(np.sum(valid)), "missing": int(np.sum(missing)), "target": int(np.sum(target)), "masked": masked_count, }
[docs] def fill_dataarray_with_nearest( # noqa: PLR0915, PLR0912 array, along_time=None, missing_value=None, mask=None, fill_value=-9999.0, default_value=None, source_file=None, ): """Fill missing values in a data array with nearest valid neighbours. Parameters ---------- array : xarray.DataArray Data array modified in place. along_time : bool, optional Whether to treat all non-time dimensions as the fill domain and copy the selected source-cell time series to each target cell. Defaults to true when ``array`` has a ``time`` dimension. missing_value : float, optional Missing-value marker to fill. NaNs are always treated as missing. mask : numpy.ndarray, optional Boolean spatial mask where true cells are excluded from filling and set to ``fill_value``. fill_value : float, default -9999.0 Value written to masked cells and to target cells when no valid source cells exist. source_file : str or pathlib.Path, optional File path used only for diagnostic log messages. Returns ------- int Number of unmasked cells selected for nearest-neighbour filling. """ coord_names = array.dims along_time = "time" in coord_names if along_time is None else bool(along_time) if along_time: coord_names = tuple(name for name in coord_names if name != "time") diagnostics = _fill_diagnostics( array, along_time=along_time, missing_value=missing_value, mask=mask, ) valid = _valid_mask(array, along_time=along_time, missing_value=missing_value) target = ~valid if mask is not None: target &= ~mask replacement_mode = "nearest" if diagnostics["target"] == 0: replacement_mode = "none" elif diagnostics["valid"] == 0: replacement_mode = ( "default_value" if default_value is not None else "fill_value" ) logger.info( f"Fill classification for variable {array.name}" f"{f' in {source_file}' if source_file is not None else ''}: " f"total spatial cells={diagnostics['total']}, " f"valid cells={diagnostics['valid']}, " f"missing cells={diagnostics['missing']}, " f"target cells={diagnostics['target']}, " f"masked cells={diagnostics['masked']}, " f"along_time={along_time}, replacement mode={replacement_mode}, " f"missing_value={missing_value}, fill_value={fill_value}, " f"default_value={default_value}." ) if diagnostics["target"] == 0: if mask is not None: values = array.transpose("time", ...).data if along_time else array.data if along_time: values[:, mask] = fill_value else: values[mask] = fill_value logger.debug(f"No cells require nearest-neighbour filling for {array.name}.") return 0 if along_time: replacement_value = fill_value if default_value is None else default_value positions = _coordinate_mesh(array.coords, coord_names) values = array.transpose("time", ...).data total_filled_count = 0 nearest_filled_count = 0 fallback_filled_count = 0 for time_index in range(values.shape[0]): valid_at_time = _valid_values(values[time_index], missing_value) target_at_time = ~valid_at_time if mask is not None: target_at_time &= ~mask if not np.any(target_at_time): if mask is not None: values[time_index][mask] = fill_value continue target_count = int(np.sum(target_at_time)) valid_count = int(np.sum(valid_at_time)) total_filled_count += target_count if valid_count == 0: fallback_filled_count += target_count context = f" in {source_file}" if source_file is not None else "" logger.warning( f"Cannot nearest-neighbour fill {target_count} cells in " f"variable {array.name}{context} at time index {time_index}: " "no valid source cells are available. Setting target cells " f"to {replacement_value} using " f"{'default_value' if default_value is not None else 'fill_value'}. " f"dims={array.dims} shape={array.shape} " f"missing_value={missing_value} mask={mask is not None}" ) values[time_index][target_at_time] = replacement_value if mask is not None: values[time_index][mask] = fill_value continue valid_positions = np.array( [position[valid_at_time] for position in positions] ).T target_positions = np.array( [position[target_at_time] for position in positions] ).T target_indices = nearest_indices(valid_positions, target_positions) values[time_index][target_at_time] = values[time_index][valid_at_time][ target_indices ] if mask is not None: values[time_index][mask] = fill_value nearest_filled_count += target_count logger.info( f"Filled {total_filled_count} cells in {array.name}" f"{f' from {source_file}' if source_file is not None else ''} " f"across {values.shape[0]} timesteps " f"(nearest={nearest_filled_count}, fallback={fallback_filled_count})." ) if mask is not None: return int(np.sum(target)) return nearest_filled_count filled_count = int(np.sum(target)) valid_count = int(np.sum(valid)) if valid_count == 0: replacement_value = fill_value if default_value is None else default_value context = f" in {source_file}" if source_file is not None else "" logger.warning( f"Cannot nearest-neighbour fill {filled_count} cells in variable " f"{array.name}{context}: no valid source cells are available. " f"Setting target cells to {replacement_value} using {replacement_mode}. " f"dims={array.dims} shape={array.shape} missing_value={missing_value} " f"mask={mask is not None}" ) values = array.transpose("time", ...).data if along_time else array.data if along_time: values[:, target] = replacement_value if mask is not None: values[:, mask] = fill_value else: values[target] = replacement_value if mask is not None: values[mask] = fill_value return 0 positions = _coordinate_mesh(array.coords, coord_names) valid_positions = np.array([position[valid] for position in positions]).T target_positions = np.array([position[target] for position in positions]).T target_indices = nearest_indices(valid_positions, target_positions) values = array.transpose("time", ...).data if along_time else array.data if along_time: values[:, target] = values[:, valid][:, target_indices] if mask is not None: values[:, mask] = fill_value else: values[target] = values[valid][target_indices] if mask is not None: values[mask] = fill_value logger.info( f"Filled {filled_count} cells in {array.name}" f"{f' from {source_file}' if source_file is not None else ''} " f"using {valid_count} valid source cells." ) return filled_count
[docs] def read_mask(mask_file, mask_var): """Read a fixed output mask from a NetCDF variable. Parameters ---------- mask_file : str or pathlib.Path or None NetCDF file containing the mask variable. If omitted, no mask is used. mask_var : str or None Variable whose NaN cells define masked output cells. If omitted, no mask is used. Returns ------- numpy.ndarray or None Boolean array with true values where output cells must remain masked, or ``None`` when masking is disabled. """ if mask_file is None or mask_var is None: return None mask_file = Path(mask_file) with xr.open_dataset(mask_file, engine="netcdf4") as dataset: if mask_var is None: mask_var = get_single_data_var(dataset) if mask_var not in dataset: msg = f"Variable {mask_var!r} not found in mask file {mask_file}." with ErrorLogger(logger): raise KeyError(msg) logger.info(f"Reading mask from variable {mask_var} in {mask_file}.") data_array = dataset[mask_var].load() values = ( data_array.transpose("time", ...).data[0, ...] if "time" in data_array.dims else data_array.data ) return np.isnan(values)
def _missing_value(data_array): """Return the missing-value marker configured on a data array. Parameters ---------- data_array : xarray.DataArray Data array whose encoding and attributes are inspected. Returns ------- float Missing-value marker, falling back to NaN when no marker is present. """ return data_array.encoding.get( "missing_value", data_array.attrs.get( "missing_value", data_array.encoding.get( "_FillValue", data_array.attrs.get("_FillValue", np.nan) ), ), ) def _prepare_coordinate(data_array): """Remove fill-value metadata from a coordinate data array. Parameters ---------- data_array : xarray.DataArray Coordinate data array modified in place. """ data_array.attrs.pop("_FillValue", None) data_array.attrs.pop("missing_value", None) def _output_encoding(dataset): """Build NetCDF encoding for coordinate variables in filled output. Parameters ---------- dataset : xarray.Dataset Dataset whose coordinate encodings are generated. Returns ------- dict[str, dict] Encoding mapping passed to ``Dataset.to_netcdf``. """ return { name: dict(_FillValue=None, **({"dtype": "i4"} if name == "time" else {})) for name in dataset.coords }
[docs] def fill_one_file(input_file, input_dir, fill_value, default_value, mask, output_dir): """Fill all data variables in one NetCDF file and write the result. Parameters ---------- input_file : pathlib.Path NetCDF file to read. input_dir : pathlib.Path Base input directory used for relative log messages. fill_value : float Fill value written to output metadata and masked cells. default_value : float Value to fill masked area if the whole domain is empty and nearest interpolation not possible. mask : numpy.ndarray or None Optional fixed output mask shared by all data variables. output_dir : pathlib.Path Directory where the filled file is written. Returns ------- pathlib.Path Path to the written output file. """ logger.info(f"Filling {input_file.relative_to(input_dir)}.") with xr.open_dataset(input_file, engine="netcdf4", mask_and_scale=False) as ds: dataset = ds.load() variable_diagnostics = {} for var_name in list(dataset.data_vars) + list(dataset.coords): data_array = dataset[var_name] if var_name in dataset.coords: _prepare_coordinate(data_array) continue missing_value = float(_missing_value(data_array)) logger.info( f"Preparing to fill {input_file} variable {var_name}: " f"dims={data_array.dims}, shape={data_array.shape}, " f"detected missing_value={missing_value}, fill_value={fill_value}, " f"default_value={default_value}, mask active={mask is not None}." ) data_array.attrs["_FillValue"] = fill_value data_array.attrs["missing_value"] = fill_value try: fill_dataarray_with_nearest( data_array, missing_value=missing_value, mask=mask, fill_value=fill_value, default_value=default_value, source_file=input_file, ) except Exception as exc: msg = ( f"Failed to nearest-neighbour fill variable {var_name!r} " f"in {input_file}. dims={data_array.dims} " f"shape={data_array.shape} missing_value={missing_value}" ) with ErrorLogger(logger): raise RuntimeError(msg) from exc along_time = "time" in data_array.dims variable_diagnostics[var_name] = ( data_array, along_time, missing_value, _fill_diagnostics( data_array, along_time=along_time, missing_value=missing_value, mask=mask, ), ) output_file = output_dir / input_file.name with tempfile.TemporaryDirectory(dir=output_dir) as tmp_dir: tmp_file = Path(tmp_dir) / "tmp.nc" dataset.to_netcdf(tmp_file, encoding=_output_encoding(dataset)) shutil.move(tmp_file, output_file) logger.info(f"Wrote filled NetCDF file to {output_file}.") for var_name, (_, _, _, diagnostics) in variable_diagnostics.items(): logger.info( f"Post-fill diagnostics for {output_file} variable {var_name}: " f"remaining missing cells={diagnostics['missing']}, " f"remaining target cells={diagnostics['target']}, " f"valid cells={diagnostics['valid']}, " f"masked cells={diagnostics['masked']}." ) return output_file
[docs] @log_arguments() def fill_nearest( input_dir, fname="precipitation_*.nc", output_dir="output_dir", mask_file=None, mask_var=None, fill_value=-9999.0, default_value=None, n_cpus=1, ): """Fill missing values in matching NetCDF files with nearest neighbours. Parameters ---------- input_dir : str or pathlib.Path Directory searched for input files. fname : str, default "precipitation_*.nc" Glob pattern selecting input NetCDF files. output_dir : str or pathlib.Path, default "output_dir" Directory where filled files are written. mask_file : str or pathlib.Path, optional Optional NetCDF file containing the fixed output mask. mask_var : str, optional Variable in ``mask_file`` whose NaNs define masked output cells. fill_value : float, default -9999.0 Fill value written to missing metadata and masked cells. n_cpus : int, default 1 Number of worker processes used for file-level parallelism. Returns ------- list[pathlib.Path] Output files written by the fill operation. """ input_dir = Path(input_dir) output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) input_files = sorted(input_dir.glob(fname)) if not input_files: msg = f"No files match {input_dir / fname}." with ErrorLogger(logger): raise FileNotFoundError(msg) mask = read_mask(mask_file, mask_var) logger.info(f"Found {len(input_files)} input files matching {input_dir / fname}.") output_files = [] if n_cpus == 1: for input_file in input_files: output_files.append( fill_one_file( input_file, input_dir, fill_value, default_value, mask, output_dir, ) ) else: output_files = Parallel(n_jobs=n_cpus, backend="loky")( delayed(fill_one_file)( input_file, input_dir, fill_value, default_value, mask, output_dir, ) for input_file in input_files ) return output_files
fill = fill_nearest