Source code for torch_harmonics.quadrature

# coding=utf-8

# SPDX-FileCopyrightText: Copyright (c) 2022 The torch-harmonics Authors. All rights reserved.
# SPDX-License-Identifier: BSD-3-Clause
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this
# list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its
# contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#

import math
import warnings
from typing import Optional, Tuple

import numpy as np
import torch

from torch_harmonics.cache import lru_cache


def _precompute_quadrature_weights(
    n: int, grid: Optional[str] = "equiangular", a: Optional[float] = 0.0, b: Optional[float] = 1.0, periodic: Optional[bool] = False
) -> Tuple[torch.Tensor, torch.Tensor]:
    """
    Precompute grid points and quadrature weights for various quadrature rules.

    Parameters
    ----------
    n : int
        Number of grid points
    grid : str, optional
        Grid type (``"equiangular-trapezoidal"``, ``"legendre-gauss"``, ``"lobatto"``, ``"equiangular"``), by default ``"equiangular"``
    a : float, optional
        Lower bound of interval, by default 0.0
    b : float, optional
        Upper bound of interval, by default 1.0
    periodic : bool, optional
        Whether the grid is periodic (only for equiangular-trapezoidal), by default False

    Returns
    -------
    Tuple[torch.Tensor, torch.Tensor]
        Grid points and weights

    Raises
    ------
    ValueError
        If periodic is True for non-equiangular-trapezoidal grids or unknown grid type
    """

    if (grid != "equiangular-trapezoidal") and periodic:
        raise ValueError("Periodic grid is only supported on equiangular-trapezoidal grids.")

    # compute coordinates
    if grid == "equiangular-trapezoidal":
        xlg, wlg = trapezoidal_weights(n, a=a, b=b, periodic=periodic)
    elif grid == "legendre-gauss":
        xlg, wlg = legendre_gauss_weights(n, a=a, b=b)
    elif grid == "lobatto":
        xlg, wlg = lobatto_weights(n, a=a, b=b)
    elif grid == "equiangular":
        xlg, wlg = clenshaw_curtiss_weights(n, a=a, b=b)
    else:
        raise ValueError(f"Unknown grid type {grid}")

    return xlg, wlg


@lru_cache(typed=True, copy=True)
def precompute_longitudes(nlon: int):
    r"""
    Return equispaced longitude nodes in :math:`[0, 2\pi)`.

    Parameters
    ----------
    nlon : int
        Number of longitudinal nodes.

    Returns
    -------
    torch.Tensor
        Tensor of longitude values in radians, shape ``(nlon,)``.
    """
    lons = torch.linspace(0, 2 * math.pi, nlon + 1, dtype=torch.float64, requires_grad=False)[:-1]
    return lons


@lru_cache(typed=True, copy=True)
def precompute_latitudes(nlat: int, grid: Optional[str] = "equiangular") -> Tuple[torch.Tensor, torch.Tensor]:
    """
    Return latitude nodes and quadrature weights for the given grid type.

    Parameters
    ----------
    nlat : int
        Number of latitudinal nodes.
    grid : str, optional
        Quadrature grid type. One of ``"equiangular"`` (Clenshaw–Curtis),
        ``"legendre-gauss"``, ``"lobatto"``, or ``"equiangular-trapezoidal"``.
        Default is ``"equiangular"``.

    Returns
    -------
    lats : torch.Tensor
        Tensor of co-latitude values in radians, shape ``(nlat,)``.
    wlg : torch.Tensor
        Corresponding quadrature weights, shape ``(nlat,)``.
    """
    # compute coordinates in the cosine theta domain
    xlg, wlg = _precompute_quadrature_weights(nlat, grid=grid, a=-1.0, b=1.0, periodic=False)

    # to perform the quadrature and account for the jacobian of the sphere, the quadrature rule
    # is formulated in the cosine theta domain, which is designed to integrate functions of cos theta
    lats = torch.flip(torch.arccos(xlg), dims=(0,)).clone()
    wlg = torch.flip(wlg, dims=(0,)).clone()

    return lats, wlg


