Source code for scippneutron.smoothing

# SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2026 Scipp contributors (https://github.com/scipp)
"""Kernel smoothing of one-dimensional sampled signals.

``smooth`` applies a translation-invariant displacement distribution, for a kernel
whose width is constant in coordinate units. ``smooth_relative`` instead applies a
distribution of fractional displacements, so its width scales with the coordinate.

Both procedures accept irregularly spaced samples. They interpolate onto a uniform
or geometric working grid that is no coarser than the tightest input spacing,
integrate the kernel probability over grid cells, renormalize the convolution at
finite boundaries, and interpolate the result back to the original coordinates.
"""

from __future__ import annotations

import warnings
from numbers import Real
from typing import Any, Protocol, cast

import numpy as np
import scipp as sc
from numpy.typing import ArrayLike, NDArray
from scipy.signal import convolve
from scipy.stats import norm, triang, uniform

__all__ = [
    "smooth",
    "smooth_relative",
]


class _Distribution(Protocol):
    def cdf(self, x: ArrayLike) -> Any: ...

    def ppf(self, probability: ArrayLike) -> Any: ...

    def support(self) -> tuple[Any, Any]: ...


type _Kernel = str | _Distribution
type _FloatArray = NDArray[np.float64]
type _IntArray = NDArray[np.int64]

_BUILTIN_KERNELS: dict[str, _Distribution] = {
    # Standard Gaussian.
    "gaussian": norm(),
    "normal": norm(),
    # Centered rectangle on [-1, 1].
    "rectangle": uniform(loc=-1.0, scale=2.0),
    "rect": uniform(loc=-1.0, scale=2.0),
    "box": uniform(loc=-1.0, scale=2.0),
    "boxcar": uniform(loc=-1.0, scale=2.0),
    "uniform": uniform(loc=-1.0, scale=2.0),
    # Centered triangle on [-1, 1], peak at 0.
    # Users can pass triang(c=...) themselves for asymmetric triangles.
    "triangle": triang(c=0.5, loc=-1.0, scale=2.0),
    "triangular": triang(c=0.5, loc=-1.0, scale=2.0),
}


def _as_kernel_distribution(kernel: _Kernel) -> _Distribution:
    if isinstance(kernel, str):
        try:
            return _BUILTIN_KERNELS[kernel.lower()]
        except KeyError:
            valid = ", ".join(sorted(_BUILTIN_KERNELS))
            raise ValueError(
                f"unknown kernel {kernel!r}; expected one of: {valid}"
            ) from None

    required = ("cdf", "ppf", "support")
    missing = [name for name in required if not hasattr(kernel, name)]
    if missing:
        raise TypeError(
            "kernel must be a scipy.stats distribution-like object with methods "
            f"{', '.join(required)}; missing {', '.join(missing)}"
        )

    return kernel


def _kernel_support(dist: _Distribution) -> tuple[float, float]:
    try:
        support_min, support_max = dist.support()
    except TypeError as e:
        raise TypeError(
            "kernel must be a fully specified distribution. For distributions "
            "with shape parameters, pass a frozen distribution such as "
            "triang(c=0.5), not triang."
        ) from e
    return float(support_min), float(support_max)


def _trim_kernel_weights(
    offsets: _IntArray, weights: _FloatArray
) -> tuple[_IntArray, _FloatArray]:
    nonzero = np.flatnonzero(weights > 0.0)
    if nonzero.size == 0:
        return offsets[:0], weights[:0]

    first = nonzero[0]
    last = nonzero[-1] + 1
    return offsets[first:last], weights[first:last]


class _Geometry(Protocol):
    """
    A grid on which the kernel is translation invariant.

    Both smoothing procedures displace a coordinate x by a distribution Z. The
    displacement law differs, but in both cases there is a working coordinate
    ``u`` in which the displacement is an additive offset independent of x, so
    that a single set of weights applies at every grid point. ``offset`` and
    ``displacement`` convert between a displacement z and its offset in u; they
    are inverses of each other.
    """

    #: Names the grid in user-facing messages.
    name: str

    def check(self, x: _FloatArray) -> None:
        """Reject coordinates outside the domain of the displacement law."""

    def coordinate(self, x: _FloatArray) -> _FloatArray:
        """Working coordinate u(x)."""

    def points(self, start: float, stop: float, count: int) -> _FloatArray:
        """``count`` samples from ``start`` to ``stop``, uniform in u."""

    def displacement_min(self, scale: float) -> float:
        """Smallest displacement z that keeps x within the valid domain."""

    def offset(self, scale: float, z: ArrayLike) -> Any:
        """Offset in u produced by displacement z."""

    def displacement(self, scale: float, u: ArrayLike) -> Any:
        """Displacement z producing an offset u."""


