# Copyright (c) 2026 MeteoSwiss, contributors listed in AUTHORS
#
# Distributed under the terms of the BSD 3-Clause License.
#
# SPDX-License-Identifier: BSD-3-Clause
# pylint: disable=too-many-lines
"""
Module for spatial interpolation of gridded datasets
"""
import logging
from functools import partial
from typing import Any, Literal, overload
from collections.abc import Callable
from math import isclose, prod, ceil
import numpy as np
from numpy.typing import NDArray
import xarray as xr
import geopandas as gpd
from scipy.interpolate import griddata
from scipy.spatial import KDTree
from pyproj import CRS
from grid_process import CONFIG
from grid_process.helpers import make_accessor, reproject
from grid_process import data_model as dm
from grid_process.optimization import memoize
from grid_process.ds_utils import ds_to_da, cast_dtype
from grid_process.da_utils import da_to_ds
logger = logging.getLogger(__name__)
# Register xarray accessor so module functions can also be called as methods
# on xr.Dataset objects
[docs]
@xr.register_dataset_accessor("grid_interp")
class GridInterp:
"""
Xarray accessors class for spatial interpolation of gridded data
"""
def __init__(self, ds: xr.Dataset):
self._data = ds
dataset_accessor = partial(make_accessor, accessor_class=GridInterp)
POI_MODEL = CONFIG.data_model.poi
POI_DIM = POI_MODEL.dim
POI_COORD_COL = POI_MODEL.coord_from
SRC_ATTRS_KEEP = ("source", "title", "institution", "history", "references", "comment")
SWISS_CRS = ("EPSG:2056", "EPSG:21781")
InterpMethods = (
Literal[
"idw",
"temp",
"temp_fixgrad",
"nearest_alt",
"snowlim",
"nearest_3D",
"linear_3D",
"idw_3D",
"upscaled",
"upscaled_3D",
]
| Literal["nearest", "linear", "cubic"]
)
[docs]
def interp_temp( # pylint: disable=too-many-locals
coords_flat: np.ndarray,
data_flat: np.ndarray,
pois_coords: np.ndarray,
z_fact: int = 25,
k: int = 36,
weight_power: float | None = None,
grad_only: bool = False,
chunk_size: int = 20,
) -> np.ndarray:
"""
Interpolation from grid to POIs for temperature data, based on local
soundings. The grid does not need to be regular. The sounding curves are
fitted on the k nearest neighbors considering a distance that more heavily
penalizes elevation differences for ``z_fact`` > 1.
Parameters
----------
coords_flat : numpy.ndarray, shape (n_grids, m)
array of flattened grid coordinates. n_grids is the number of model
grid points, m the number of coordinates dimensions (i.e. 3)
data_flat : numpy.ndarray, shape (n_grids, ...)
array containing the flattened model data with various trailing
dimensions
pois_coords : numpy.ndarray , shape (n_pois, m)
array of POI coordinates. n_pois is the number of POIs, m the number of
coordinates dimensions (i.e. 3)
z_fact : positive int, optional
factor to multiply the z coordinates to find nearest neighbors based on a
non-euclidean distance that penalizes more altitude differences. Default
value is 50. A value of 1 will use regular euclidean distances.
k : positive int, optional
number of considered nearest neighbors for the local sounding. Default
is 36
weight_power : positive float, optional
a weight vector is passed to the sounding fit according to
``w = d^(-weight_power)``, with d being the (non-euclidean) distances between
the considered POI and the k nearest grid points. The greater is the
``weight_power``, the stronger is the penalization with distance. A
``weight_power`` of 0 is equivalent to None (i.e. same weights for all
points). By default, gives same weight for all points
grad_only : bool, optional
if True, only uses the local gradient to correct the nearest gridpoint
temperature based on the altitude difference with the POI. Default is
False
chunk_size : positive int, optional
number of POIs processed per sounding batch. Smaller values reduce peak
memory (dominant cost: ``chunk_size × k × n_trail × 12`` bytes) and
keep the working set in L3 cache, which is typically faster when
``n_trail`` is large. Larger values reduce loop overhead and are better
for small trailing dimensions or when n_pois is small. Default is 20.
Returns
-------
poi_data : numpy.ndarray, shape (n_pois, ...)
array containing the interpolated values and the same trailing
dimensions as the input ``data_flat`` array
Raises
------
ValueError : Raised if the orography is flat, as no sounding can be estimated.
See also
--------
:func:`interp2poi`
Note
----
For POIs with altitude higher than 300m above the highest gridpoint or lower
than 500m below the lowest gridpoint in the local neighborhood used for the fit,
the fixgrad method is used with default gradient of 0.006 [K / m].
"""
# make sure k is smaller or equal to the number of grid points
k = min(k, len(coords_flat))
# rescale z coords
coords_flat_zfact = coords_flat.copy()
pois_coords_zfact = pois_coords.copy()
coords_flat_zfact[:, -1] *= z_fact
pois_coords_zfact[:, -1] *= z_fact
# find indexes and distances of k nearest neighbors in the non-euclidean dist
dist_nn, ind_nn = nn_lookup_cached(coords_flat_zfact, pois_coords_zfact, k)
# preallocate output array
data_dtype = data_flat.dtype
poi_data = np.empty((pois_coords.shape[0],) + data_flat.shape[1:], dtype=data_dtype)
# gather neighbor altitudes for all POIs at once (small: n_pois × k floats)
all_alt = coords_flat[ind_nn, -1] # (n_pois, k)
if weight_power is not None:
# weights for the sounding fit according to the distance
dist_weight: np.ndarray | None = _compute_dist_weight(dist_nn, weight_power, data_dtype)
else:
dist_weight = None
poi_alts = pois_coords[:, -1]
chunk_size = max(1, chunk_size)
if grad_only:
# grad_only always uses a deg=1 fit regardless of altitude position, so
# the deg3/deg1 split is unnecessary. Only min/max quantiles are needed
# to classify fixgrad vs sounding POIs.
alt_minmax = np.quantile(all_alt, [0, 1], axis=1) # shape (2, n_pois)
fixgrad_mask = (poi_alts < alt_minmax[0] - 600) | (poi_alts > alt_minmax[1] + 200)
sounding_groups = [(np.nonzero(~fixgrad_mask)[0], 1, True)]
else:
# classify each POI into three groups: fixgrad, deg1 sounding, deg3 sounding
alt_q = np.quantile(all_alt, [0, 0.15, 0.85, 1], axis=1) # shape (4, n_pois)
fixgrad_mask = (poi_alts < alt_q[0] - 600) | (poi_alts > alt_q[-1] + 200)
sounding_mask = ~fixgrad_mask
deg3_mask = sounding_mask & (alt_q[1] < poi_alts) & (poi_alts < alt_q[2])
deg1_mask = sounding_mask & ~deg3_mask
sounding_groups = [
(np.nonzero(deg1_mask)[0], 1, False),
(np.nonzero(deg3_mask)[0], 3, False),
]
# fixgrad group: only needs the nearest neighbor per POI (same for both branches)
fg_idx = np.nonzero(fixgrad_mask)[0]
if fg_idx.size > 0:
poi_data[fg_idx] = _fixgrad(
data_flat[ind_nn[fg_idx, 0]],
all_alt[fg_idx, 0],
poi_alts[fg_idx],
)
# sounding groups: process in memory-bounded chunks
for idx, deg, go in sounding_groups:
for start in range(0, idx.size, chunk_size):
chunk_idx = idx[start : start + chunk_size]
chunk_w = dist_weight[chunk_idx] if dist_weight is not None else None
poi_data[chunk_idx] = _sounding_batch(
data_flat[ind_nn[chunk_idx]],
all_alt[chunk_idx],
poi_alts[chunk_idx],
deg=deg,
w=chunk_w,
grad_only=go,
)
return poi_data
def _normalize_altitudes(
altitudes: np.ndarray,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""
Normalize altitudes per POI to improve numerical conditioning of the
polynomial fit.
Parameters
----------
altitudes : numpy.ndarray, shape (n_sub, k)
neighbor altitudes for each POI
Returns
-------
alt_norm : numpy.ndarray, shape (n_sub, k)
normalized altitudes with zero mean and unit standard deviation per POI
alt_mean : numpy.ndarray, shape (n_sub, 1)
per-POI altitude mean used for normalization
alt_std : numpy.ndarray, shape (n_sub, 1)
per-POI altitude standard deviation used for normalization
"""
alt_mean = altitudes.mean(axis=1, keepdims=True)
alt_std = altitudes.std(axis=1, keepdims=True).clip(1e-10)
return (altitudes - alt_mean) / alt_std, alt_mean, alt_std
def _build_vandermonde(alt_norm: np.ndarray, deg: int) -> np.ndarray:
"""
Build a batched Vandermonde matrix from normalized altitudes.
Parameters
----------
alt_norm : numpy.ndarray, shape (n_sub, k)
normalized neighbor altitudes
deg : positive int
polynomial degree
Returns
-------
V : numpy.ndarray, shape (n_sub, k, deg+1)
Vandermonde matrix with columns [1, x, x^2, ..., x^deg]
"""
exponents = np.arange(deg + 1, dtype=np.float64)
return alt_norm[:, :, None] ** exponents
def _apply_weights(vandermonde: np.ndarray, values: np.ndarray, w: np.ndarray | None) -> tuple[np.ndarray, np.ndarray]:
"""
Apply per-POI weights to the Vandermonde matrix and right-hand side.
Parameters
----------
vandermonde : numpy.ndarray, shape (n_sub, k, deg+1)
Vandermonde matrix
values : numpy.ndarray, shape (n_sub, k, n_trail)
flattened data values
w : numpy.ndarray, shape (n_sub, k) or None
weight vector per POI; if None, uniform weights are used
Returns
-------
vw : numpy.ndarray, shape (n_sub, k, deg+1)
weight-scaled Vandermonde matrix
yw : numpy.ndarray, shape (n_sub, k, n_trail)
weight-scaled right-hand side
"""
if w is not None:
ww = w[:, :, None].astype(np.float64, copy=False)
return vandermonde * ww, values * ww
return vandermonde, values
def _qr_polyfit_batch(vw: np.ndarray, yw: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""
Solve a batch of weighted least-squares problems via QR decomposition and
detect rank-deficient systems.
Parameters
----------
vw : numpy.ndarray, shape (n_sub, k, deg+1)
weighted Vandermonde matrix
yw : numpy.ndarray, shape (n_sub, k, n_trail)
weighted right-hand side
Returns
-------
coeffs : numpy.ndarray, shape (n_sub, deg+1, n_trail)
polynomial coefficients; zero for rank-deficient systems
rank_deficient : numpy.ndarray, shape (n_sub,), dtype bool
True for systems where the R diagonal indicates near-singularity
"""
q_mat, r_mat = np.linalg.qr(vw)
qt_b = q_mat.swapaxes(-2, -1) @ yw # (n_sub, deg+1, n_trail)
diag_abs = np.abs(np.diagonal(r_mat, axis1=-2, axis2=-1)) # (n_sub, deg+1)
rank_ok = diag_abs.min(axis=-1) >= 1e-10 * diag_abs.max(axis=-1)
coeffs = np.zeros_like(qt_b)
ok_idx = np.nonzero(rank_ok)[0]
if ok_idx.size > 0:
coeffs[ok_idx] = np.linalg.solve(r_mat[ok_idx], qt_b[ok_idx])
return coeffs, ~rank_ok
def _eval_poly_batch(coeffs: np.ndarray, poi_norm: np.ndarray, deg: int) -> np.ndarray:
"""
Evaluate a batch of polynomials at normalized POI altitudes.
Parameters
----------
coeffs : numpy.ndarray, shape (n_sub, deg+1, n_trail)
polynomial coefficients in the normalized altitude space
poi_norm : numpy.ndarray, shape (n_sub,)
normalized target altitudes for each POI
deg : positive int
polynomial degree
Returns
-------
result : numpy.ndarray, shape (n_sub, n_trail)
evaluated polynomial values
"""
exponents = np.arange(deg + 1, dtype=np.float64)
vander_poi = poi_norm[:, None] ** exponents # (n_sub, deg+1)
return (vander_poi[:, None, :] @ coeffs)[:, 0, :] # (n_sub, n_trail)
def _sounding_batch( # pylint: disable=too-many-locals
data_array: np.ndarray,
altitudes: np.ndarray,
poi_alts: np.ndarray,
deg: int = 3,
w: np.ndarray | None = None,
grad_only: bool = False,
) -> np.ndarray:
"""
Returns predicted temperature values for a batch of POIs according to the
local sounding, using a batched QR decomposition.
Each POI has its own neighborhood and independent polynomial fit. All fits
are computed simultaneously via ``numpy.linalg.qr`` and
``numpy.linalg.solve`` to avoid per-POI Python-level dispatch.
Parameters
----------
data_array : numpy.ndarray, shape (n_sub, k, ...)
contains the temperature values of the k nearest neighbor grid points
for each of the n_sub POIs in this batch
altitudes : numpy.ndarray, shape (n_sub, k)
contains the altitudes of the k nearest neighbor grid points for each POI
poi_alts : numpy.ndarray, shape (n_sub,)
altitude of each POI in this batch
deg : positive int
degree of the polynomial fit. Only used if ``grad_only=False``
w : numpy.ndarray, shape (n_sub, k) or None
per-POI weight vectors for the polynomial fit. If None, uniform weights
are used for all points
grad_only : bool
if True, only uses the local gradient to correct the temperature of the
nearest gridpoint based on the altitude difference with the POI.
In this case, the gradient is bound to [-0.01, 0.03] K m-1. Default is False
Returns
-------
fitted_val : numpy.ndarray, shape (n_sub, ...)
array of fitted temperatures for each POI in the batch
See also
--------
:func:`interp_temp`
"""
n_sub = data_array.shape[0]
trail_shape = data_array.shape[2:]
n_trail = int(np.prod(trail_shape)) if trail_shape else 1
# flatten trailing dims and cast to float64 for numerical stability
values = data_array.reshape(n_sub, data_array.shape[1], n_trail, order="C").astype(np.float64, copy=False)
alt_norm, alt_mean, alt_std = _normalize_altitudes(altitudes)
if grad_only:
vandermonde = _build_vandermonde(alt_norm, 1)
vw, yw = _apply_weights(vandermonde, values, w)
coeffs, _ = _qr_polyfit_batch(vw, yw)
# convert slope from normalized space back to original altitude units
slope = (coeffs[:, 1] / alt_std[:, 0, None]).clip(-0.01, 0.03)
fitted_val = values[:, 0] + slope * (poi_alts[:, None] - altitudes[:, 0, None])
else:
vandermonde = _build_vandermonde(alt_norm, deg)
vw, yw = _apply_weights(vandermonde, values, w)
coeffs, rank_deficient = _qr_polyfit_batch(vw, yw)
poi_norm = (poi_alts - alt_mean[:, 0]) / alt_std[:, 0]
fitted_val = _eval_poly_batch(coeffs, poi_norm, deg).reshape(n_sub, *trail_shape)
# overwrite rank-deficient results with a deg=1 fallback. If deg is already 1, raise an error instead.
bad_idx = np.nonzero(rank_deficient)[0]
if bad_idx.size > 0:
if deg == 1:
raise ValueError(
f"deg=1 sounding is rank-deficient for {bad_idx.size} POI(s): "
"all k neighbor altitudes are identical. Check the surface altitude coordinate."
)
fitted_val[bad_idx] = _sounding_batch(
data_array[bad_idx],
altitudes[bad_idx],
poi_alts[bad_idx],
deg=1,
w=w[bad_idx] if w is not None else None,
grad_only=False,
)
return fitted_val.reshape((n_sub,) + trail_shape)
[docs]
def interp_temp_fixgrad(
coords_flat: np.ndarray,
data_flat: np.ndarray,
pois_coords: np.ndarray,
r_search: float = 2,
z_weight: int = 200,
grad: float = 0.006,
) -> np.ndarray:
"""
Interpolation from grid to POIs for temperature data by taking the
surrounding gridpoint with the most similar altitude and further correct
for altitude difference with a fixed gradient.
Parameters
----------
coords_flat : numpy.ndarray, shape (n_grids, m)
array of flattened grid coordinates. n_grids is the number of model
grid points, m the number of coordinates dimensions (i.e. 3)
data_flat : numpy.ndarray, shape (n_grids, ...)
array containing the flattened model data with various trailing
dimensions
pois_coords : numpy.ndarray , shape (n_pois, m)
array of POI coordinates. n_pois is the number of POIs, m the number of
coordinates dimensions (i.e. 3)
r_search : positive float, optional
radius in model gridpoints that define the number of neighbors included
in the search. Default is 4
z_weight : positive int, optional
weight for the z axis in the distance computation of the "most adequat"
gridpoint. ``d = horizontal_dist + z_weight * vertical_dist``.
Default value is 200
grad : positive float
gradient in [K / m] to correct for altitude difference between POI and
nearest grid point. Default is 0.006 [K / m]
Returns
-------
poi_data : numpy.ndarray, shape (n_pois, ...)
array containing the interpolated values and the same trailing
dimensions as the input ``data_flat`` array.
See also
--------
:func:`interp2poi`
"""
ind = _grid_assignation(coords_flat, pois_coords, r_search, z_weight)
return _fixgrad(
data_flat[ind,],
coords_flat[ind, -1],
pois_coords[:, -1],
grad,
)
def _fixgrad(
best_gp_data: np.ndarray,
best_gp_alt: np.ndarray,
pois_alt: np.ndarray,
grad: float = 0.006,
) -> np.ndarray:
"""
Correct temperature based on altitudinal differences and a fix gradient.
Parameters
----------
best_gp_data : numpy.ndarray, shape (n_pois, ...)
contains the temperature values of the most appropriate gridpoint for
each POI
best_gp_alt : numpy.ndarray, shape (n_pois,)
contains the altitude of the most appropriate gridpoint for each POI.
pois_alt : numpy.ndarray, shape (n_pois,)
contains the altitude of each POI
grad : positive float
gradient in [K / m] to correct for altitude difference between POI and
nearest grid point. Default is 0.006 [K / m]
Returns
-------
poi_data : numpy.ndarray, shape (n_pois, ...)
array of altitude corrected temperatures
See also
--------
:func:`interp_temp_fixgrad`
"""
ndim_trail = best_gp_data.ndim - 1
delta_t = (best_gp_alt - pois_alt) * grad
delta_t = delta_t.astype(best_gp_data.dtype, copy=False)
return best_gp_data + delta_t.reshape(delta_t.shape + (1,) * ndim_trail)
[docs]
def interp_nearest_alt(
coords_flat: np.ndarray,
data_flat: np.ndarray,
pois_coords: np.ndarray,
r_search: float = 2,
z_weight: int = 500,
) -> np.ndarray:
"""
Interpolation from grid to POIs for elevation dependent data. The method finds
the nearest grid point according to a distance that use a much larger weight
along the z axis. For wind data for instance, grid points at similar altitudes
are preferred. The grid does not need to be regular.
Parameters
----------
coords_flat : numpy.ndarray, shape (n_grids, m)
array of flattened grid coordinates. n_grids is the number of model
grid points, m the number of coordinates dimensions (i.e. 3)
data_flat : numpy.ndarray, shape (n_grids, ...)
array containing the flattened model data with various trailing
dimensions
pois_coords : numpy.ndarray , shape (n_pois, m)
array of POI coordinates. n_pois is the number of POIs, m the number of
coordinates dimensions (i.e. 3)
r_search : positive float, optional
radius in model gridpoints that define the number of neighbors included
in the search. Default is 4
z_weight : positive int, optional
weight for the z axis in the distance computation of the "most adequat"
gridpoint. ``d = horizontal_dist + z_weight * vertical_dist``.
Default value is 500
Returns
-------
poi_data : numpy.ndarray, shape (n_pois, ...)
array containing the interpolated values and the same trailing
dimensions as the input ``data_flat`` array
See also
--------
:func:`interp2poi`
"""
ind = _grid_assignation(coords_flat, pois_coords, r_search, z_weight)
return data_flat[ind,]
def _grid_assignation(
coords_flat: np.ndarray,
pois_coords: np.ndarray,
r_search: float,
z_weight: int,
deltah_min: int = 50,
) -> np.ndarray:
"""
Returns the index of the "most appropriate" gridpoint based on a distance
metric that gives a much greater weight to vertical distances. This is the
standard used for verification in COSMO.
"""
k = round(np.pi * r_search**2)
# find indexes and distances of nearest neighbor grids for all POIs
dist_nn_horz, ind_nn = nn_lookup_cached(coords_flat[:, :2], pois_coords[:, :2], k)
dist_nn_vert = np.abs(coords_flat[ind_nn, -1] - pois_coords[:, [-1]])
dist_weighted = dist_nn_horz + z_weight * (dist_nn_vert - deltah_min).clip(0)
return ind_nn[np.arange(ind_nn.shape[0]), np.argmin(dist_weighted, axis=1)]
[docs]
def interp_idw(
coords_flat: np.ndarray,
data_flat: np.ndarray,
pois_coords: np.ndarray,
k: int = 4,
weight_power: float = 1.0,
) -> np.ndarray:
"""
Performs interpolations from grid to POIs using inverse distance
weightning.
Parameters
----------
coords_flat : numpy.ndarray, shape (n_grids,m)
array of flattened grid coordinates. n_grids is the number of model
grid points, m the number of coordinates dimensions (i.e. 2 or 3)
data_flat : numpy.ndarray, shape (n_grids, ...)
array containing the flattened model data with various trailing
dimensions
pois_coords : numpy.ndarray , shape (n_pois, m)
array of POI coordinates. n_pois is the number of POIs, m the number of
coordinates dimensions (i.e. 2 or 3)
k : positive int, optional
number of considered nearest neighbors. Default is 4
weight_power : positive float, optional
the interpolated value is the weighted average of the k nearest grid
points accordi g to ``w = d^(-weight_power)``, with d being the
distance between the considered POI and the gird points. The greater is
the ``weight_power``, the stronger is the penalization with distance.
A ``weight_power`` of 0 produces a normal average of the k nearest grid
points
Returns
-------
poi_data : numpy.ndarray, shape (n_pois, ...)
array containing the interpolated values and the same trailing
dimensions as the input ``data_flat`` array
See also
--------
:func:`interp2poi`
"""
# find indexes and distances of the k nearest neighbor grids for all POIS
dist_nn, ind_nn = nn_lookup_cached(coords_flat, pois_coords, k)
# compute the weights
dist_weight = _compute_dist_weight(dist_nn, weight_power, data_flat.dtype)
# vectorized weigthed average
ndim_trail = data_flat.ndim - 1
poi_data = np.sum(
data_flat[ind_nn,] * dist_weight.reshape(dist_weight.shape + (1,) * ndim_trail),
axis=1,
)
return poi_data
[docs]
def interp_upscaled(
coords_flat: np.ndarray,
data_flat: np.ndarray,
pois_coords: np.ndarray,
k: int = 4,
agg_func: Callable = np.max,
) -> np.ndarray:
"""
Perform interpolations from grid to POIs by computing a group statistic
over the k nearest neighbors (e.g. upscaling).
Parameters
----------
coords_flat : numpy.ndarray, shape (n_grids, m)
array of flattened grid coordinates. n_grids is the number of model
grid points, m the number of coordinates dimensions (i.e. 2 or 3)
data_flat : numpy.ndarray, shape (n_grids, ...)
array containing the flattened model data with various trailing
dimensions
pois_coords : numpy.ndarray , shape (n_pois, m)
array of POI coordinates. n_pois is the number of POIs, m the number of
coordinates dimensions (i.e. 2 or 3).
k : positive int, optional
number of considered nearest neighbors. Default is 4
agg_func : callable, optional
specify the spatial aggregation function to use (i.e. numpy.mean,
numpy.max, numpy.min, numpy.std). Default is numpy.max.
Returns
-------
poi_data : numpy.ndarray, shape (n_pois, ...)
array containing the interpolated values and the same trailing
dimensions as the input ``data_flat`` array
See also
--------
:func:`interp2poi`
"""
# find indexes and distances of the k nearest neighbor grids for all POIS
_, ind_nn = nn_lookup_cached(coords_flat, pois_coords, k)
# group aggregation
return agg_func(
data_flat[ind_nn,],
axis=1,
)
[docs]
def interp_nearest(coords_flat: np.ndarray, data_flat: np.ndarray, pois_coords: np.ndarray) -> np.ndarray:
"""
Performs nearest neighbor interpolation from grid to POIs.
Parameters
----------
coords_flat : numpy.ndarray, shape (n_grids, m)
array of flattened grid coordinates. n_grids is the number of model
grid points, m the number of coordinates dimensions (i.e. 2 or 3).
data_flat : numpy.ndarray, shape (n_grids, ...)
array containing the flattened model data with various trailing
dimensions
pois_coords : numpy.ndarray , shape (n_pois, m)
array of POI coordinates. n_pois is the number of POIs, m the number of
coordinates dimensions (i.e. 2 or 3).
Returns
-------
poi_data : numpy.ndarray, shape (n_pois, ...)
array containing the interpolated values and the same trailing
dimensions as the input ``data_flat`` array
See also
--------
:func:`interp2poi`
"""
# find indexes and distances of the k nearest neighbor grids for all POIS
_, ind_nn = nn_lookup_cached(coords_flat, pois_coords, 1)
return data_flat[ind_nn]
[docs]
def interp_snowlim(
coords_flat: np.ndarray,
data_flat: np.ndarray,
pois_coords: np.ndarray,
k: int = 36,
d_max: float = 40000,
) -> np.ndarray:
"""
Special interpolation routine for snow limit. Snow limits below model
topography are undefined, resulting in datasets with randomly distributed
NaNs. This routine checks for the nearest non-NaN grid points. If this
point is further away than d_max, the snowlimit is set to a value between
0 and 200 [m] depending on the POI altitude. Otherwise, the minimum between
the nearest non-NaN grid point and the minimum altitude of NaN grid points
among the k nearest neighbors is taken.
Parameters
----------
coords_flat : numpy.ndarray, shape (n_grids, m)
array of flattened grid coordinates. n_grids is the number of model
grid points, m the number of coordinates dimensions (i.e. 3)
data_flat : numpy.ndarray, shape (n_grids, ...)
Array containing the flattened model data with various trailing
dimension.
pois_coords : numpy.ndarray , shape (n_pois, m)
array of POI coordinates. n_pois is the number of POIs, m the number of
coordinates dimensions (i.e. 3)
k : positive int
Number of considered nearest neighbors to check for altitude of NaN
grid points.
d_max : positive float
if all grid points within a radius of d_max meter from the POI are NaNs
the snow limit is set to a value between 0 and 200 [m] depending on
the POI altitude
Returns
-------
poi_data : numpy.ndarray, shape (n_pois, ...)
array containing the interpolated values and the same trailing
dimensions as the input ``data_flat`` array
See also
--------
:func:`interp2poi`
"""
data_shape = data_flat.shape
n_pois = len(pois_coords)
poi_dummy = _fillna_alt( # NaNs filled with model topo
coords_flat, data_flat, pois_coords, min(data_shape[0], k)
)
data_flat = data_flat.reshape((data_shape[0], -1), order="C")
# array with default values when there is only NaNs within distance d_max
poi_data = (pois_coords[:, 2] - 200).clip(0, 200).astype(data_flat.dtype, copy=False)
poi_data -= poi_data % 50 # floor to multiple of 50
poi_data = np.broadcast_to(poi_data[:, None], (n_pois, data_flat.shape[-1])).copy()
for idx, array in enumerate(data_flat.T):
ind = ~np.isnan(array)
if ind.any():
dist_nn, ind_nn = nn_lookup(coords_flat[ind, :2], pois_coords[:, :2], 1)
bool_dist = dist_nn < d_max
poi_data[bool_dist, idx] = array[ind][ind_nn[bool_dist]]
poi_data = poi_data.reshape((n_pois,) + data_shape[1:], order="C")
return np.minimum(poi_data, poi_dummy)
def _fillna_alt(coords_flat: np.ndarray, data_flat: np.ndarray, pois_coords: np.ndarray, k: int) -> np.ndarray:
"""
Interp to POIs with nearest neighbor and fill NaNs by simply taking the
altitude of the lowest grid point among the k nearest neighbors that have
an undefined snow limit.
Parameters
----------
coords_flat : numpy.ndarray, shape (n_grids, m)
array of flattened grid coordinates. n_grids is the number of model
grid points, m the number of coordinates dimensions (i.e. 3)
data_flat : numpy.ndarray, shape (n_grids, ...)
array containing the flattened model data with various trailing
dimensions
pois_coords : numpy.ndarray , shape (n_pois, m)
array of POI coordinates. n_pois is the number of POIs, m the number of
coordinates dimensions (i.e. 3)
k : positive int
number of considered nearest neighbors to check for altitude of NaN
Returns
-------
data_poi : numpy.ndarray, shape (n_pois, ...)
array containing the interpolated values/model topography with same
trailing dimensions as the input ``data_flat`` array.
See also
-------
:func:`interp_snowlim`
"""
ndim_trail = data_flat.ndim - 1
_, ind_nn = nn_lookup(coords_flat[:, :2], pois_coords[:, :2], k)
data_poi = data_flat[ind_nn]
ind_na = np.isnan(data_poi)
alt_poi = coords_flat[ind_nn, -1].reshape(ind_nn.shape + (1,) * ndim_trail)
alt_poi = np.broadcast_to(alt_poi, data_poi.shape)
alt_poi = np.amin(alt_poi, axis=1, initial=10000, where=ind_na)
data_poi = data_poi[:, 0]
data_poi[ind_na[:, 0]] = alt_poi[ind_na[:, 0]]
return data_poi
[docs]
@dataset_accessor
def interp2poi(
data: xr.Dataset,
poi_info: gpd.GeoDataFrame,
method: InterpMethods,
try_reg: bool = True,
**kwargs: Any,
) -> xr.Dataset:
"""
Interpolates gridded spatial data to a set of Points of Interest (POIs).
This function provides a unified interface for interpolating values from regular or unstructured spatial grids
to arbitrary POI locations, supporting a variety of interpolation methods and data models. It replaces the
original spatial grid dimensions in `data` with a POI dimension, containing interpolated values for each POI.
Parameters
----------
data : xarray.Dataset
The source grid dataset. Supported data models include:
- CF-convention grids (see https://cfconventions.org/)
- internal MCH models
- Unstructured grids
Typical spatial dimension and coordinate names are supported, and can be extended via environment variables:
- Y coordinate: export GRID_PROCESS__data_model__grid__y (e.g., '["y", "latitude", "lat", "dummy_y"]')
- X coordinate: export GRID_PROCESS__data_model__grid__x (e.g., '["x", "longitude", "lon", "dummy_x"]')
- Unstructured dimension: export GRID_PROCESS__data_model__grid__unstructured_dim (e.g., '["dummy_cell"]')
- When the spatial coordinates variables have a CF compliant `standard_name` attribute, arbitrary names can be
used directly.
Spatial CRS must be referenced via one of:
- Global attribute 'crs' (e.g., "EPSG:2056" or anything accepted by :meth:`pyproj.CRS.from_user_input`)
- Global attribute 'grid_mapping' (internal grid database convention)
- CF-convention grid mapping variable
- If coordinates are named 'latitude'/'longitude' or 'lat'/'lon', WGS84 (EPSG:4326) is assumed.
For methods requiring altitude, ensure a surface altitude coordinate is present; extend supported names via
GRID_PROCESS__data_model__grid__surf_alt.
poi_info : geopandas.GeoDataFrame
POI locations and attributes. Must have geometry and a defined CRS.
For methods requiring altitude, a POI altitude column or z-component in geometry (in units of meters) is needed.
Extend supported POI altitude column names via:
- export GRID_PROCESS__data_model__poi__alt_from (e.g., '["height_masl", "altitude", "dummy_height"]')
The column `name` is used for the output POI coordinate by default; override via:
- export GRID_PROCESS__data_model__poi__coord_from (e.g., dummy_name)
method : str
Interpolation method. Supported options include:
{'nearest', 'linear', 'cubic', 'idw', 'temp', 'temp_fixgrad', 'nearest_alt', 'snowlim',
'nearest_3D', 'linear_3D', 'idw_3D', 'upscaled', 'upscaled_3D'}.
3D methods include altitude in neighbor selection. For unstructured grids, prefer 'idw' with k=4 and
weight_power=2 over 'linear' for performence reasons.
try_reg : bool, default True
If True, attempts regular grid interpolation for structured grids with 1D coordinates when ``method`` is
'nearest' or 'linear'. Regular grid interpolation is typically more efficient for large grids, but may be less
so for small grids with many trailing dimensions.
**kwargs : Any
Additional arguments passed to the specific interpolation sub-function, named `interp_<method>`.
Returns
-------
xr.Dataset
Dataset where original spatial dimensions are replaced with a POI dimension containing interpolated values.
The output POI dimension name can be overridden via GRID_PROCESS__data_model__poi__dim (e.g. station).
See also
--------
:func:`interp_temp`, :func:`interp_temp_fixgrad`, :func:`interp_nearest_alt`,
:func:`interp_idw`, :func:`interp_upscaled`, :func:`interp_nearest`,
:func:`interp_snowlim`, :func:`scipy.interpolate.griddata`, :meth:`xarray.Dataset.interp`
Notes
-----
- Grid points containing only NaNs across all dimensions are excluded from interpolation, enabling easy masking
(e.g., excluding water pixels with `ds.where(ds.fr_land > 0.5).interp2poi(..., try_reg=False)`).
- For regular grid interpolation, all NaN grid points are kept.
- Environment variables allow flexible support for custom grid and POI data models.
"""
def _fullnan_ds(data: xr.DataArray) -> xr.Dataset:
"""
Returns a dataset full of NaNs with correct dimensions
"""
da = (
data.isel({dim: 0 for dim in spatial_dims}, drop=True)
.expand_dims({POI_DIM: poi_info[POI_COORD_COL].to_numpy(dtype=str)})
.copy()
)
da.attrs = _add_attrs(data.attrs)
da[:] = np.nan
return da_to_ds(da, vars_attrs)
def _array2ds(data: xr.DataArray) -> xr.Dataset:
"""
Converts an array with interpolated values into a dataset with correct
dimensions, coordinates and attributes
"""
da = xr.DataArray(
poi_data,
dims=(POI_DIM,) + data.dims[len(spatial_dims) :],
coords={
POI_DIM: poi_info[POI_COORD_COL].to_numpy(dtype=str),
**data.isel({dim: 0 for dim in spatial_dims}, drop=True).coords,
},
attrs=_add_attrs(data.attrs),
)
return da_to_ds(da, vars_attrs)
spatial_dims = dm.get_spatial_dims(data)
data = dm.set_grid_mapping(data)
cond_reg = method in ("nearest", "linear") and len(spatial_dims) > 1 and set(spatial_dims).issubset(data.coords)
if try_reg and cond_reg:
return _interp_regular(data, spatial_dims, poi_info, method) # type: ignore[arg-type]
data = slice_boundingbox(data, poi_info, method, spatial_dims, **kwargs)
data, vars_attrs = ds_to_da(data) # type: ignore[assignment]
data = data.transpose(*spatial_dims, ...)
coords_flat, data_flat, pois_coords = _flatten_concat(
data, # type: ignore[arg-type]
spatial_dims,
poi_info,
method,
)
if len(coords_flat) == 0:
return _fullnan_ds(data) # type: ignore[arg-type]
if method in INTERP_FUNC:
poi_data = INTERP_FUNC[method](coords_flat, data_flat, pois_coords, **kwargs) # type: ignore[operator]
elif method in ("linear", "linear_3D", "cubic"):
poi_data = griddata(coords_flat, data_flat, pois_coords, method.rsplit("_")[0])
poi_data = poi_data.astype(data_flat.dtype, copy=False)
else:
raise ValueError(f"Unknown interpolation method {method!r}")
return _array2ds(data) # type: ignore[arg-type]
def _interp_regular( # pylint: disable=too-many-locals
data: xr.Dataset,
spatial_dims: tuple[str, str],
poi_info: gpd.GeoDataFrame,
method: Literal["nearest", "linear"] = "linear",
) -> xr.Dataset:
"""
Wrapper for interpolation from a regular grid to POIs.
Handle reprojection of the points to the native crs of the input grid.
Also assigns coordinate labels to the output poi dimension according to the
POI_COORD_COL column of ``poi_info``. Used in :func:`interp2poi`
"""
def _interp_cast_dtype(
method: Literal["nearest", "linear"],
) -> xr.Dataset:
"""
Wrapper for xarray.Dataset.interp to return data in the same dtype as
input.
"""
poi_data = data.interp({x_dim: pois_coords[0], y_dim: pois_coords[1]}, method, assume_sorted=True).drop_vars(
spatial_dims, errors="ignore"
)
cast_dtype(poi_data, data)
return poi_data
data = _drop_coords_reg(data, spatial_dims)
y_dim, x_dim = spatial_dims
data = _y_increasing(data, y_dim)
# Reproject POIs coordinates in the grid CRS
pois_coords = _convert_poi(poi_info, dm.get_grid_crs(data), data[y_dim].dtype)
poi_dim_coord = poi_info[POI_COORD_COL].to_numpy(dtype=str)
pois_coords = (
xr.DataArray(pois_coords[0], coords=[(POI_DIM, poi_dim_coord)]),
xr.DataArray(pois_coords[1], coords=[(POI_DIM, poi_dim_coord)]),
)
if method == "nearest":
dx, dy = np.diff(data[x_dim].values), np.diff(data[y_dim].values)
cst_spacing = isclose(dx.min(), dx.max(), rel_tol=1e-4) and isclose(dy.min(), dy.max(), rel_tol=1e-4)
dtype_bool = not all(data[var].dtype in (np.float32, np.float64) for var in data)
chunk_bool = len(data.chunks) > 0
if cst_spacing and (chunk_bool or dtype_bool):
grid_spacing = float(abs(dx[0])), float(abs(dy[0]))
poi_data = _interp_nearest_regular(data, spatial_dims, grid_spacing, pois_coords)
else:
poi_data = _interp_cast_dtype("nearest")
else:
poi_data = _interp_cast_dtype(method)
poi_data.attrs = _add_attrs(data.attrs)
return poi_data.transpose(POI_DIM, ...)
def _y_increasing(ds: xr.Dataset, y_dim: str) -> xr.Dataset:
"""
Reverse along the y dimension if coordinates are decreasing instead of increasing.
This allows to use ``assume_sorted=True`` in :meth:`xarray.Dataset.interp` which is significantly faster.
"""
if dm.is_decreasing1d(ds[y_dim]):
ds = ds.isel({y_dim: slice(None, None, -1)})
return ds
def _drop_coords_reg(data: xr.Dataset, spatial_dims: tuple[str, str]) -> xr.Dataset:
"""
Drop non-dimensional coordinate variables that have spatial dimensions.
"""
notdim_coords = set(data.coords) - set(data.dims)
spd = set(spatial_dims)
to_drop = [coord for coord in notdim_coords if spd.issubset(data[coord].dims)]
return data.drop_vars(to_drop)
def _interp_nearest_regular(
data: xr.Dataset,
spatial_dims: tuple[str, str],
grid_spacing: tuple[float, float],
pois_coords: tuple[xr.DataArray, xr.DataArray],
) -> xr.Dataset:
"""
Perform fast nearest neighbor interpolation from a regular grid with
constant spacing along each dimension to POIs. Preserve dtype
so the memory footprint is limited compared to xarray.Dataset.interp which
cast to numpy.float64.
Note
----
If the spacing along a dimension is not constant, results may be wrong.
"""
y_dim, x_dim = spatial_dims
data = data.sel(
{
x_dim: slice(
pois_coords[0].min() - grid_spacing[0],
pois_coords[0].max() + grid_spacing[0],
),
y_dim: slice(
pois_coords[1].min() - grid_spacing[1],
pois_coords[1].max() + grid_spacing[1],
),
}
)
data = data.chunk() # cast to dask
grid_coords = (data[x_dim].values, data[y_dim].values)
hkey = (
tuple(pois_coords[0].poi.values.tolist()),
pois_coords[0].values.tobytes(),
pois_coords[1].values.tobytes(),
grid_coords[0].tobytes(),
grid_coords[1].tobytes(),
)
# pylint: disable-next=unbalanced-tuple-unpacking # returns one index array per spatial dimension
indx, indy = nn_lookup_regular(grid_coords, grid_spacing, pois_coords, hkey=hkey) # type: ignore[call-arg]
return data.isel({x_dim: indx, y_dim: indy}).drop_vars(spatial_dims, errors="ignore")
[docs]
@memoize(CONFIG.cache_size.nn_lookup)
def nn_lookup(grid_coords: np.ndarray, points: np.ndarray, k: int) -> tuple[np.ndarray, np.ndarray]:
"""
Find the k nearest grid points and corresponding distances for a set of
POIs.
Parameters
----------
grid_coords : numpy.ndarray, shape (n, m)
array of flattened grid coordinates. N is the number of model grid
points, m the number of coordinates dimensions (i.e. 2 or 3)
points : numpy.ndarray, shape (p, m)
array of POIs coordinates. P is the number of POIs, m the number of
coordinates dimensions (i.e. 2 or 3)
k : positive int
number of considered nearest neighbors
Returns
-------
dd : numpy.ndarray, shape (p, k)
array of nearest neighbor distances.
ii : numpy.ndarray, shape (p, k)
array of nearest neighbor grid indexes
See also
--------
:func:`nn_lookup_cached`
"""
tree = KDTree(grid_coords)
dd, ii = tree.query(points, k, workers=-1)
return dd.astype(points.dtype, copy=False), ii
[docs]
def nn_lookup_cached(grid_coords: np.ndarray, points: np.ndarray, k: int) -> tuple[np.ndarray, np.ndarray]:
"""
Wrapper to :func:`nn_lookup` function to use it with a cache
"""
hkey = (grid_coords.tobytes(), points.tobytes(), k)
return nn_lookup(grid_coords, points, k, hkey=hkey) # type: ignore[call-arg]
[docs]
@memoize(CONFIG.cache_size.nn_lookup_regular)
def nn_lookup_regular(
grid_coords: tuple[np.ndarray, np.ndarray],
grid_spacing: tuple[float, float],
points: tuple[np.ndarray | xr.DataArray, np.ndarray | xr.DataArray],
) -> list[np.ndarray | xr.DataArray]:
"""
Find the nearest grid point for a set of POIs when the source
grid is regular and the spacing constant along each dimension.
Parameters
----------
grid_coords : tuple of m 1-D arrays
contains flattened grid coordinates arrays for all m dimensions
grid_spacing : tuple of m floats
contains the grid spacing for all m dimensions
points : tuple of m 1-D arrays (or xarray.DataArray with dim poi)
contains POIs coordinates for all m dimensions. It assumes the same
order of dimensions as in ``grid_coords``
Returns
-------
index : list of m 1-D arrays (or xarray DataArray with dimension `poi`)
Used to index the dataset in each spatial dimension. The output indexes
have length equals to the number of points
"""
if len(points) != len(grid_coords):
raise ValueError("The number of grid and points dimensions does not match!")
grid_index = []
for n, dim_coords in enumerate(grid_coords):
dim_spacing = grid_spacing[n]
dim_origin = np.min(dim_coords) - dim_spacing / 2
dim_index = (points[n] - dim_origin) // dim_spacing
dim_index = np.clip(dim_index, 0, dim_coords.size - 1).astype(int)
grid_index.append(dim_index)
return grid_index
def _compute_dist_weight(dist: np.ndarray, power: float, dtype: np.dtype) -> np.ndarray:
"""
Compute weights based on the inverse distance. The weights scales to 1.
"""
increm = np.mean(dist, axis=1, keepdims=True) / 50 # avoid division by 0
w = 1 / np.power(dist + increm, power)
w = w.astype(dtype, copy=False)
return w / np.sum(w, axis=1, keepdims=True)
def _add_attrs(data_attrs: dict) -> dict:
"""
Add attrs from source grid
"""
return {key: data_attrs[key] for key in SRC_ATTRS_KEEP if key in data_attrs}
def _flatten_concat(
data: xr.DataArray,
spatial_dims: tuple[str, ...],
poi_info: gpd.GeoDataFrame,
method: str,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""
Flattens the grid and concatenate columnwise `x` and `y` coordinates into a
single 2D array. Also, reprojects the POIs coordinates if they are not in
the same CRS than the model coordinates
"""
data, grid_y, grid_x, grid_crs = _flatten_coords_crs(data, spatial_dims)
if method in HEIGHT_METHODS:
# include elevation
surf_alt = dm.get_surf_alt(data)
coords_flat = np.c_[
data[grid_x].values,
data[grid_y].values,
data[surf_alt].values,
]
if grid_crs not in SWISS_CRS:
# project to CH1903+/LV95 to have units in [m]
new_crs = CRS.from_string(SWISS_CRS[0])
coords_flat[:, 0], coords_flat[:, 1] = _reproject_grid_cached(
coords_flat[:, 0], coords_flat[:, 1], grid_crs, new_crs
)
grid_crs = new_crs
else:
coords_flat = np.c_[data[grid_x].values, data[grid_y].values]
# Reproject POIs coordinates in the grid CRS
pois_coords = _convert_poi(poi_info, grid_crs, coords_flat.dtype)
if method in HEIGHT_METHODS:
pois_coords_cat = np.c_[
pois_coords[0],
pois_coords[1],
dm.get_poi_alt(poi_info),
]
else:
pois_coords_cat = np.c_[pois_coords[0], pois_coords[1]]
if method == "snowlim":
return coords_flat, data.values, pois_coords_cat
# check for all NaN grid points
ind = ~data.isnull().all(data.dims[1:]).values
return coords_flat[ind], data.values[ind], pois_coords_cat
def _convert_poi(poi_info: gpd.GeoDataFrame, grid_crs: CRS, dtype: np.dtype) -> tuple[np.ndarray, np.ndarray]:
"""
Cached reprojection of POIs coordinates in the grid CRS. Returns a tuple of xy
coordinates with the specified dtype.
"""
pois_coords = poi_info.get_coordinates().values
pois_coords = pois_coords.astype(dtype, copy=False)
poi_crs = poi_info.crs.to_2d()
if grid_crs == poi_crs:
return pois_coords[:, 0], pois_coords[:, 1]
hkey = (pois_coords[:, 0].tobytes(), pois_coords[:, 1].tobytes(), grid_crs)
return _reproject_poi(pois_coords, poi_crs, grid_crs, hkey=hkey) # type: ignore[call-arg]
@memoize(CONFIG.cache_size.reproject_poi)
def _reproject_poi(coords: np.ndarray, src_crs: CRS, dst_crs: CRS) -> tuple[np.ndarray, np.ndarray]:
"""
Reprojection of POIs coordinates that can be cached
"""
x_coords, y_coords = reproject(coords[:, 0], coords[:, 1], src_crs, dst_crs)
dtype = coords.dtype
x_coords = x_coords.astype(dtype, copy=False)
y_coords = y_coords.astype(dtype, copy=False)
return x_coords, y_coords
@memoize(CONFIG.cache_size.reproject_grid)
def _reproject_grid(
x_coords: np.ndarray, y_coords: np.ndarray, src_crs: CRS, dst_crs: CRS
) -> tuple[np.ndarray, np.ndarray]:
"""
Reprojection of grid coordinates that can be cached
"""
return reproject(x_coords, y_coords, src_crs, dst_crs)
def _reproject_grid_cached(
x_coords: np.ndarray, y_coords: np.ndarray, src_crs: CRS, dst_crs: CRS
) -> tuple[np.ndarray, np.ndarray]:
"""
Cached reprojection of grid coordinates
"""
if dst_crs != src_crs:
hkey = (x_coords.tobytes(), y_coords.tobytes(), x_coords.shape, dst_crs)
x_coords, y_coords = _reproject_grid( # type: ignore[call-arg]
x_coords, y_coords, src_crs, dst_crs, hkey=hkey
)
return x_coords.astype(np.float64, copy=False), y_coords.astype(np.float64, copy=False)
[docs]
@dataset_accessor
def interp2grid(
data: xr.Dataset,
dst_grid: xr.Dataset,
method: InterpMethods,
try_reg: bool = True,
**kwargs: Any,
) -> xr.Dataset:
"""
Interpolates data from a source grid to a destination grid, supporting regular and unstructured grids.
This function provides a unified interface for grid-to-grid interpolation, supporting a variety of interpolation
methods and data models. The output dataset contains the data of the source grid mapped onto the destination grid
coordinates.
Parameters
----------
data : xarray.Dataset
The source grid dataset. Supported data models include:
- CF-convention grids (see https://cfconventions.org/)
- internal MCH models
- Unstructured grids
Typical spatial dimension and coordinate names are supported, and can be extended via environment variables:
- Y coordinate: export GRID_PROCESS__data_model__grid__y (e.g., '["y", "latitude", "lat", "dummy_y"]')
- X coordinate: export GRID_PROCESS__data_model__grid__x (e.g., '["x", "longitude", "lon", "dummy_x"]')
- Unstructured dimension: export GRID_PROCESS__data_model__grid__unstructured_dim (e.g., '["dummy_cell"]')
- When the spatial coordinates variables have a CF compliant `standard_name` attribute, arbitrary names can be
used directly.
Spatial CRS must be referenced via one of:
- Global attribute 'crs' (e.g., "EPSG:2056" or anything accepted by :meth:`pyproj.CRS.from_user_input`)
- Global attribute 'grid_mapping' (internal grid database convention)
- CF-convention grid mapping variable
- If coordinates are named 'latitude'/'longitude' or 'lat'/'lon', WGS84 (EPSG:4326) is assumed.
For methods requiring altitude, ensure a surface altitude coordinate is present; extend supported names via
GRID_PROCESS__data_model__grid__surf_alt.
dst_grid : xarray.Dataset
The destination grid to interpolate to. Same supported data models as the source grid, including conventions for
dimensions and coordinates as well as spatial CRS.
For methods requiring altitude, ensure a surface altitude coordinate is also present.
The destination dataset may contain only spatial coordinates or also data variables which will be ignored.
method : str
Interpolation method. Supported options include:
{'nearest', 'linear', 'cubic', 'idw', 'temp', 'temp_fixgrad', 'nearest_alt', 'snowlim',
'nearest_3D', 'linear_3D', 'idw_3D', 'upscaled', 'upscaled_3D'}.
3D methods include altitude in neighbor selection. For unstructured grids, prefer 'idw' with k=4 and
weight_power=2 over 'linear' for performence reasons.
try_reg : bool, default True
If True, attempts regular grid interpolation for structured source grids with 1D coordinates when ``method`` is
'nearest' or 'linear'. Regular grid interpolation is typically more efficient for large grids, but may be less
so for small grids with many trailing dimensions.
**kwargs : Any
Additional arguments passed to the specific interpolation sub-function, named `interp_<method>`.
Returns
-------
xr.Dataset
Dataset with values from `data` interpolated onto the coordinates of `dst_grid`.
See also
--------
:func:`interp_temp`, :func:`interp_temp_fixgrad`, :func:`interp_nearest_alt`,
:func:`interp_idw`, :func:`interp_upscaled`, :func:`interp_nearest`,
:func:`interp_snowlim`, :func:`scipy.interpolate.griddata`, :meth:`xarray.Dataset.interp`
Notes
-----
- Not all interpolation methods scale efficiently with large input datasets or and/or destination grids.
- Recommended methods: 'nearest', 'linear' (if the source grid is regular with 1D coordinates), 'idw' (with k=4),
'temp_fixgrad', 'nearest_3D', 'nearest_alt'.
- Environment variables allow flexible support for custom grid data models.
"""
def _array2ds(data: xr.DataArray) -> xr.Dataset:
"""
Converts an array with interpolated values into a dataset with correct
dimensions, coordinates and attributes
"""
da = xr.DataArray(
interp_data.reshape(
(
dst_grid.sizes[dst_spatial_dims[0]],
dst_grid.sizes[dst_spatial_dims[1]],
)
+ data.shape[len(src_spatial_dims) :]
),
dims=dst_spatial_dims + data.dims[len(src_spatial_dims) :],
coords={
**data.isel({dim: 0 for dim in src_spatial_dims}, drop=True).coords,
**dst_grid.coords,
},
attrs=_combine_attrs(data.attrs, dst_grid.attrs),
)
return da_to_ds(da, vars_attrs)
src_spatial_dims = dm.get_spatial_dims(data)
dst_spatial_dims = dm.get_spatial_dims(dst_grid)
data = dm.set_grid_mapping(data)
dst_grid = dm.set_grid_mapping(dst_grid)
dst_grid = dst_grid.drop_dims(set(dst_grid.dims) - set(dst_spatial_dims))
cond_reg = (
method in ("nearest", "linear") and len(src_spatial_dims) > 1 and set(src_spatial_dims).issubset(data.coords)
)
if try_reg and cond_reg:
return _interp_grid_regular(
data,
src_spatial_dims, # type: ignore[arg-type]
dst_grid,
dst_spatial_dims, # type: ignore[arg-type]
method, # type: ignore[arg-type]
)
dst_grid = dst_grid.transpose(*dst_spatial_dims)
data = slice_coarsen(data, dst_grid, method, src_spatial_dims, dst_spatial_dims, **kwargs)
data, vars_attrs = ds_to_da(data) # type: ignore[assignment]
data = data.transpose(*src_spatial_dims, ...)
coords_flat, data_flat, dst_coords_flat = _flatten_concat_grids(
data, # type: ignore[arg-type]
src_spatial_dims,
dst_grid,
dst_spatial_dims, # type: ignore[arg-type]
method,
)
if method in INTERP_FUNC:
interp_data = INTERP_FUNC[method]( # type: ignore[operator]
coords_flat, data_flat, dst_coords_flat, **kwargs
)
elif method in ("linear", "linear_3D", "cubic"):
interp_data = griddata(coords_flat, data_flat, dst_coords_flat, method.rsplit("_")[0])
interp_data = interp_data.astype(data_flat.dtype, copy=False)
else:
raise ValueError(f"Unknown interpolation method {method!r}")
return _array2ds(data) # type: ignore[arg-type]
def _interp_grid_regular(
data: xr.Dataset,
src_spatial_dims: tuple[str, str],
dst_grid: xr.Dataset,
dst_spatial_dims: tuple[str, str],
method: Literal["nearest", "linear"] = "linear",
) -> xr.Dataset:
"""
Wrapper for interpolation from a regular grid to another one.
Handle reprojection of the destination grid to the native crs of the source grid.
The destination grid does not need to be regular. Used in :func:`interp2grid`.
Note
----
For very large grids in 'x' and 'y', the interpolation is much more efficient
when the input and destination grids are on the same native CRS.
"""
def _cond_dst_1d() -> bool:
"""
Whether the destination coordinates should be kept as 1D (when the source and destinations grids are in the
same CRS). Depending on the size of the grids, xarray "nearest" interpolation is much faster when destination
coordinates are 2D.
"""
data_trail_size = prod([size for dim, size in data.sizes.items() if dim not in src_spatial_dims])
dst_sizes = dst_grid.sizes
dim_ratio = (dst_sizes[dst_spatial_dims[1]] * dst_sizes[dst_spatial_dims[0]]) / data_trail_size
bool_dst_1d = dim_ratio > 2000 or method == "linear"
return bool_dst_1d and set(dst_spatial_dims).issubset(dst_grid.coords)
def _reproject_dst_2d() -> tuple[xr.DataArray, xr.DataArray]:
"""
Reproject destination 2D coordinates in the source grid CRS
"""
x_coords_val = dst_grid[dst_coords_crs[1]].values
y_coords_val = dst_grid[dst_coords_crs[0]].values
if x_coords_val.ndim == 1:
x_coords_val, y_coords_val = np.meshgrid(x_coords_val, y_coords_val)
# Reproject destination 2D coordinates in the grid CRS
x_coords_val, y_coords_val = _reproject_grid_cached(x_coords_val, y_coords_val, dst_coords_crs[2], src_crs)
return xr.DataArray(x_coords_val, dims=dst_spatial_dims), xr.DataArray(y_coords_val, dims=dst_spatial_dims)
def _coarsen_interp() -> xr.Dataset:
"""
Coarsen input dataset if needed (downsampling) and perform regular interpolation
"""
src_coords_crs = src_spatial_dims + (src_crs,)
res_ratio_x, res_ratio_y = _get_coarsen_ratio(
data,
src_spatial_dims,
src_coords_crs,
dst_grid,
dst_spatial_dims,
dst_coords_crs,
)
interp_data = data
if (res_ratio_x > 1) or (res_ratio_y > 1):
coarsen_specs = {
dim: ratio
for dim, ratio in (
(src_spatial_dims[0], res_ratio_y),
(src_spatial_dims[1], res_ratio_x),
)
if ratio > 1
}
# reduce(np.mean) propagates NaNs like mean(skipna=False), which fails with xarray 2026.9.0
interp_data = interp_data.coarsen(coarsen_specs, boundary="trim").reduce(np.mean)
interp_data = _y_increasing(interp_data, src_spatial_dims[0])
interp_data = interp_data.interp(
{src_spatial_dims[1]: x_coords, src_spatial_dims[0]: y_coords},
method=method,
assume_sorted=True,
).drop_vars(src_spatial_dims)
cast_dtype(interp_data, data)
interp_data.attrs = _combine_attrs(data.attrs, dst_grid.attrs)
interp_data = interp_data.assign_coords(dst_grid.coords)
return interp_data
data = _drop_coords_reg(data, src_spatial_dims)
src_crs = dm.get_grid_crs(data)
if _cond_dst_1d():
dst_crs = dm.get_grid_crs(dst_grid)
if src_crs == dst_crs:
dst_coords_crs = dst_spatial_dims + (dst_crs,)
x_coords, y_coords = (
dst_grid[dst_spatial_dims[1]].drop_vars(dst_spatial_dims[1]),
dst_grid[dst_spatial_dims[0]].drop_vars(dst_spatial_dims[0]),
)
return _coarsen_interp()
dst_grid = dst_grid.transpose(*dst_spatial_dims)
# chose to use lat/lon if present in dst_grid otherwise native coordinates
dst_coords_crs = dm.get_coords_name_crs(dst_grid, dst_spatial_dims)
x_coords, y_coords = _reproject_dst_2d()
return _coarsen_interp()
def _flatten_concat_grids( # pylint: disable=too-many-locals
data: xr.DataArray,
spatial_dims: tuple[str, ...],
dst_grid: xr.Dataset,
dst_spatial_dims: tuple[str, str],
method: str,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""
Flattens the grids and concatenate columnwise `x` and `y` coordinates into a
single 2D array. Also, reprojects the destination grid coordinates if they are not
in the same CRS than the input model coordinates.
"""
def _choose_crs_meters() -> CRS:
"""
Choose the target CRS with units in [m] for reprojection.
"""
if reproj_dst:
if grid_crs in SWISS_CRS:
return grid_crs
return CRS.from_string(SWISS_CRS[1]) if dst_grid_crs == SWISS_CRS[1] else CRS.from_string(SWISS_CRS[0])
if dst_grid_crs in SWISS_CRS:
return dst_grid_crs
return CRS.from_string(SWISS_CRS[1]) if grid_crs == SWISS_CRS[1] else CRS.from_string(SWISS_CRS[0])
data, grid_y, grid_x, grid_crs = _flatten_coords_crs(data, spatial_dims)
flat_dim = spatial_dims[0] if len(spatial_dims) == 1 else "cell"
dst_grid, dst_grid_y, dst_grid_x, dst_grid_crs = _flatten_coords_crs(dst_grid, dst_spatial_dims, flat_dim)
reproj_dst = dst_grid.sizes[flat_dim] <= data.sizes[flat_dim]
if method in HEIGHT_METHODS:
surf_alt = dm.get_surf_alt(data)
dst_surf_alt = dm.get_surf_alt(dst_grid)
# include elevation
coords_flat = np.c_[
data[grid_x].values,
data[grid_y].values,
data[surf_alt].values,
]
dst_coords_flat = np.c_[
dst_grid[dst_grid_x].values,
dst_grid[dst_grid_y].values,
dst_grid[dst_surf_alt].values,
]
target_crs = _choose_crs_meters()
coords_flat[:, 0], coords_flat[:, 1] = _reproject_grid_cached(
coords_flat[:, 0], coords_flat[:, 1], grid_crs, target_crs
)
dst_coords_flat[:, 0], dst_coords_flat[:, 1] = _reproject_grid_cached(
dst_coords_flat[:, 0], dst_coords_flat[:, 1], dst_grid_crs, target_crs
)
return coords_flat, data.values, dst_coords_flat
# Non-height methods
coords_flat = np.c_[data[grid_x].values, data[grid_y].values]
dst_coords_flat = np.c_[dst_grid[dst_grid_x].values, dst_grid[dst_grid_y].values]
if reproj_dst: # reproject destination coordinates in the grid CRS
dst_coords_flat[:, 0], dst_coords_flat[:, 1] = _reproject_grid_cached(
dst_coords_flat[:, 0], dst_coords_flat[:, 1], dst_grid_crs, grid_crs
)
return coords_flat, data.values, dst_coords_flat
# reproject grid coordinates in the destination CRS
coords_flat[:, 0], coords_flat[:, 1] = _reproject_grid_cached(
coords_flat[:, 0], coords_flat[:, 1], grid_crs, dst_grid_crs
)
return coords_flat, data.values, dst_coords_flat
@overload
def _flatten_coords_crs(
grid: xr.DataArray, spatial_dims: tuple[str, ...], flat_dim: str = "cell"
) -> tuple[xr.DataArray, str, str, CRS]: ...
@overload
def _flatten_coords_crs(
grid: xr.Dataset, spatial_dims: tuple[str, ...], flat_dim: str = "cell"
) -> tuple[xr.Dataset, str, str, CRS]: ...
def _flatten_coords_crs(
grid: xr.DataArray | xr.Dataset,
spatial_dims: tuple[str, ...],
flat_dim: str = "cell",
) -> tuple[xr.DataArray | xr.Dataset, str, str, CRS]:
"""
Flatten input dataset if not already and extract spatial coordinates and
corresponding crs to be used.
"""
if len(spatial_dims) > 1:
# flatten grid
grid = grid.stack({flat_dim: spatial_dims}).transpose(flat_dim, ...)
spatial_dims = (flat_dim,)
grid_y, grid_x, grid_crs = dm.get_coords_name_crs(grid, spatial_dims)
return grid, grid_y, grid_x, grid_crs
[docs]
def slice_boundingbox(
data: xr.Dataset,
poi_info: gpd.GeoDataFrame,
method: str,
spatial_dims: tuple[str, ...] | None = None,
**kwargs: Any,
) -> xr.Dataset:
"""
Slice input dataset spatial dimensions to the bounding box of the target POIs
plus a buffer depending on the interpolation method and corresponding kwargs.
"""
if spatial_dims is None:
spatial_dims = dm.get_spatial_dims(data)
grid_y, grid_x, grid_crs = dm.get_coords_name_crs(data, spatial_dims)
pois_coords = _convert_poi(poi_info, grid_crs, data[grid_y].dtype)
yx_bnds = (
(pois_coords[1].min().item(), pois_coords[1].max().item()),
(pois_coords[0].min().item(), pois_coords[0].max().item()),
)
buffer = _get_buffer(method, **kwargs)
sub_domain, _ = _get_sub_domain(data, spatial_dims, (grid_y, grid_x), yx_bnds, buffer)
return data.isel(sub_domain)
[docs]
def slice_coarsen( # pylint: disable=too-many-locals
data: xr.Dataset,
dst_grid: xr.Dataset,
method: str,
spatial_dims: tuple[str, ...] | None = None,
dst_spatial_dims: tuple[str, ...] | None = None,
**kwargs: Any,
) -> xr.Dataset:
"""
Slice input dataset to the extend of the destination grid and coarsen it if the
destination grid has lower resolution (downsampling).
"""
def extra_buffer(ratio: int) -> int:
"""
Extra buffer to add when input data is coarsened.
"""
if buffer == 1:
return ceil(ratio / 2)
return (ratio - 1) * (buffer - 1)
if spatial_dims is None:
spatial_dims = dm.get_spatial_dims(data)
if dst_spatial_dims is None:
dst_spatial_dims = dm.get_spatial_dims(dst_grid)
coords_crs = dm.get_coords_name_crs(data, spatial_dims)
dst_coords_crs = dm.get_coords_name_crs(dst_grid, dst_spatial_dims)
grid_crs, dst_grid_crs = coords_crs[2], dst_coords_crs[2]
x_coords, y_coords = (
dst_grid[dst_coords_crs[1]].values,
dst_grid[dst_coords_crs[0]].values,
)
if grid_crs != dst_grid_crs:
if x_coords.ndim == 1:
x_coords, y_coords = np.meshgrid(x_coords, y_coords)
# only keep the outer rectangle
x_coords = np.append(x_coords[[0, -1], :].ravel(), x_coords[1:-1, [0, -1]].ravel())
y_coords = np.append(y_coords[[0, -1], :].ravel(), y_coords[1:-1, [0, -1]].ravel())
# Reproject destination coordinates in the grid CRS
x_coords, y_coords = reproject(x_coords, y_coords, dst_grid_crs, grid_crs)
yx_bnds = ((y_coords.min(), y_coords.max()), (x_coords.min(), x_coords.max()))
buffer = _get_buffer(method, **kwargs)
sub_domain, res = _get_sub_domain(data, spatial_dims, coords_crs[:2], yx_bnds, buffer)
data_sub = data.isel(sub_domain)
# coarsen input dataset if needed (downsampling)
res_ratio_x, res_ratio_y = _get_coarsen_ratio(
data_sub,
spatial_dims,
coords_crs,
dst_grid,
dst_spatial_dims, # type: ignore[arg-type]
dst_coords_crs,
res,
)
if (res_ratio_x > 1) or (res_ratio_y > 1):
if len(spatial_dims) == 1:
logger.warning(
"Unstructured grid cannot be coarsened. Interpolation to a lower"
" resolution grid may create aliasing artifacts."
)
return data_sub
coarsen_specs = {
dim: ratio
for dim, ratio in (
(spatial_dims[1], res_ratio_x),
(spatial_dims[0], res_ratio_y),
)
if ratio > 1
}
sub_domain.update(
{
dim: slice( # type: ignore[misc]
max(sub_domain[dim].start - extra_buffer(ratio), 0), # type: ignore[union-attr]
sub_domain[dim].stop + extra_buffer(ratio), # type: ignore[union-attr]
)
for dim, ratio in coarsen_specs.items()
}
)
# reduce(np.mean) propagates NaNs like mean(skipna=False), which fails with xarray 2026.9.0
return data.isel(sub_domain).coarsen(coarsen_specs, boundary="trim").reduce(np.mean)
return data_sub
def _get_buffer(method: str, **kwargs: Any) -> int:
"""
Buffer specific to the interpolation method to add when slicing input dataset
to target grid or POIs
"""
buffer = 1
if (method == "cubic") or ("3D" in method):
buffer += 1
if "k" in kwargs:
buffer += ceil(np.sqrt(kwargs["k"] / np.pi))
if "r_search" in kwargs:
buffer += ceil(kwargs["r_search"])
return buffer
def _get_sub_domain( # pylint: disable=too-many-locals
data: xr.Dataset,
spatial_dims: tuple[str, ...],
spatial_coords: tuple[str, str],
yx_bnds: tuple[tuple[float, float], tuple[float, float]],
buffer: int,
) -> tuple[dict[str, slice], None] | tuple[dict[str, NDArray], float]:
"""
Get a dict to index ``data`` in ``spatial_dims`` according to ``yx_bnds``
plus a ``buffer``. Also return an estimate of the resolution if input grid is
unstructured.
"""
def get_cond_xy(buffer_crs: float) -> NDArray[np.bool_]:
"""
Get a boolean array to indicate which points are within x_bnds and y_bnds
+- ``buffer_crs`` to index data on the unstructured grid dimension.
"""
cond_x = (data[grid_x].values > x_bnds[0] - buffer_crs) & (data[grid_x].values < x_bnds[1] + buffer_crs)
cond_y = (data[grid_y].values > y_bnds[0] - buffer_crs) & (data[grid_y].values < y_bnds[1] + buffer_crs)
return cond_x & cond_y
def get_ind_startend(coordname: str, dim: str, bnds: tuple[float, float]) -> tuple[int, int]:
"""
Get start and end indices to slice data on ``dim`` based on ``bnds``.
"""
coords = data[coordname]
dim_size = data.sizes[dim]
bool_decrease = dm.is_decreasing(coords, dim)
if bool_decrease:
coords = coords.isel({dim: slice(None, None, -1)})
# Find start index: first value greater than lower bound
id_start = (coords > bnds[0]).argmax(dim).values.min().item()
# Find end index: first value greater than upper bound, or use full size
end_mask = coords > bnds[1]
id_end = end_mask.argmax(dim).values.max().item() if end_mask.values.any() else dim_size
if bool_decrease:
start_ori = id_start
id_start = dim_size - id_end
id_end = dim_size - start_ori
return id_start, id_end
grid_y, grid_x = spatial_coords
y_bnds, x_bnds = yx_bnds
if len(spatial_dims) == 1:
res_start = max(abs(np.diff(data[grid_x][:2])[0]), abs(np.diff(data[grid_y][:2])[0]))
res_end = max(abs(np.diff(data[grid_x][-2:])[0]), abs(np.diff(data[grid_y][-2:])[0]))
res_approx = max(res_start, res_end)
cond_xy = get_cond_xy(2 * res_approx)
res = _get_flat_grid_res(data[grid_x][cond_xy], data[grid_y][cond_xy])
cond_xy = get_cond_xy(buffer * res * 1.5)
return {spatial_dims[0]: cond_xy}, res
y_dim, x_dim = spatial_dims
idx_start, idx_end = get_ind_startend(grid_x, x_dim, x_bnds)
idy_start, idy_end = get_ind_startend(grid_y, y_dim, y_bnds)
idx_start = max(idx_start - buffer, 0)
idx_end += buffer
idy_start = max(idy_start - buffer, 0)
idy_end += buffer
return {x_dim: slice(idx_start, idx_end), y_dim: slice(idy_start, idy_end)}, None
def _get_flat_grid_res(coords_x: xr.DataArray, coords_y: xr.DataArray) -> float:
"""
Get approximative resolution of a flattened grid. Assumes similar resolution
in both horizontal dimensions.
"""
def dcoords(coords: xr.DataArray) -> float:
return np.median(abs(np.diff(coords.values)))
return max(dcoords(coords_x), dcoords(coords_y))
def _get_coarsen_ratio( # pylint: disable=too-many-locals
data: xr.Dataset,
spatial_dims: tuple[str, ...],
coords_crs: tuple[str, str, CRS],
dst_grid: xr.Dataset,
dst_spatial_dims: tuple[str, str],
dst_coords_crs: tuple[str, str, CRS],
res: float | None = None,
) -> tuple[int, int]:
"""
Get the resolution ratios in `x` and `y` between input data and destination grid
to eventually coarsen the input data before interpolation.
"""
if res is not None:
res_src_x = res_src_y = res
else:
center_src_x, center_src_y = _center_coords(data, spatial_dims, coords_crs[:2]) # type: ignore[arg-type]
res_src_x = center_src_x.diff(spatial_dims[1]).values.mean()
res_src_y = center_src_y.diff(spatial_dims[0]).values.mean()
center_dst_x, center_dst_y = _center_coords(dst_grid, dst_spatial_dims, dst_coords_crs[:2])
src_crs, dst_crs = coords_crs[2], dst_coords_crs[2]
if src_crs != dst_crs:
center_dst_x.values, center_dst_y.values = reproject(center_dst_x.values, center_dst_y.values, dst_crs, src_crs)
res_dst_x = center_dst_x.diff(dst_spatial_dims[1]).values.mean()
res_dst_y = center_dst_y.diff(dst_spatial_dims[0]).values.mean()
return abs(round(res_dst_x / res_src_x)), abs(round(res_dst_y / res_src_y))
def _center_coords(
grid: xr.Dataset, dims: tuple[str, str], coords: tuple[str, str]
) -> tuple[xr.DataArray, xr.DataArray]:
"""
Returns the "x" and "y" coordinates for the 4 center points of input grid domain.
Used to compute the resolution of the grid.
"""
y_dim, x_dim = dims
grid_y, grid_x = coords
mid_x = int(grid.sizes[x_dim] / 2)
mid_y = int(grid.sizes[y_dim] / 2)
sub_x = grid[grid_x].isel({x_dim: slice(mid_x - 1, mid_x + 1)})
sub_y = grid[grid_y].isel({y_dim: slice(mid_y - 1, mid_y + 1)})
if y_dim in sub_x.dims:
sub_x = sub_x.isel({y_dim: slice(mid_y - 1, mid_y + 1)})
sub_y = sub_y.isel({x_dim: slice(mid_x - 1, mid_x + 1)})
else:
sub_y, sub_x = xr.broadcast(sub_y, sub_x)
return sub_x, sub_y
def _combine_attrs(src_attrs: dict, dst_attrs: dict) -> dict:
"""
Combine attrs from source and destination grids
"""
from_dst = ("grid_mapping", "crs", "bounding_box")
combined_attrs = {key: src_attrs[key] for key in SRC_ATTRS_KEEP if key in src_attrs}
combined_attrs.update({key: dst_attrs[key] for key in from_dst if key in dst_attrs})
return combined_attrs
HEIGHT_METHODS = (
"temp",
"temp_fixgrad",
"snowlim",
"nearest_alt",
"idw_3D",
"nearest_3D",
"linear_3D",
"upscaled_3D",
)
INTERP_FUNC = {
"temp": interp_temp,
"temp_fixgrad": interp_temp_fixgrad,
"nearest_alt": interp_nearest_alt,
"snowlim": interp_snowlim,
"nearest": interp_nearest,
"nearest_3D": interp_nearest,
"idw": interp_idw,
"idw_3D": interp_idw,
"upscaled": interp_upscaled,
"upscaled_3D": interp_upscaled,
}