@lru_cache(typed=True, copy=False)
def compute_latitude_spacing(nlat: int, grid: Optional[str] = "equiangular") -> float:
    r"""
    Return the largest gap between adjacent latitude nodes of a grid.

    This is the grid's own notion of "one latitudinal grid spacing". Only the
    ``equiangular`` grid has uniform spacing, in which case this reduces to the
    familiar :math:`\pi / (N_\theta - 1)`. Gauss--Lobatto nodes cluster towards
    the equator, and ``equiangular-trapezoidal`` nodes are equispaced in
    :math:`\cos\theta` rather than in :math:`\theta`, so both are considerably
    coarser near the poles than a node count alone would suggest.

    Parameters
    ----------
    nlat : int
        Number of latitudinal nodes.
    grid : str, optional
        Quadrature grid type, by default ``"equiangular"``.

    Returns
    -------
    float
        Maximum spacing :math:`\max_k (\theta_{k+1} - \theta_k)` in radians.
    """
    lats, _ = precompute_latitudes(nlat, grid=grid)
    return (lats[1:] - lats[:-1]).max().item()


[docs] def compute_theta_cutoff(nlat: int, grid: Optional[str] = "equiangular", scale: Optional[float] = 1.0) -> float: r""" Default angular cutoff for localized operators on the sphere. Both the DISCO convolutions and neighborhood attention need a support radius for their filter basis. The heuristic is to take one latitudinal grid spacing of the coarser of the two grids involved, so that the basis functions of adjacent output points overlap and every output point sees more than the single latitude ring it sits on. The spacing is taken from the grid's actual node distribution (:func:`compute_latitude_spacing`) rather than from ``nlat`` alone. Using :math:`\pi / (N_\theta - 1)` is only correct for equiangular grids; on ``lobatto`` and ``equiangular-trapezoidal`` grids it underestimates the polar spacing by ~21% and ~5x respectively, which collapses the stencil of the polar output latitudes to a single latitude ring. Parameters ---------- nlat : int Number of latitudinal nodes of the grid that sets the cutoff. This is the output grid for a forward transform and the input grid for a transpose one, mirroring which of the two is the coarser. grid : str, optional Quadrature grid type, by default ``"equiangular"``. scale : float, optional Multiplier on the grid spacing, by default 1.0. Returns ------- float Cutoff angle in radians. Warns ----- UserWarning On grids whose node spacing is not uniform in :math:`\theta`, where this returns a different value than the ``pi / (N_\theta - 1)`` heuristic used before v0.9.3. Equiangular grids are unaffected and do not warn. Notes ----- This routine is the single place where the cutoff heuristic lives; it is intended to take a grid descriptor once the descriptor-based API lands, at which point ``nlat`` and ``grid`` collapse into a single argument. """ dlat_max = compute_latitude_spacing(nlat, grid=grid) # only the equiangular grid is uniform in theta, so only there does the # superseded heuristic still agree (up to arccos roundoff) legacy = math.pi / float(nlat - 1) if abs(dlat_max - legacy) > 1e-9 * legacy: consequence = "the previous value under-covered the poles" if dlat_max > legacy else "the previous value was slightly wider than the grid warrants" warnings.warn( f"Default theta_cutoff changed in v0.9.3: the '{grid}' grid is not uniform in theta, so the cutoff is now " f"its maximum latitudinal node spacing ({dlat_max:.6f}) rather than pi/(nlat-1) ({legacy:.6f}); " f"{consequence}. Specify theta_cutoff explicitly to override.", UserWarning, stacklevel=2, ) return scale * dlat_max
#: Fractional widening applied to ``theta_cutoff`` when a sparsity pattern is built, to keep the #: support from aliasing against the grid width near the poles. Anything deriving a latitude band #: or a halo from the cutoff has to apply the same widening, or it will come out narrower than the #: pattern it is supposed to bound. THETA_CUTOFF_EPS = 1e-3
[docs] def effective_theta_cutoff(theta_cutoff: float, theta_eps: Optional[float] = THETA_CUTOFF_EPS) -> float: """The cutoff a sparsity pattern is actually built with, see ``THETA_CUTOFF_EPS``.""" return (1.0 + theta_eps) * theta_cutoff
[docs] def latitude_support_band(lats_in: torch.Tensor, lats_out: torch.Tensor, theta_cutoff: float) -> Tuple[torch.Tensor, torch.Tensor]: r""" Inclusive range of input latitudes that can lie within ``theta_cutoff`` of each output latitude. The localized operators on the sphere -- the DISCO convolutions and neighborhood attention -- only couple points closer than an angular cutoff. A colatitude difference is bounded by the great-circle distance between the two points, so .. math:: |\theta_\mathrm{in} - \theta_\mathrm{out}| \le \theta_c is a *necessary* condition for a pair to interact, and the latitudes satisfying it form a contiguous band around each output latitude. Everything outside the band is known to be zero without evaluating it. That band is what makes these operators cheap to build and to distribute. The sparsity pattern only has to be evaluated inside it, which is the difference between :math:`O(N_\theta^2 N_\lambda)` and :math:`O(N_\theta N_\lambda b)` work; and a polar shard only ever needs latitudes inside it from its neighbours, which is what bounds the halo. The band is a superset of the true support, since it ignores longitude and so admits points that are close in latitude but far apart on the sphere. It is tight in the sense that it cannot be narrowed without knowing the longitudes: a point at the output's own longitude attains the bound. Because the criterion is symmetric in the two grids, it does not matter which of them the caller regards as the input -- the transpose direction may pass them the other way round. Parameters ---------- lats_in : torch.Tensor Input colatitudes in radians, ascending, shape ``(nlat_in,)``. lats_out : torch.Tensor Output colatitudes in radians, ascending, shape ``(nlat_out,)``. theta_cutoff : float Angular support radius of the filter basis. Pass the same effective value the sparsity pattern is built with, including any widening factor, or the band may exclude entries the pattern would keep. Returns ------- lo : torch.Tensor First input-latitude index in the band, per output latitude, shape ``(nlat_out,)``. hi : torch.Tensor Last input-latitude index in the band, inclusive, shape ``(nlat_out,)``. ``hi < lo`` marks an output latitude that no input latitude can reach. Notes ----- The bound is compared inclusively, but only up to floating point: a node sitting exactly ``theta_cutoff`` away falls on whichever side the arithmetic rounds to, and that can differ between platforms. It does not matter for the cutoffs in use, which come from the grid's node spacing widened by ``THETA_CUTOFF_EPS`` and so land between nodes rather than on one. A cutoff chosen to coincide exactly with a node separation is the case to avoid. Examples -------- >>> import torch >>> from torch_harmonics.quadrature import latitude_support_band, precompute_latitudes >>> lats, _ = precompute_latitudes(16) >>> cutoff = 1.5 * float(lats[1] - lats[0]) # one and a half grid spacings >>> lo, hi = latitude_support_band(lats, lats, cutoff) >>> int(lo[8]), int(hi[8]) (7, 9) >>> int(lo[0]), int(hi[0]) # clamped at the pole (0, 1) """ lo = torch.searchsorted(lats_in, lats_out - theta_cutoff, right=False) hi = torch.searchsorted(lats_in, lats_out + theta_cutoff, right=True) - 1 # lo may run one past the end and hi one before the start; both encode an empty band return lo.clamp(0, lats_in.numel()), hi.clamp(-1, lats_in.numel() - 1)
[docs] def trapezoidal_weights(n: int, a: Optional[float] = -1.0, b: Optional[float] = 1.0, periodic: Optional[bool] = False) -> Tuple[torch.Tensor, torch.Tensor]: """ Helper routine which returns equiangular-trapezoidal nodes with trapezoidal weights on the interval [a, b] Parameters ---------- n : int Number of quadrature nodes a : Optional[float] Lower bound of the interval b : Optional[float] Upper bound of the interval periodic : Optional[bool] Whether the grid is periodic Returns ------- xlg : torch.Tensor Tensor of quadrature nodes wlg : torch.Tensor Tensor of quadrature weights """ xlg = torch.as_tensor(np.linspace(a, b, n, endpoint=not periodic)) # dtype is explicit: torch.ones defaults to float32, which would make these the only # float32 weights in the module and cap the accuracy of every rule derived from them wlg = (b - a) / (n - 1 + periodic * 1) * torch.ones(n, dtype=xlg.dtype, requires_grad=False) if not periodic: wlg[0] *= 0.5 wlg[-1] *= 0.5 return xlg, wlg
[docs] def legendre_gauss_weights(n: int, a: Optional[float] = -1.0, b: Optional[float] = 1.0) -> Tuple[torch.Tensor, torch.Tensor]: """ Helper routine which returns the Legendre-Gauss nodes and weights on the interval [a, b] Parameters ---------- n : int Number of quadrature nodes a : Optional[float] Lower bound of the interval b : Optional[float] Upper bound of the interval Returns ------- xlg : torch.Tensor Tensor of quadrature nodes wlg : torch.Tensor Tensor of quadrature weights """ xlg, wlg = np.polynomial.legendre.leggauss(n) xlg = torch.as_tensor(xlg).clone() wlg = torch.as_tensor(wlg).clone() xlg = (b - a) * 0.5 * xlg + (b + a) * 0.5 wlg = wlg * (b - a) * 0.5 return xlg, wlg
[docs] def lobatto_weights(n: int, a: Optional[float] = -1.0, b: Optional[float] = 1.0, tol: Optional[float] = 1e-16, maxiter: Optional[int] = 100) -> Tuple[torch.Tensor, torch.Tensor]: """ Helper routine which returns the Legendre-Gauss-Lobatto nodes and weights on the interval [a, b] Parameters ---------- n : int Number of quadrature nodes a : Optional[float] Lower bound of the interval b : Optional[float] Upper bound of the interval tol : Optional[float] Tolerance for the iteration maxiter : Optional[int] Maximum number of iterations Returns ------- tlg : torch.Tensor Tensor of quadrature nodes wlg : torch.Tensor Tensor of quadrature weights """ wlg = torch.zeros((n,), dtype=torch.float64, requires_grad=False) tlg = torch.zeros((n,), dtype=torch.float64, requires_grad=False) tmp = torch.zeros((n,), dtype=torch.float64, requires_grad=False) # Vandermonde Matrix vdm = torch.zeros((n, n), dtype=torch.float64, requires_grad=False) # initialize Chebyshev nodes as first guess for i in range(n): tlg[i] = -math.cos(math.pi * i / (n - 1)) tmp = 2.0 for i in range(maxiter): tmp = tlg vdm[:, 0] = 1.0 vdm[:, 1] = tlg for k in range(2, n): vdm[:, k] = ((2 * k - 1) * tlg * vdm[:, k - 1] - (k - 1) * vdm[:, k - 2]) / k tlg = tmp - (tlg * vdm[:, n - 1] - vdm[:, n - 2]) / (n * vdm[:, n - 1]) if max(abs(tlg - tmp).flatten()) < tol: break wlg = 2.0 / ((n * (n - 1)) * (vdm[:, n - 1] ** 2)) # rescale tlg = (b - a) * 0.5 * tlg + (b + a) * 0.5 wlg = wlg * (b - a) * 0.5 return tlg, wlg
[docs] def clenshaw_curtiss_weights(n: int, a: Optional[float] = -1.0, b: Optional[float] = 1.0) -> Tuple[torch.Tensor, torch.Tensor]: """ Computation of the Clenshaw-Curtis quadrature nodes and weights. This implementation follows Parameters ---------- n : int Number of quadrature nodes a : Optional[float] Lower bound of the interval b : Optional[float] Upper bound of the interval Returns ------- tcc : torch.Tensor Tensor of quadrature nodes wcc : torch.Tensor Tensor of quadrature weights References ---------- :cite:`Waldvogel2006` """ if n <= 1: raise ValueError(f"n must be greater than 1, got {n}") tcc = torch.cos(torch.linspace(math.pi, 0, n, dtype=torch.float64, requires_grad=False)) if n == 2: wcc = torch.as_tensor([1.0, 1.0], dtype=torch.float64) else: n1 = n - 1 N = torch.arange(1, n1, 2, dtype=torch.float64) l = len(N) m = n1 - l v = torch.cat([2 / N / (N - 2), 1 / N[-1:], torch.zeros(m, dtype=torch.float64, requires_grad=False)]) # v = 0 - v[:-1] - v[-1:0:-1] v = 0 - v[:-1] - torch.flip(v[1:], dims=(0,)) g0 = -torch.ones(n1, dtype=torch.float64, requires_grad=False) g0[l] = g0[l] + n1 g0[m] = g0[m] + n1 g = g0 / (n1**2 - 1 + (n1 % 2)) wcc = torch.fft.ifft(v + g).real wcc = torch.cat((wcc, wcc[:1])) # rescale tcc = (b - a) * 0.5 * tcc + (b + a) * 0.5 wcc = wcc * (b - a) * 0.5 return tcc, wcc
[docs] class QuadratureS2(torch.nn.Module): r""" Scalar quadrature on :math:`S^2` for integrating spherical fields defined on a latitude/longitude grid. Given a signal :math:`f(\theta, \lambda)` sampled on a latitude--longitude grid, this module approximates the surface integral over the sphere: .. math:: I[f] = \int_0^{2\pi}\!\int_0^{\pi} f(\theta, \lambda)\,\sin\theta\; d\theta\; d\lambda \;\approx\; \sum_{k=0}^{N_\theta - 1} \sum_{j=0}^{N_\lambda - 1} f(\theta_k, \lambda_j)\, q_k\, \Delta\lambda where :math:`q_k` are the latitudinal quadrature weights (which absorb the :math:`\sin\theta` Jacobian via the change of variable to :math:`\cos\theta`) and :math:`\Delta\lambda = 2\pi / N_\lambda` is the uniform longitudinal spacing. The choice of ``grid`` determines how the nodes :math:`\theta_k` and weights :math:`q_k` are computed: * ``"legendre-gauss"`` -- Gauss--Legendre quadrature. Nodes are the roots of the Legendre polynomial :math:`P_N(\cos\theta)`. Exact for polynomials of degree up to :math:`2N - 1`. * ``"lobatto"`` -- Gauss--Lobatto quadrature. Nodes include both endpoints (poles). Exact for polynomials of degree up to :math:`2N - 3`. * ``"equiangular"`` -- Clenshaw--Curtis quadrature on equiangular nodes. Nodes are equally spaced in :math:`\theta`. Exact for polynomials of degree up to approximately :math:`N - 1`. * ``"equiangular-trapezoidal"`` -- Trapezoidal rule on equiangular nodes. When ``normalize=True``, the weights are divided by :math:`4\pi` so that the output represents the spherical mean rather than the integral: .. math:: \bar{f} = \frac{1}{4\pi} \int_{S^2} f\; dA Parameters ---------- img_shape : Tuple[int] Spatial grid shape ``(nlat, nlon)``. grid : str, optional Quadrature grid type (``"equiangular"``, ``"legendre-gauss"``, ``"lobatto"``, ``"equiangular-trapezoidal"``), by default ``"equiangular"``. normalize : bool, optional If ``True``, divides weights by :math:`4\pi` to return a spherical mean instead of an integral, by default ``False``. Examples -------- Compute the surface area of the unit sphere (:math:`\int_{S^2} 1\,dA = 4\pi`): >>> import torch >>> import torch_harmonics as th >>> nlat, nlon = 128, 256 >>> quad = th.QuadratureS2(img_shape=(nlat, nlon), grid="legendre-gauss") >>> ones = torch.ones(1, 1, nlat, nlon) >>> round(quad(ones).item(), 5) # ≈ 4π; the weights buffer is float32 12.56637 Compute the spherical mean of a field: >>> quad_norm = th.QuadratureS2(img_shape=(nlat, nlon), grid="legendre-gauss", normalize=True) >>> quad_norm(ones).item() # ≈ 1.0 1.0 Raises ------ ValueError If an unknown ``grid`` type is provided. """ def __init__(self, img_shape: Tuple[int], grid: Optional[str] = "equiangular", normalize: Optional[bool] = False): super().__init__() self.grid = grid self.normalize = normalize if self.grid == "legendre-gauss": _, weights = legendre_gauss_weights(img_shape[0], -1, 1) dlambda = 2 * torch.pi / img_shape[1] quad_weight = dlambda * weights.unsqueeze(1) quad_weight = quad_weight.tile(1, img_shape[1]) elif self.grid == "lobatto": _, weights = lobatto_weights(img_shape[0], -1, 1) dlambda = 2 * torch.pi / img_shape[1] quad_weight = dlambda * weights.unsqueeze(1) quad_weight = quad_weight.tile(1, img_shape[1]) elif self.grid == "equiangular": _, weights = clenshaw_curtiss_weights(img_shape[0], -1, 1) dlambda = 2 * torch.pi / img_shape[1] quad_weight = dlambda * weights.unsqueeze(1) quad_weight = quad_weight.tile(1, img_shape[1]) elif self.grid == "equiangular-trapezoidal": _, weights = trapezoidal_weights(img_shape[0], -1, 1) dlambda = 2 * torch.pi / img_shape[1] quad_weight = dlambda * weights.unsqueeze(1) quad_weight = quad_weight.tile(1, img_shape[1]) else: raise (ValueError("Unknown quadrature mode")) # apply normalization if normalize: quad_weight = quad_weight / (4.0 * torch.pi) # make it contiguous quad_weight = quad_weight.contiguous() # reshape quad_weight = quad_weight.reshape(1, 1, *img_shape).to(torch.float32).contiguous() # register buffer self.register_buffer("quad_weight", quad_weight, persistent=False)
[docs] def forward(self, x: torch.Tensor) -> torch.Tensor: """ Integrate a signal over the sphere using the precomputed quadrature. Parameters ---------- x : torch.Tensor Input signal of shape ``(..., nlat, nlon)``. Integration is over the last two (spatial) dimensions. Returns ------- torch.Tensor Integral of shape ``(...)`` (the input with its last two dimensions reduced). """ # integrate over last two axes only: quad = torch.sum(x * self.quad_weight, dim=(-2, -1)) return quad