class _Uniform:
    """Constant kernel width: ``x' = x + scale * Z``, additive in x itself."""

    name = "uniform"

    def check(self, x: _FloatArray) -> None:
        pass

    def coordinate(self, x: _FloatArray) -> _FloatArray:
        return x

    def points(self, start: float, stop: float, count: int) -> _FloatArray:
        return np.linspace(start, stop, count)

    def displacement_min(self, scale: float) -> float:
        return -np.inf

    def offset(self, scale: float, z: ArrayLike) -> Any:
        return scale * np.asarray(z, dtype=float)

    def displacement(self, scale: float, u: ArrayLike) -> Any:
        return np.asarray(u, dtype=float) / scale


class _Geometric:
    """
    Relative kernel width: ``x' = x * (1 + scale * Z)``, additive in ``log(x)``.

    Equivalently, the kernel is

        K(x, x') = 1 / (scale * x) * f((x' - x) / (scale * x))

    for a distribution with PDF f. Displacements are restricted to ``x' > 0``,
    that is ``1 + scale * z > 0``.
    """

    name = "geometric"

    def check(self, x: _FloatArray) -> None:
        if np.any(x <= 0):
            raise ValueError("x must be positive")

    def coordinate(self, x: _FloatArray) -> _FloatArray:
        return cast(_FloatArray, np.log(x))

    def points(self, start: float, stop: float, count: int) -> _FloatArray:
        return np.geomspace(start, stop, count)

    def displacement_min(self, scale: float) -> float:
        return -1.0 / scale

    def offset(self, scale: float, z: ArrayLike) -> Any:
        # log1p(-1) is -inf, which _kernel_weights treats as unbounded support.
        with np.errstate(divide="ignore", invalid="ignore"):
            return np.log1p(scale * np.asarray(z, dtype=float))

    def displacement(self, scale: float, u: ArrayLike) -> Any:
        return np.expm1(np.asarray(u, dtype=float)) / scale


def _kernel_weights(
    geometry: _Geometry,
    spacing: float,
    scale: float,
    kernel: _Kernel,
    tail: float,
    max_offset: int,
) -> tuple[_IntArray, _FloatArray]:
    """
    Cell-integrated kernel weights on a grid of the given spacing in u.

    Weights are exact integrals of the kernel probability over grid cells, so
    the result is a proper quadrature of the smoothing integral rather than a
    point sampling of the kernel.
    """
    dist = _as_kernel_distribution(kernel)

    z_min = geometry.displacement_min(scale)
    support_min, support_max = _kernel_support(dist)

    p_min = float(dist.cdf(z_min))
    reachable_mass = 1.0 - p_min
    if not np.isfinite(reachable_mass) or reachable_mass <= 0.0:
        raise ValueError("kernel has no mass in the valid domain for this scale")
    u_left = float(geometry.offset(scale, max(z_min, support_min)))
    u_right = float(geometry.offset(scale, support_max))

    # Unbounded support, or support reaching the edge of the valid domain,
    # gives an infinite bound in u. Truncate at the requested tail instead.
    if not (np.isfinite(u_left) and np.isfinite(u_right)):
        probabilities = (
            p_min + np.array([0.5 * tail, 1.0 - 0.5 * tail]) * reachable_mass
        )
        u_left, u_right = (
            float(value) for value in geometry.offset(scale, dist.ppf(probabilities))
        )
        # The geometric domain boundary may leave u_left non-finite; m_min is
        # safely clamped to the finite input below. u_right has no such bound.
        if not np.isfinite(u_right):
            raise ValueError("right kernel bound is not finite; increase tail")

    # Cells are centered at m*h and span [(m-1/2)h, (m+1/2)h].
    # Include offset zero even for one-sided kernels and clamp the stencil to
    # offsets that can contribute to the finite input.
    m_min = int(np.clip(np.floor(u_left / spacing + 0.5), -max_offset, 0))
    m_max = int(np.clip(np.ceil(u_right / spacing - 0.5), 0, max_offset))
    m = np.arange(m_min, m_max + 1, dtype=np.int64)

    lower = geometry.displacement(scale, (m - 0.5) * spacing)
    upper = geometry.displacement(scale, (m + 0.5) * spacing)
    weights = np.maximum(dist.cdf(upper) - dist.cdf(lower), 0.0)
    return _trim_kernel_weights(m, weights)


