Source code for pyinterp.optimal_interpolation

# Copyright (c) 2026 CNES.
#
# All rights reserved. Use of this source code is governed by a
# BSD-style license that can be found in the LICENSE file.
r"""Optimal Interpolation (OI / BLUE) for scattered 4D observations.

This module provides an Optimal Interpolation estimator with an anisotropic
covariance kernel and a per-observation error variance. Decorrelation length
scales and the field standard deviation may be either scalars or
:class:`pyinterp.Grid2D` objects sampled (bilinear) at each query point,
which lets the analysis be tuned regionally — a common requirement in
operational altimetry products (DUACS / AVISO).

Two coordinate-system modes are available:

* ``"cartesian"`` (default, backward-compatible) — observations are stored as
  ``(x, y, t)`` in user units, and the analysis is run in that frame. Good
  for regional studies after a manual projection.
* ``"geographic"`` — observations are passed as ``(lon, lat, t)`` in
  degrees/seconds; the wrapper internally converts ``(lon, lat)`` to ECEF
  coordinates via the WGS-84 spheroid, indexes them in a 4D R-tree, and
  runs the OI in ECEF + time. The ``Grid2D`` length-scale and sigma fields
  remain naturally indexed by ``(lon, lat)`` and are sampled at the query
  position. The spatial decorrelation scale ``l_spatial`` is expressed in
  meters and applied isotropically to the three ECEF axes — appropriate for
  global isotropic spatial covariances such as those used in DUACS-like
  products.

For each query point the algorithm:

1. Retrieves up to ``k`` nearest observations from the internal 4D R-tree.
2. Builds the obs-obs covariance ``C_oo`` and the obs-grid covariance
   ``c_og`` with an anisotropic kernel
   :math:`C(\Delta) = \sigma^2 K(r)` where
   :math:`r^2 = \sum_d (\Delta_d / L_d)^2`.
3. Adds the per-observation error variance to the diagonal,
   :math:`R = \mathrm{diag}(\sigma^2_\mathrm{obs})`.
4. Solves :math:`w = (C_{oo} + R)^{-1} c_{og}` (Cholesky, LDLT fallback).
5. Returns the analysed value :math:`f = w^\top \cdot \mathrm{obs}` and
   the formal error standard deviation
   :math:`e = \sqrt{\max(\sigma^2 - c_{og}^\top w, 0)}`.

Heavy lifting is delegated to the C++ backend exposed by
:class:`pyinterp.RTree4D.optimal_interpolation` — this module is a thin
shim that handles coordinate conversion and ``Grid2D`` sampling.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal, Union

import numpy as np

from . import core
from .core import RTree4D
from .core.config.rtree import CovarianceFunction as _CovEnum
from .core.config.rtree import OptimalInterpolation as _OIConfig
from .core.geometry.geographic import Coordinates
from .core.geometry.geographic import Spheroid as _S
from .regular_grid_interpolator import bivariate


if TYPE_CHECKING:
    from .core import GridHolder
    from .core.geometry.geographic import Spheroid
    from .type_hints import NDArray1DFloat64, NDArray1DUInt32, NDArray2DFloat64


__all__ = [
    "CoordinateSystem",
    "CovarianceFunction",
    "OIResult",
    "OptimalInterpolation",
]


#: Anisotropic covariance kernels supported by the OI.
CovarianceFunction = Literal[
    "cauchy",
    "gaussian",
    "matern_12",
    "matern_32",
    "matern_52",
    "spherical",
    "wendland",
]

#: Coordinate-system options for the OI.
CoordinateSystem = Literal["cartesian", "geographic"]

#: A length-scale or sigma parameter: scalar or 2D grid sampled bilinearly.
ScalarOrGrid = Union[float, "GridHolder"]


_SQRT3 = np.sqrt(3.0)
_SQRT5 = np.sqrt(5.0)


_KERNEL_NAME_TO_ENUM = {
    "cauchy": _CovEnum.CAUCHY,
    "gaussian": _CovEnum.GAUSSIAN,
    "matern_12": _CovEnum.MATERN_12,
    "matern_32": _CovEnum.MATERN_32,
    "matern_52": _CovEnum.MATERN_52,
    "spherical": _CovEnum.SPHERICAL,
    "wendland": _CovEnum.WENDLAND,
}


# A length scale large enough that any reasonable obs/query distance becomes
# negligible in the anisotropic squared distance — used to neutralise the
# z axis in 4D ⟵ 3D padding.
_HUGE_LENGTH = 1.0e30

# Public coordinate arrays are 2-D with three columns: ``(x, y, t)`` in
# cartesian mode, ``(lon, lat, t)`` in geographic mode.
_COORDS_NDIM = 2
_COORDS_NCOLS = 3


def _kernel_from_r2(r2: np.ndarray, kernel: CovarianceFunction) -> np.ndarray:
    """Evaluate an anisotropic covariance kernel from squared scaled distance.

    Used by the regression tests as a pure-Python reference.

    Args:
        r2: Squared anisotropic distance.
        kernel: Kernel name.

    Returns:
        Covariance values with the same shape as ``r2``.

    """
    d = np.sqrt(r2)
    if kernel == "gaussian":
        result = np.exp(-r2)
    elif kernel == "cauchy":
        result = 1.0 / (1.0 + r2)
    elif kernel == "matern_12":
        result = np.exp(-d)
    elif kernel == "matern_32":
        result = (1.0 + _SQRT3 * d) * np.exp(-_SQRT3 * d)
    elif kernel == "matern_52":
        result = (1.0 + _SQRT5 * d + (5.0 / 3.0) * r2) * np.exp(-_SQRT5 * d)
    elif kernel == "spherical":
        result = np.where(d < 1.0, 1.0 - 1.5 * d + 0.5 * d**3, 0.0)
    elif kernel == "wendland":
        result = np.where(d < 1.0, (1.0 - d) ** 2, 0.0)
    else:
        msg = f"Unknown covariance kernel: {kernel!r}"
        raise ValueError(msg)
    return result


def _sample_scalar_or_grid(
    param: ScalarOrGrid,
    lon: np.ndarray,
    lat: np.ndarray,
    name: str,
) -> np.ndarray:
    """Resolve a scalar-or-Grid2D parameter into per-query values."""
    if np.isscalar(param) or (
        isinstance(param, np.ndarray) and param.ndim == 0
    ):
        value = float(param)  # type: ignore[arg-type]
        if not np.isfinite(value) or value <= 0.0:
            msg = f"{name} must be a positive finite scalar, got {value!r}"
            raise ValueError(msg)
        return np.full(lon.shape, value, dtype=np.float64)

    if isinstance(param, core.GridHolder):
        result = np.asarray(
            bivariate(param, lon, lat, "bilinear"), dtype=np.float64
        )
        # Out-of-domain query points yield NaN (bivariate uses
        # bounds_error=False); masked / fill cells can yield non-positive
        # values. Either would silently propagate into 1/L or sigma**2 and
        # produce NaN/garbage analyses, so reject them up front with a clear
        # message rather than at the C++ layer.
        if not np.all(np.isfinite(result)):
            msg = (
                f"{name} sampled from the Grid2D contains non-finite "
                "values; this usually means some query points fall outside "
                "the grid domain. Extend the grid to cover all query points."
            )
            raise ValueError(msg)
        if np.any(result <= 0.0):
            msg = (
                f"{name} sampled from the Grid2D contains non-positive "
                "values; length scales and sigma must be strictly positive."
            )
            raise ValueError(msg)
        return result

    msg = (
        f"{name} must be a positive scalar or a pyinterp.Grid2D, "
        f"got type {type(param).__name__}"
    )
    raise TypeError(msg)


def _lla_to_ecef(
    lon: np.ndarray, lat: np.ndarray, spheroid: Spheroid | None
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Convert (lon, lat, alt=0) → (x, y, z) ECEF in meters."""
    sph = spheroid if spheroid is not None else _S()
    coords = Coordinates(sph)
    alt = np.zeros_like(lon)
    return coords.lla_to_ecef(lon, lat, alt)


