Source code for torch_harmonics.resample

# 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
from typing import Optional

# import numpy as np
import torch
import torch.nn as nn

from torch_harmonics.quadrature import precompute_latitudes, precompute_longitudes


def _slerp_shortest_arc(start: torch.Tensor, end: torch.Tensor, weight: torch.Tensor) -> torch.Tensor:
    r"""Interpolate angle-valued samples along the shorter arc of the circle.

    Spherical linear interpolation of two unit vectors
    :math:`\mathbf{u}_i = (\cos f_i, \sin f_i)` separated by
    :math:`\Omega = \arccos(\mathbf{u}_0 \cdot \mathbf{u}_1)` is

    .. math::

        \mathrm{slerp}(t) = \frac{\sin((1-t)\Omega)}{\sin\Omega}\,\mathbf{u}_0
                          + \frac{\sin(t\Omega)}{\sin\Omega}\,\mathbf{u}_1

    In two dimensions the angle of that interpolant collapses to a much simpler
    closed form -- walking a fraction :math:`t` along the shorter arc:

    .. math::

        f(t) = f_0 + t \cdot \mathrm{wrap}_\pi(f_1 - f_0),
        \qquad \mathrm{wrap}_\pi(\delta) \in (-\pi, \pi]

    so no trigonometry, no division, and no singularity are needed. Note the
    weights of the vector form are deliberately *not* a partition of unity --
    that is what re-normalises the interpolant back onto the sphere -- so they
    must never be applied to scalar samples directly.

    Because :math:`\mathrm{wrap}_\pi` is the identity whenever
    :math:`|f_1 - f_0| \le \pi`, this agrees exactly with plain linear
    interpolation everywhere except across a phase wrap, which is the case this
    mode exists to handle. It is exactly shift-equivariant, reproduces both
    endpoints, and never returns a value outside the range spanned by the arc.

    Parameters
    ----------
    start, end : torch.Tensor
        Angle-valued samples at the two ends of the arc, in radians.
    weight : torch.Tensor
        Interpolation weight :math:`t \in [0, 1]`.

    Returns
    -------
    torch.Tensor
        Interpolated angles, expressed as a continuous lift from ``start``
        (i.e. not re-wrapped), so the result stays adjacent to ``start``.
    """
    # antipodal samples (delta = +-pi) are a genuine tie: both arcs are equally
    # short. remainder() resolves it consistently towards -pi.
    delta = torch.remainder(end - start + math.pi, 2.0 * math.pi) - math.pi
    return start + weight * delta


def _circular_mean(x: torch.Tensor, dim: int = -1, keepdim: bool = False) -> torch.Tensor:
    r"""Mean *direction* of angle-valued samples, in radians.

    .. math::

        \bar{f} = \mathrm{atan2}\bigl(\overline{\sin f},\; \overline{\cos f}\bigr)

    The arithmetic mean is meaningless for angles: averaging :math:`+\pi` and
    :math:`-\pi`, which denote the same direction, gives :math:`0` -- the
    opposite one. Averaging the unit vectors instead and recovering the angle is
    invariant to where the branch cut is placed.

    If the samples are spread uniformly around the circle the resultant vector
    vanishes and the mean direction is genuinely undefined (a phase defect);
    ``atan2(0, 0)`` returns ``0`` in that case.
    """
    return torch.atan2(torch.sin(x).mean(dim=dim, keepdim=keepdim), torch.cos(x).mean(dim=dim, keepdim=keepdim))