def _valid_weight_sums(n: int, m: _IntArray, w: _FloatArray) -> _FloatArray:
    """
    Boundary normalization.

    Sum weights whose offsets remain within the input at each output position.
    This uses cumulative sums and is O(n), not another full convolution.
    """
    i = np.arange(n, dtype=np.int64)

    m_min = int(m[0])
    m_max = int(m[-1])

    lower = np.maximum(m_min, -i)
    upper = np.minimum(m_max, n - 1 - i)

    cumsum = np.empty(w.size + 1, dtype=float)
    cumsum[0] = 0.0
    np.cumsum(w, out=cumsum[1:])

    out = np.zeros(n, dtype=float)
    valid = lower <= upper
    out[valid] = cumsum[upper[valid] - m_min + 1] - cumsum[lower[valid] - m_min]
    return out


def _smooth_with_weights(
    y: _FloatArray, offsets: _IntArray, weights: _FloatArray
) -> _FloatArray:
    if weights.size == 0:
        return np.full_like(y, np.nan)

    # Desired operation:
    #
    #   out[i] = sum_m weights[m] * y[i + m]
    #
    # scipy convolution reverses the second argument, hence weights[::-1].
    # FFT convolution would spread a single NaN or infinity over the entire
    # output. SciPy recommends the direct method for non-finite inputs.
    method = "auto" if np.all(np.isfinite(y)) else "direct"
    full = cast(_FloatArray, convolve(y, weights[::-1], mode="full", method=method))
    start = int(offsets[-1])
    source_begin = max(start, 0)
    source_end = min(start + y.size, full.size)
    destination_begin = source_begin - start
    destination_end = destination_begin + source_end - source_begin
    numerator = np.zeros_like(y)
    numerator[destination_begin:destination_end] = full[source_begin:source_end]
    denominator = _valid_weight_sums(y.size, offsets, weights)

    out = np.full_like(numerator, np.nan)
    np.divide(numerator, denominator, out=out, where=denominator > 0.0)
    return out


def _validate_max_grid_points(max_grid_points: int) -> None:
    if isinstance(max_grid_points, bool | np.bool_) or not isinstance(
        max_grid_points, int | np.integer
    ):
        raise TypeError("max_grid_points must be an integer")
    if max_grid_points < 2:
        raise ValueError("max_grid_points must be at least 2")


def _smooth_values(
    geometry: _Geometry,
    x: ArrayLike,
    y: ArrayLike,
    scale: float,
    kernel: _Kernel,
    tail: float,
    max_grid_points: int,
) -> _FloatArray:
    x = np.asarray(x, dtype=float)
    y = np.asarray(y, dtype=float)

    if not np.isfinite(scale) or scale < 0:
        raise ValueError("scale must be non-negative")
    if not np.isfinite(tail) or not (0.0 < tail < 1.0):
        raise ValueError("tail must be between 0 and 1")
    _validate_max_grid_points(max_grid_points)
    if np.any(~np.isfinite(x)):
        raise ValueError("x must contain only finite values")
    geometry.check(x)
    if np.any(np.diff(x) <= 0):
        raise ValueError("x must be strictly increasing")
    if x.size < 2 or scale == 0:
        return y.copy()

    u = geometry.coordinate(x)
    du = np.diff(u)
    u_range = float(u[-1] - u[0])

    # Preserve an existing regular grid. Otherwise choose a grid at least as
    # dense as the smallest input spacing, measured in the working coordinate.
    if np.allclose(du, du[0], rtol=1e-7, atol=0.0):
        k = float(x.size)
    else:
        # Coordinates that are distinct but collide, or nearly so, in the
        # working coordinate give an infinite point count. Let it propagate to
        # the guard below rather than raising here.
        with np.errstate(divide="ignore", over="ignore"):
            k = np.ceil(u_range / np.min(du)) + 1.0

    if not (k <= max_grid_points) and k > 2 * x.size:
        raise ValueError(
            f"{geometry.name} resampling would require too many points, exceeding "
            f"max_grid_points={max_grid_points:,}. Increase max_grid_points to "
            "allow a larger grid."
        )
    k = int(k)

    spacing = u_range / (k - 1)
    if not np.isfinite(spacing) or spacing <= 0:
        raise ValueError("grid spacing must be positive")

    offsets, weights = _kernel_weights(
        geometry,
        spacing=spacing,
        scale=scale,
        kernel=kernel,
        tail=tail,
        max_offset=k - 1,
    )
    xp = geometry.points(float(x[0]), float(x[-1]), k)
    zg = _smooth_with_weights(np.interp(xp, x, y), offsets, weights)
    return cast(_FloatArray, np.interp(x, xp, zg))