[docs] @dataclass class OIResult: """Result of an Optimal Interpolation analysis at a set of query points. Attributes: value: Analysed field, shape ``(M,)``. ``NaN`` where insufficient neighbours were found. error: Formal error standard deviation, shape ``(M,)``. ``NaN`` where insufficient neighbours were found. neighbors: Number of observations actually used at each query point, shape ``(M,)``, ``uint32``. """ value: NDArray1DFloat64 error: NDArray1DFloat64 neighbors: NDArray1DUInt32
[docs] class OptimalInterpolation: """Optimal Interpolation with anisotropic covariance and per-obs error. The estimator indexes scattered observations and computes the BLUE (Best Linear Unbiased Estimator) at arbitrary query points. .. important:: This is **simple kriging**: the BLUE about a *known, zero* mean. The weights are not constrained to sum to one, so **observations must be anomalies relative to a zero background mean** — subtract a mean / reference field before passing them in (e.g. use sea-level *anomalies*, not absolute sea-surface height). Where a query has no nearby, well-correlated observations the analysis relaxes toward ``0`` (the prior mean) and the formal error toward ``sigma``, *not* toward the local data mean. Feeding non-centred values therefore produces a systematic bias toward zero in data-sparse regions. The field standard deviation ``sigma`` is also evaluated only at the query point and assumed locally constant across that query's neighbourhood; strong ``sigma`` gradients within one neighbourhood introduce a small modelling bias. Args: obs_coords: Observation coordinates of shape ``(N, 3)``. In ``cartesian`` mode this is ``(x, y, t)`` in user units. In ``geographic`` mode this is ``(lon_deg, lat_deg, t_seconds)``. obs_values: Observed values, shape ``(N,)``. obs_sigma2: Per-observation error variance, shape ``(N,)``. Must be strictly positive. covariance: Anisotropic covariance kernel name. Defaults to ``"gaussian"``. Note that ``"spherical"`` and ``"wendland"`` are positive-definite only in up to three dimensions; in ``geographic`` mode the analysis runs over three ECEF axes plus time, so for large decorrelation scales the covariance matrix is not guaranteed positive-definite with those two kernels — prefer ``"gaussian"``, ``"matern_12/32/52"`` or ``"cauchy"`` there. coordinate_system: ``"cartesian"`` (default) or ``"geographic"``. See module docstring for the trade-offs. spheroid: Optional :class:`pyinterp.geometry.geographic.Spheroid` used in ``geographic`` mode. Defaults to WGS-84. time_scale: Scale factor applied to ``t`` when packing the internal R-tree, so the k-NN search (and any ``radius`` filter) treat space and time comparably. In ``cartesian`` mode it is in user-space-units per time-unit; in ``geographic`` mode it is in **meters per second** (time becomes a meters-equivalent before being mixed with the ECEF axes). It cancels out of the covariance — only neighbour selection is affected, so results with the default ``1.0`` are unchanged. Default ``1.0``. Note: The internal R-tree uses the 4D Euclidean metric on the packed coordinates, mixing the three spatial axes with the ``time_scale``-scaled time axis. In ``geographic`` mode the spatial part is the geodetic chord distance (very close to the great-circle distance for short ranges); set ``time_scale`` to a meters-per-second value so the k-NN search balances space and time. In ``cartesian`` mode the user is responsible for unit consistency between space and time. Choosing ``time_scale``: The k-NN search ranks neighbours by the *raw* packed Euclidean distance, which mixes spatial units with time. With the default ``time_scale = 1`` that ranking depends on the arbitrary ratio of spatial units to seconds, not on how correlated the neighbours actually are, so the retrieved set can be skewed toward one axis. A neighbour one decorrelation length away in space and one away in time are equally correlated with the query; to make the search treat them equally, scale time so those two steps have the same packed length:: time_scale ≈ L_spatial / L_t (typical spatial decorrelation length over the temporal one). Then the ``k`` nearest neighbours are the ``k`` *most correlated* observations. Worked example (geographic): ``L_spatial = 150 km`` and ``L_t = 6 h`` give ``time_scale ≈ 150000 m / 21600 s ≈ 7 m/s``. ``time_scale`` is baked into the tree at construction, so choose a value representative of the scales you query with; it never changes the covariance (hence the analysis is identical when all neighbours are used), only which neighbours are retrieved. """ def __init__( self, obs_coords: NDArray2DFloat64, obs_values: NDArray1DFloat64, obs_sigma2: NDArray1DFloat64, *, covariance: CovarianceFunction = "gaussian", coordinate_system: CoordinateSystem = "cartesian", spheroid: Spheroid | None = None, time_scale: float = 1.0, ) -> None: """Index the observations and build the internal 4D R-tree.""" obs_coords = np.ascontiguousarray(obs_coords, dtype=np.float64) obs_values = np.ascontiguousarray(obs_values, dtype=np.float64) obs_sigma2 = np.ascontiguousarray(obs_sigma2, dtype=np.float64) if ( obs_coords.ndim != _COORDS_NDIM or obs_coords.shape[1] != _COORDS_NCOLS ): msg = f"obs_coords must have shape (N, 3); got {obs_coords.shape}" raise ValueError(msg) n = obs_coords.shape[0] if obs_values.shape != (n,): msg = ( f"obs_values shape {obs_values.shape} does not match " f"obs_coords ({n},)" ) raise ValueError(msg) if obs_sigma2.shape != (n,): msg = ( f"obs_sigma2 shape {obs_sigma2.shape} does not match " f"obs_coords ({n},)" ) raise ValueError(msg) if np.any(obs_sigma2 <= 0.0): msg = "obs_sigma2 must be strictly positive everywhere" raise ValueError(msg) if coordinate_system not in ("cartesian", "geographic"): msg = ( "coordinate_system must be 'cartesian' or 'geographic', " f"got {coordinate_system!r}" ) raise ValueError(msg) if time_scale <= 0.0 or not np.isfinite(time_scale): msg = ( "time_scale must be a positive finite number, got " f"{time_scale!r}" ) raise ValueError(msg) self._covariance: CovarianceFunction = covariance # Validate kernel name early. _ = _kernel_from_r2(np.zeros(1), covariance) self._coordinate_system = coordinate_system self._spheroid = spheroid self._time_scale = float(time_scale) self._obs_coords = obs_coords # kept for introspection / tests # Build the 4D packed coordinates. packed = np.empty((n, 4), dtype=np.float64) if coordinate_system == "cartesian": # (x, y, 0, t * time_scale) — z axis neutralised via large Lz. packed[:, 0] = obs_coords[:, 0] packed[:, 1] = obs_coords[:, 1] packed[:, 2] = 0.0 packed[:, 3] = obs_coords[:, 2] * self._time_scale else: # geographic x, y, z = _lla_to_ecef( obs_coords[:, 0], obs_coords[:, 1], spheroid ) packed[:, 0] = x packed[:, 1] = y packed[:, 2] = z # Time is scaled like the cartesian path so that ``time_scale`` # rebalances space vs. time in the k-NN / radius search. It # cancels out of the covariance (lt is scaled identically below). packed[:, 3] = obs_coords[:, 2] * self._time_scale tree = RTree4D(dtype="float64") tree.packing(packed, obs_values, obs_sigma2) self._tree = tree @property def covariance(self) -> CovarianceFunction: """Covariance kernel name.""" return self._covariance @property def coordinate_system(self) -> CoordinateSystem: """Coordinate system in use.""" return self._coordinate_system @property def n_observations(self) -> int: """Number of indexed observations.""" return self._obs_coords.shape[0]
[docs] def __call__( # noqa: PLR0915 self, query_coords: NDArray2DFloat64, *, lx: ScalarOrGrid | None = None, ly: ScalarOrGrid | None = None, lt: ScalarOrGrid, sigma: ScalarOrGrid, l_spatial: ScalarOrGrid | None = None, k: int = 24, radius: float | None = None, num_threads: int = 0, ) -> OIResult: """Run the OI analysis at a set of query points. Args: query_coords: Query coordinates of shape ``(M, 3)``. Cartesian mode: ``(x, y, t)``. Geographic mode: ``(lon_deg, lat_deg, t_seconds)``. lx: Decorrelation length along the first axis (cartesian mode). Scalar or :class:`pyinterp.Grid2D` sampled at the query ``(x, y)`` (cartesian) or ``(lon, lat)`` (geographic). ly: Decorrelation length along the second axis (cartesian mode). Same options as ``lx``. lt: Temporal decorrelation length (same unit as ``t``). Required in both modes. sigma: Field standard deviation. Same options as ``lx``. l_spatial: Spatial decorrelation length **in meters** (geographic mode only). Applied isotropically to the three ECEF axes. Scalar or Grid2D sampled at ``(lon, lat)``. Mutually exclusive with ``lx``/``ly``. k: Maximum number of nearest neighbours to use. radius: Optional maximum search radius in the packed metric — the *combined* space+time Euclidean distance, not a pure spatial distance. With ``time_scale`` chosen so time is a meters-equivalent this is metres in geographic mode, and user units in cartesian. num_threads: Number of worker threads. ``0`` uses ``os.cpu_count()``. Returns: :class:`OIResult`. """ query_coords = np.ascontiguousarray(query_coords, dtype=np.float64) if ( query_coords.ndim != _COORDS_NDIM or query_coords.shape[1] != _COORDS_NCOLS ): msg = ( "query_coords must have shape (M, 3); got " f"{query_coords.shape}" ) raise ValueError(msg) if k < 1: msg = f"k must be >= 1, got {k}" raise ValueError(msg) m = query_coords.shape[0] qx = query_coords[:, 0] # cartesian x OR longitude qy = query_coords[:, 1] # cartesian y OR latitude sigma_arr = _sample_scalar_or_grid(sigma, qx, qy, "sigma") lt_arr = _sample_scalar_or_grid(lt, qx, qy, "lt") if self._coordinate_system == "cartesian": if l_spatial is not None: msg = "l_spatial is only valid in geographic mode" raise ValueError(msg) if lx is None or ly is None: msg = "lx and ly are required in cartesian mode" raise ValueError(msg) lx_arr = _sample_scalar_or_grid(lx, qx, qy, "lx") ly_arr = _sample_scalar_or_grid(ly, qx, qy, "ly") lz_arr = np.full(m, _HUGE_LENGTH, dtype=np.float64) lt_packed = lt_arr * self._time_scale packed_query = np.empty((m, 4), dtype=np.float64) packed_query[:, 0] = qx packed_query[:, 1] = qy packed_query[:, 2] = 0.0 packed_query[:, 3] = query_coords[:, 2] * self._time_scale else: # geographic if lx is not None or ly is not None: msg = ( "lx / ly are not valid in geographic mode; use " "l_spatial (isotropic horizontal scale in meters)" ) raise ValueError(msg) if l_spatial is None: msg = "l_spatial is required in geographic mode" raise ValueError(msg) ls_arr = _sample_scalar_or_grid(l_spatial, qx, qy, "l_spatial") lx_arr = ls_arr ly_arr = ls_arr lz_arr = ls_arr lt_packed = lt_arr * self._time_scale x, y, z = _lla_to_ecef(qx, qy, self._spheroid) packed_query = np.empty((m, 4), dtype=np.float64) packed_query[:, 0] = x packed_query[:, 1] = y packed_query[:, 2] = z packed_query[:, 3] = query_coords[:, 2] * self._time_scale cfg = ( _OIConfig() .with_covariance_model(_KERNEL_NAME_TO_ENUM[self._covariance]) .with_k(int(k)) .with_num_threads(int(num_threads)) ) if radius is not None: cfg = cfg.with_radius(float(radius)) values, errors, neighbors = self._tree.optimal_interpolation( packed_query, lx_arr, ly_arr, lz_arr, lt_packed, sigma_arr, cfg ) return OIResult(value=values, error=errors, neighbors=neighbors)