[docs] class ResampleS2(nn.Module): r""" Resampling module for signals on the 2-sphere :math:`S^2`. This module resamples a spherical signal from one grid resolution (and type) to another. Interpolation is performed independently along latitudes and longitudes, with proper handling of periodicity in :math:`\lambda` and pole expansion when the output grid extends beyond the input latitude range. Two interpolation modes are available: * ``"bilinear"`` -- Standard bilinear (linear-linear) interpolation. For two neighbouring grid values :math:`f_0` and :math:`f_1` with interpolation weight :math:`t \in [0, 1]`, the interpolated value is .. math:: f(t) = (1 - t)\, f_0 + t\, f_1 This is applied first along the latitudinal (:math:`\theta`) and then along the longitudinal (:math:`\lambda`) direction. * ``"bilinear-spherical"`` -- Spherical linear interpolation (slerp) for fields whose *values* are angles (e.g.\ directions or phases, in radians). Neighbouring samples are interpolated along the shorter arc of the circle rather than along a straight line in value space: .. math:: f(t) = f_0 + t \cdot \mathrm{wrap}_\pi(f_1 - f_0), \qquad \mathrm{wrap}_\pi(\delta) \in (-\pi, \pi] This is the two-dimensional closed form of slerp; see the ``_slerp_shortest_arc`` helper in this module for the derivation. Since :math:`\mathrm{wrap}_\pi` is the identity whenever :math:`|f_1 - f_0| \le \pi`, this mode is **identical to** ``"bilinear"`` except across a :math:`2\pi` phase wrap -- precisely the discontinuity it exists to handle. Interpolating :math:`f_0 = 3` and :math:`f_1 = -3` (nearly the same direction, either side of the branch cut) gives :math:`\pi` here, whereas ``"bilinear"`` gives :math:`0`, the opposite direction. The result is expressed as a continuous lift from :math:`f_0` and is not re-wrapped, so it stays adjacent to :math:`f_0` and may leave :math:`(-\pi, \pi]`. Apply :func:`torch.remainder` afterwards if a canonical range is required. Parameters ---------- nlat_in : int Number of latitude points in the input grid nlon_in : int Number of longitude points in the input grid nlat_out : int Number of latitude points in the output grid nlon_out : int Number of longitude points in the output grid grid_in : str, optional Input grid type (``"equiangular"``, ``"legendre-gauss"``, ``"lobatto"``), by default ``"equiangular"`` grid_out : str, optional Output grid type (``"equiangular"``, ``"legendre-gauss"``, ``"lobatto"``), by default ``"equiangular"`` mode : str, optional Interpolation mode (``"bilinear"``, ``"bilinear-spherical"``), by default ``"bilinear"``. See above for a description of each mode. Examples -------- >>> import torch >>> import torch_harmonics as th >>> resample = th.ResampleS2(64, 128, 128, 256) >>> x = torch.randn(1, 64, 128) >>> y = resample(x) >>> y.shape torch.Size([1, 128, 256]) """ def __init__( self, nlat_in: int, nlon_in: int, nlat_out: int, nlon_out: int, grid_in: Optional[str] = "equiangular", grid_out: Optional[str] = "equiangular", mode: Optional[str] = "bilinear", ): super().__init__() # currently only bilinear is supported if mode in ["bilinear", "bilinear-spherical"]: self.mode = mode else: raise NotImplementedError(f"unknown interpolation mode {mode}") self.nlat_in, self.nlon_in = nlat_in, nlon_in self.nlat_out, self.nlon_out = nlat_out, nlon_out self.grid_in = grid_in self.grid_out = grid_out # for upscaling the latitudes we will use interpolation self.lats_in, _ = precompute_latitudes(nlat_in, grid=grid_in) self.lons_in = precompute_longitudes(nlon_in) self.lats_out, _ = precompute_latitudes(nlat_out, grid=grid_out) self.lons_out = precompute_longitudes(nlon_out) # in the case where some points lie outside of the range spanned by lats_in, # we need to expand the solution to the poles before interpolating # bool(), not a 0-dim tensor: this is branched on in forward, and a tensor # there is data-dependent control flow that breaks torch.compile(fullgraph=True) self.expand_poles = bool((self.lats_out > self.lats_in[-1]).any() or (self.lats_out < self.lats_in[0]).any()) if self.expand_poles: self.lats_in = torch.cat( [torch.as_tensor([0.0], dtype=torch.float64, device=self.lats_in.device), self.lats_in, torch.as_tensor([math.pi], dtype=torch.float64, device=self.lats_in.device)] ).contiguous() # prepare the interpolation by computing indices to the left and right of each output latitude lat_idx = torch.searchsorted(self.lats_in, self.lats_out, side="right") - 1 # make sure that we properly treat the last point if they coincide with the pole lat_idx = torch.where(self.lats_out == self.lats_in[-1], lat_idx - 1, lat_idx) # lat_idx = np.where(self.lats_out > self.lats_in[-1], lat_idx - 1, lat_idx) # lat_idx = np.where(self.lats_out < self.lats_in[0], 0, lat_idx) # compute the interpolation weights along the latitude lat_weights = ((self.lats_out - self.lats_in[lat_idx]) / torch.diff(self.lats_in)[lat_idx]).to(torch.float32) lat_weights = lat_weights.unsqueeze(-1) # register buffers self.register_buffer("lat_idx", lat_idx, persistent=False) self.register_buffer("lat_weights", lat_weights, persistent=False) # get left and right indices but this time make sure periodicity in the longitude is handled lon_idx_left = torch.searchsorted(self.lons_in, self.lons_out, side="right") - 1 lon_idx_right = torch.where(self.lons_out >= self.lons_in[-1], torch.zeros_like(lon_idx_left), lon_idx_left + 1) # get the difference diff = self.lons_in[lon_idx_right] - self.lons_in[lon_idx_left] # Coincident endpoints span a full period when there is one longitude. diff = torch.where(diff <= 0.0, diff + 2 * math.pi, diff) lon_weights = ((self.lons_out - self.lons_in[lon_idx_left]) / diff).to(torch.float32) # register buffers self.register_buffer("lon_idx_left", lon_idx_left, persistent=False) self.register_buffer("lon_idx_right", lon_idx_right, persistent=False) self.register_buffer("lon_weights", lon_weights, persistent=False) self.skip_resampling = (nlon_in == nlon_out) and (nlat_in == nlat_out) and (grid_in == grid_out) def extra_repr(self): return f"in_shape={(self.nlat_in, self.nlon_in)}, out_shape={(self.nlat_out, self.nlon_out)}" def _upscale_longitudes(self, x: torch.Tensor): # do the interpolation in precision of x lwgt = self.lon_weights.to(x.dtype) if self.mode == "bilinear": x = torch.lerp(x[..., self.lon_idx_left], x[..., self.lon_idx_right], lwgt) else: x = _slerp_shortest_arc(x[..., self.lon_idx_left], x[..., self.lon_idx_right], lwgt) return x def _expand_poles(self, x: torch.Tensor): # A pole is a single point, so its value cannot depend on longitude: reduce the # adjacent ring over phi. That reduction annihilates every m != 0 mode exactly, # which is the right answer there, since every m != 0 mode vanishes at the pole. # # Do NOT replace this with an f(theta, phi) -> f(-theta, phi + pi) continuation. # That is the correct way to reach *through* a pole (as a neighbourhood stencil # does), but here it would leave a phi-dependent -- i.e. multivalued -- value # *at* the pole: odd m cancels, but even m reinforces. if self.mode == "bilinear": x_north = x[..., 0, :].mean(dim=-1, keepdims=True) x_south = x[..., -1, :].mean(dim=-1, keepdims=True) else: # angle-valued field: must reduce as directions, not as numbers x_north = _circular_mean(x[..., 0, :], dim=-1, keepdim=True) x_south = _circular_mean(x[..., -1, :], dim=-1, keepdim=True) x = nn.functional.pad(x, pad=[0, 0, 1, 1], mode="constant") x[..., 0, :] = x_north[...] x[..., -1, :] = x_south[...] return x def _upscale_latitudes(self, x: torch.Tensor): # do the interpolation in precision of x lwgt = self.lat_weights.to(x.dtype) if self.mode == "bilinear": x = torch.lerp(x[..., self.lat_idx, :], x[..., self.lat_idx + 1, :], lwgt) else: x = _slerp_shortest_arc(x[..., self.lat_idx, :], x[..., self.lat_idx + 1, :], lwgt) return x
[docs] def forward(self, x: torch.Tensor): """ Resample a spherical signal onto the output grid. Parameters ---------- x : torch.Tensor Input signal of shape ``(..., nlat_in, nlon_in)``. Resampling acts on the last two (spatial) dimensions; any leading batch/channel dimensions are preserved. Returns ------- torch.Tensor Resampled signal of shape ``(..., nlat_out, nlon_out)``. """ if self.skip_resampling: return x if self.expand_poles: x = self._expand_poles(x) x = self._upscale_latitudes(x) x = self._upscale_longitudes(x) return x