def _validate_data(data: object) -> sc.Variable:
    """Validate user input and return its dimension coordinate."""
    if not isinstance(data, sc.DataArray):
        raise TypeError("expected a DataArray")
    if data.ndim != 1:
        raise sc.DimensionError("data must be one-dimensional")
    if data.is_binned:
        raise sc.DTypeError("data must not be binned")
    if data.dim not in data.coords:
        raise sc.CoordError("data must have a dimension coordinate")
    coord = data.coords[data.dim]
    if coord.sizes != data.sizes:
        raise sc.CoordError(
            "the dimension coordinate must have the same shape as the data"
        )
    if data.masks:
        raise ValueError("smoothing data with masks is not supported")
    if data.variances is not None:
        raise sc.VariancesError(
            "Smoothing signals with variances is not supported because it would "
            "introduce correlations between data points."
        )
    if np.any(~np.isfinite(data.values)):
        warnings.warn(
            "Data contains NaNs or infinities; smoothing may fall back to a slower "
            "method.",
            UserWarning,
            stacklevel=3,
        )
    return coord


def _with_values(data: sc.DataArray, values: _FloatArray) -> sc.DataArray:
    # The new container shares unchanged coordinates, but its data is independent.
    out = data.copy(deep=False)
    out.data = sc.array(dims=data.dims, values=values, unit=data.unit)
    return out


def _scale_in_coordinate_unit(scale: sc.Variable, x: sc.Variable) -> float:
    if not isinstance(scale, sc.Variable):
        raise TypeError("scale must be a scipp.Variable")
    if scale.ndim != 0:
        raise sc.DimensionError("scale must be a scalar")
    if scale.variances is not None:
        raise sc.VariancesError("kernel scales with variances are not supported")
    return float(scale.to(unit=x.unit).value)


def _dimensionless_scale(scale: object) -> float:
    if not isinstance(scale, sc.Variable):
        if not isinstance(scale, Real):
            raise TypeError("scale must be a real number or a scipp.Variable")
        return float(scale)
    if scale.ndim != 0:
        raise sc.DimensionError("scale must be a scalar")
    if scale.variances is not None:
        raise sc.VariancesError("kernel scales with variances are not supported")
    return float(scale.to(unit=sc.units.dimensionless).value)


[docs] def smooth( data: sc.DataArray, *, scale: sc.Variable, kernel: _Kernel = "gaussian", tail: float = 1e-12, max_grid_points: int = 1_000_000, ) -> sc.DataArray: """Smooth sampled data with a translation-invariant kernel. The kernel describes a distribution of displacements ``Z``, with displaced coordinates given by ``x' = x + scale * Z``. At the boundaries, the kernel is renormalized over the available finite input domain. Input that is not uniformly spaced is interpolated to a uniform grid, smoothed, and interpolated back to the original coordinates. Parameters ---------- data: One-dimensional data to smooth. Must have a strictly increasing dimension coordinate. scale: Finite, non-negative scale factor for the displacement distribution. Must be a scalar with a unit compatible with the coordinate. Set to zero to return a copy of the input without smoothing. kernel: Kernel distribution. The canonical names are ``'gaussian'``, ``'boxcar'``, and ``'triangular'``. They represent a standard normal distribution, a uniform distribution on [-1, 1], and a symmetric triangular distribution on [-1, 1], respectively. Other aliases are accepted. Alternatively, provide a fully specified distribution with ``cdf``, ``ppf``, and ``support`` methods. tail: Total probability omitted when truncating a kernel with unbounded support. Must be strictly between zero and one. max_grid_points: Intermediate uniform grids no larger than this are always allowed. Larger grids may be rejected to guard against excessive resampling. Returns ------- : Smoothed data. Coordinates and units are preserved. Points where no kernel mass falls inside the input domain are NaN; this can occur at the boundaries for one-sided kernels. Raises ------ ValueError If the inputs have invalid values, if a data array has masks, if a string does not identify a supported kernel, or if the required intermediate grid exceeds ``max_grid_points``. scipp.DimensionError If the input is not one-dimensional or ``scale`` is not scalar. scipp.CoordError If a data array has no dimension coordinate or has a bin-edge coordinate. scipp.DTypeError If ``data`` is binned. scipp.UnitError If the unit of ``scale`` is incompatible with the coordinate unit. scipp.VariancesError If the signal or ``scale`` has variances. TypeError If ``data`` is not a data array, ``scale`` is not a variable, ``kernel`` is not a distribution-like object, or ``max_grid_points`` is not an integer. Warns ----- UserWarning If the data contains NaNs or infinities, since smoothing may fall back to a slower method. """ x = _validate_data(data) values = _smooth_values( _Uniform(), x.values, data.values, scale=_scale_in_coordinate_unit(scale, x), kernel=kernel, tail=tail, max_grid_points=max_grid_points, ) return _with_values(data, values)
[docs] def smooth_relative( data: sc.DataArray, *, scale: float | sc.Variable, kernel: _Kernel = "gaussian", tail: float = 1e-12, max_grid_points: int = 1_000_000, ) -> sc.DataArray: """Smooth sampled data with a kernel of relative width. The kernel describes a distribution of relative displacements ``Z``, with displaced coordinates given by ``x' = x * (1 + scale * Z)``. At the boundaries, the kernel is renormalized over the available finite input domain. Input that is not geometrically spaced is interpolated to a geometric grid, smoothed, and interpolated back to the original coordinates. Parameters ---------- data: One-dimensional data to smooth. Must have a positive, strictly increasing dimension coordinate. scale: Finite, non-negative, dimensionless scale factor for the relative-displacement distribution. May be a real number or a scalar, dimensionless variable. Set to zero to return a copy of the input without smoothing. kernel: Kernel distribution. The canonical names are ``'gaussian'``, ``'boxcar'``, and ``'triangular'``. They represent a standard normal distribution, a uniform distribution on [-1, 1], and a symmetric triangular distribution on [-1, 1], respectively. Other aliases are accepted. Alternatively, provide a fully specified distribution with ``cdf``, ``ppf``, and ``support`` methods. tail: Total probability omitted when truncating a kernel with unbounded support or support reaching the nonpositive coordinate domain. Must be strictly between zero and one. max_grid_points: Intermediate geometric grids no larger than this are always allowed. Larger grids may be rejected to guard against excessive resampling. Returns ------- : Smoothed data. Coordinates and units are preserved. Points where no kernel mass falls inside the input domain are NaN; this can occur at the boundaries for one-sided kernels. Raises ------ ValueError If the inputs have invalid values, if a data array has masks, if a string does not identify a supported kernel, or if the required intermediate grid exceeds ``max_grid_points``. scipp.DimensionError If the input is not one-dimensional or ``scale`` is not scalar. scipp.CoordError If a data array has no dimension coordinate or has a bin-edge coordinate. scipp.DTypeError If ``data`` is binned. scipp.UnitError If ``scale`` is a variable with a non-dimensionless unit. scipp.VariancesError If the signal or ``scale`` has variances. TypeError If ``data`` is not a data array, ``scale`` is neither a real number nor a variable, ``kernel`` is not a distribution-like object, or ``max_grid_points`` is not an integer. Warns ----- UserWarning If the data contains NaNs or infinities, since smoothing may fall back to a slower method. """ x = _validate_data(data) values = _smooth_values( _Geometric(), x.values, data.values, scale=_dimensionless_scale(scale), kernel=kernel, tail=tail, max_grid_points=max_grid_points, ) return _with_values(data, values)