Source code for torch_harmonics.sht

# 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 torch
import torch.nn as nn

from torch_harmonics.fft import irfft, rfft
from torch_harmonics.legendre import _precompute_dlegpoly, _precompute_legpoly
from torch_harmonics.quadrature import clenshaw_curtiss_weights, legendre_gauss_weights, lobatto_weights
from torch_harmonics.truncation import truncate_sht


[docs] class RealSHT(nn.Module): r""" Defines a module for computing the forward (real-valued) SHT. Precomputes Legendre Gauss nodes, weights and associated Legendre polynomials on these nodes. The SHT is applied to the last two dimensions of the input. Given a real-valued signal :math:`f(\theta, \lambda)` sampled on the sphere, the forward scalar SHT computes the spherical harmonic coefficients via a longitudinal FFT followed by Legendre quadrature: .. math:: \hat{f}_l^m = 2\pi \sum_{k=0}^{N_\theta - 1} \tilde{f}_m(\theta_k)\, P_l^m(\cos\theta_k)\, q_k where :math:`\tilde{f}_m` are the Fourier modes and :math:`q_k` are the quadrature weights. .. seealso:: :doc:`/guide/spherical_harmonic_transforms` User guide with the full mathematical derivation, normalization conventions, grid types, and worked examples. Parameters ---------- nlat : int Number of latitude points nlon : int Number of longitude points lmax : int Maximum spherical harmonic degree mmax : int Maximum spherical harmonic order grid : str Grid type (``"equiangular"``, ``"legendre-gauss"``, ``"lobatto"``, ``"equiangular-trapezoidal"``), by default ``"equiangular"`` norm : str Normalization convention (``"ortho"``, ``"schmidt"``, ``"unnorm"``), by default ``"ortho"``. csphase : bool Whether to include the Condon--Shortley phase factor :math:`(-1)^m`, by default ``True``. Examples -------- >>> import torch >>> import torch_harmonics as th >>> nlat, nlon = 128, 256 >>> sht = th.RealSHT(nlat, nlon).cuda() >>> signal = torch.randn(1, nlat, nlon, device="cuda") >>> coeffs = sht(signal) # shape (1, lmax, mmax), complex >>> coeffs.shape torch.Size([1, 128, 129]) .. note:: This module uses **cuFFT** (via :func:`torch.fft.rfft`) to compute the longitudinal Fourier transform efficiently. When running in **float16** or **bfloat16** precision, cuFFT requires the transformed dimension (``nlon``) to be a **power of two**. If your grid does not satisfy this constraint and the module is called inside a :class:`torch.autocast` context, guard it with ``torch.autocast(device_type="cuda", enabled=False)``:: with torch.autocast(device_type="cuda", dtype=torch.float16): # ... other half-precision work ... with torch.autocast(device_type="cuda", enabled=False): coeffs = sht(signal.float()) References ---------- :cite:`Schaeffer2013`, :cite:`Wang2018` """ def __init__(self, nlat, nlon, lmax=None, mmax=None, grid="equiangular", norm="ortho", csphase=True): super().__init__() self.nlat = nlat self.nlon = nlon self.grid = grid self.norm = norm self.csphase = csphase # TODO: include assertions regarding the dimensions # compute quadrature points and lmax based on the exactness of the quadrature if self.grid == "legendre-gauss": cost, weights = legendre_gauss_weights(nlat, -1, 1) elif self.grid == "lobatto": cost, weights = lobatto_weights(nlat, -1, 1) elif self.grid == "equiangular": cost, weights = clenshaw_curtiss_weights(nlat, -1, 1) else: raise (ValueError("Unknown quadrature mode")) # apply cosine transform and flip them tq = torch.flip(torch.arccos(cost), dims=(0,)) # determine maximum degrees based on triangular truncation self.lmax, self.mmax = truncate_sht(self.nlat, self.nlon, lmax, mmax, self.grid) # combine quadrature weights with the legendre weights pct = _precompute_legpoly(self.mmax, self.lmax, tq, norm=self.norm, csphase=self.csphase) weights = torch.einsum("mlk,k->mlk", pct, weights).contiguous() # remember quadrature weights self.register_buffer("weights", weights, persistent=False) def extra_repr(self): return f"nlat={self.nlat}, nlon={self.nlon},\n lmax={self.lmax}, mmax={self.mmax},\n grid={self.grid}, csphase={self.csphase}"
[docs] def forward(self, x: torch.Tensor): """ Compute the forward (real) spherical harmonic transform. Parameters ---------- x : torch.Tensor Real-valued signal on the sphere of shape ``(..., nlat, nlon)``. Returns ------- torch.Tensor Complex spherical harmonic coefficients of shape ``(..., lmax, mmax)``. """ torch._check(x.dim() >= 2, lambda: f"Expected tensor with at least 2 dimensions but got {x.dim()} instead") torch._check(x.shape[-2] == self.nlat, lambda: f"Expected latitudes shape[-2]=={self.nlat}, got {x.shape[-2]}") torch._check(x.shape[-1] == self.nlon, lambda: f"Expected longitudes shape[-1]=={self.nlon}, got {x.shape[-1]}") # apply real fft in the longitudinal direction x = 2.0 * torch.pi * rfft(x, nmodes=self.mmax, dim=-1, norm="forward") # transpose to put the contraction dim (nlat) on the fast axis x = x.transpose(-1, -2) x_re = x.real.contiguous() x_im = x.imag.contiguous() # Legendre-Gauss quadrature: contract over k=nlat (stride-1 in both operands) w = self.weights.to(x_re.dtype) out_re = torch.einsum("...mk,mlk->...lm", x_re, w) out_im = torch.einsum("...mk,mlk->...lm", x_im, w) # the ...lm einsum output is non-contiguous (l ends up stride-1, m slow); torch.complex # preserves those strides, but inductor's meta kernel for aten.complex predicts a contiguous # layout, tripping assert_size_stride under torch.compile(dynamic=False). Force contiguous. return torch.complex(out_re.contiguous(), out_im.contiguous())
[docs] class InverseRealSHT(nn.Module): r""" Defines a module for computing the inverse (real-valued) SHT. Precomputes Legendre Gauss nodes, weights and associated Legendre polynomials on these nodes. Given complex spherical harmonic coefficients :math:`\hat{f}_l^m`, the inverse scalar SHT reconstructs the real-valued signal on the sphere via Legendre synthesis followed by an inverse FFT: .. math:: f(\theta, \lambda) = \sum_{l=0}^{l_{\max}-1} \sum_{m=0}^{m_{\max}-1} \hat{f}_l^m\, Y_l^m(\theta, \lambda) .. seealso:: :doc:`/guide/spherical_harmonic_transforms` User guide with the full mathematical derivation, normalization conventions, grid types, and worked examples. Parameters ---------- nlat : int Number of latitude points nlon : int Number of longitude points lmax : int Maximum spherical harmonic degree mmax : int Maximum spherical harmonic order grid : str Grid type (``"equiangular"``, ``"legendre-gauss"``, ``"lobatto"``, ``"equiangular-trapezoidal"``), by default ``"equiangular"`` norm : str Normalization convention (``"ortho"``, ``"schmidt"``, ``"unnorm"``), by default ``"ortho"``. csphase : bool Whether to include the Condon--Shortley phase factor :math:`(-1)^m`, by default ``True``. Examples -------- >>> import torch >>> import torch_harmonics as th >>> nlat, nlon = 128, 256 >>> isht = th.InverseRealSHT(nlat, nlon).cuda() >>> coeffs = torch.randn(1, 128, 129, dtype=torch.cfloat, device="cuda") >>> signal = isht(coeffs) # shape (1, 128, 256), real >>> signal.shape torch.Size([1, 128, 256]) .. note:: This module uses **cuFFT** (via :func:`torch.fft.irfft`) to compute the longitudinal inverse Fourier transform efficiently. When running in **float16** or **bfloat16** precision, cuFFT requires the transformed dimension (``nlon``) to be a **power of two**. If your grid does not satisfy this constraint and the module is called inside a :class:`torch.autocast` context, guard it with ``torch.autocast(device_type="cuda", enabled=False)``:: with torch.autocast(device_type="cuda", dtype=torch.float16): # ... other half-precision work ... with torch.autocast(device_type="cuda", enabled=False): signal = isht(coeffs.to(torch.cfloat)) .. note:: The inverse real FFT (C2R transform) expects the DC component (:math:`m = 0`) and, when ``nlon`` is even, the Nyquist component (:math:`m = N_\lambda / 2`) to be purely real. This routine zeros out the imaginary parts of these components before calling the transform. Raises ------ ValueError If the grid type is unknown References ---------- :cite:`Schaeffer2013`, :cite:`Wang2018` """ def __init__(self, nlat, nlon, lmax=None, mmax=None, grid="equiangular", norm="ortho", csphase=True): super().__init__() self.nlat = nlat self.nlon = nlon self.grid = grid self.norm = norm self.csphase = csphase # compute quadrature points if self.grid == "legendre-gauss": cost, _ = legendre_gauss_weights(nlat, -1, 1) elif self.grid == "lobatto": cost, _ = lobatto_weights(nlat, -1, 1) elif self.grid == "equiangular": cost, _ = clenshaw_curtiss_weights(nlat, -1, 1) else: raise (ValueError("Unknown quadrature mode")) # apply cosine transform and flip them t = torch.flip(torch.arccos(cost), dims=(0,)) # determine maximum degrees based on triangular truncation self.lmax, self.mmax = truncate_sht(self.nlat, self.nlon, lmax, mmax, self.grid) # precompute associated Legendre polynomials # store as (mmax, nlat, lmax) so the contraction dim l is stride-1 pct = _precompute_legpoly(self.mmax, self.lmax, t, norm=self.norm, inverse=True, csphase=self.csphase) pct = pct.permute(0, 2, 1).contiguous() # register buffer self.register_buffer("pct", pct, persistent=False) def extra_repr(self): return f"nlat={self.nlat}, nlon={self.nlon},\n lmax={self.lmax}, mmax={self.mmax},\n grid={self.grid}, csphase={self.csphase}"
[docs] def forward(self, x: torch.Tensor): """ Compute the inverse (real) spherical harmonic transform. Parameters ---------- x : torch.Tensor Complex spherical harmonic coefficients of shape ``(..., lmax, mmax)``. Returns ------- torch.Tensor Real-valued signal on the sphere of shape ``(..., nlat, nlon)``. """ torch._check(x.dim() >= 2, lambda: f"Expected tensor with at least 2 dimensions but got {x.dim()} instead") torch._check(x.shape[-2] == self.lmax, lambda: f"Expected spherical harmonic degrees (lmax) shape[-2]=={self.lmax}, got {x.shape[-2]}") torch._check(x.shape[-1] == self.mmax, lambda: f"Expected spherical harmonic orders (mmax) shape[-1]=={self.mmax}, got {x.shape[-1]}") # transpose to put the contraction dim (lmax) on the fast axis x = x.transpose(-1, -2) x_re = x.real.contiguous() x_im = x.imag.contiguous() # legendre transformation: contract over l=lmax (stride-1 in both operands) # pct layout: (mmax, nlat, lmax) w = self.pct.to(x_re.dtype) out_re = torch.einsum("...ml,mkl->...km", x_re, w) out_im = torch.einsum("...ml,mkl->...km", x_im, w) # force contiguous: the einsum output is non-contiguous and inductor's aten.complex meta # predicts a contiguous layout, tripping assert_size_stride under torch.compile (see fwd SHT). x = torch.complex(out_re.contiguous(), out_im.contiguous()) # apply the inverse (real) FFT x = irfft(x, n=self.nlon, dim=-1, norm="forward") return x
[docs] class RealVectorSHT(nn.Module): r""" Defines a module for computing the forward (real) vector SHT. Precomputes Legendre Gauss nodes, weights and associated Legendre polynomials on these nodes. The SHT is applied to the last three dimensions of the input. Decomposes a tangential vector field :math:`\mathbf{v} = v_\theta\,\hat{e}_\theta + v_\lambda\,\hat{e}_\lambda` into **spheroidal** and **toroidal** spectral coefficients :math:`\hat{s}_l^m` and :math:`\hat{t}_l^m` using the derivatives of the associated Legendre polynomials. .. seealso:: :doc:`/guide/spherical_harmonic_transforms` User guide with the full mathematical derivation of the vector SHT formulas, normalization conventions, and worked examples. Parameters ---------- nlat : int Number of latitude points nlon : int Number of longitude points lmax : int Maximum spherical harmonic degree mmax : int Maximum spherical harmonic order grid : str Grid type (``"equiangular"``, ``"legendre-gauss"``, ``"lobatto"``, ``"equiangular-trapezoidal"``), by default ``"equiangular"`` norm : str Normalization convention (``"ortho"``, ``"schmidt"``, ``"unnorm"``), by default ``"ortho"``. csphase : bool Whether to include the Condon--Shortley phase factor :math:`(-1)^m`, by default ``True``. Examples -------- >>> import torch >>> import torch_harmonics as th >>> nlat, nlon = 128, 256 >>> vsht = th.RealVectorSHT(nlat, nlon).cuda() >>> vector_field = torch.randn(1, 2, nlat, nlon, device="cuda") >>> coeffs = vsht(vector_field) # shape (1, 2, lmax, mmax), complex >>> coeffs.shape torch.Size([1, 2, 128, 129]) .. note:: This module uses **cuFFT** (via :func:`torch.fft.rfft`) to compute the longitudinal Fourier transform efficiently. When running in **float16** or **bfloat16** precision, cuFFT requires the transformed dimension (``nlon``) to be a **power of two**. If your grid does not satisfy this constraint and the module is called inside a :class:`torch.autocast` context, guard it with ``torch.autocast(device_type="cuda", enabled=False)``:: with torch.autocast(device_type="cuda", dtype=torch.float16): # ... other half-precision work ... with torch.autocast(device_type="cuda", enabled=False): coeffs = vsht(vector_field.float()) References ---------- :cite:`Schaeffer2013`, :cite:`Wang2018` """ def __init__(self, nlat, nlon, lmax=None, mmax=None, grid="equiangular", norm="ortho", csphase=True): super().__init__() self.nlat = nlat self.nlon = nlon self.grid = grid self.norm = norm self.csphase = csphase # compute quadrature points if self.grid == "legendre-gauss": cost, weights = legendre_gauss_weights(nlat, -1, 1) elif self.grid == "lobatto": cost, weights = lobatto_weights(nlat, -1, 1) elif self.grid == "equiangular": cost, weights = clenshaw_curtiss_weights(nlat, -1, 1) else: raise (ValueError("Unknown quadrature mode")) # apply cosine transform and flip them tq = torch.flip(torch.arccos(cost), dims=(0,)) # determine maximum degrees based on triangular truncation self.lmax, self.mmax = truncate_sht(self.nlat, self.nlon, lmax, mmax, self.grid) # precompute associated Legendre polynomials dpct = _precompute_dlegpoly(self.mmax, self.lmax, tq, norm=self.norm, csphase=self.csphase) # combine integration weights, normalization factor in to one: l = torch.arange(0, self.lmax) norm_factor = 1.0 / l / (l + 1) norm_factor[0] = 1.0 weights = torch.einsum("dmlk,k,l->dmlk", dpct, weights, norm_factor).contiguous() # since the second component is imaginary, we need to take complex conjugation into account weights[1] = -1 * weights[1] # remember quadrature weights self.register_buffer("weights", weights, persistent=False) def extra_repr(self): return f"nlat={self.nlat}, nlon={self.nlon},\n lmax={self.lmax}, mmax={self.mmax},\n grid={self.grid}, csphase={self.csphase}"
[docs] def forward(self, x: torch.Tensor): """ Compute the forward (real) vector spherical harmonic transform. Parameters ---------- x : torch.Tensor Real-valued tangential vector field of shape ``(..., 2, nlat, nlon)``, where the size-2 dimension holds the two tangential (colatitude, longitude) components. Returns ------- torch.Tensor Complex vector harmonic coefficients of shape ``(..., 2, lmax, mmax)``, where the size-2 dimension holds the spheroidal and toroidal components. """ torch._check(x.dim() >= 3, lambda: f"Expected tensor with at least 3 dimensions but got {x.dim()} instead") torch._check(x.shape[-3] == 2, lambda: f"Expected vector field shape[-3]==2, got {x.shape[-3]}") torch._check(x.shape[-2] == self.nlat, lambda: f"Expected latitudes shape[-2]=={self.nlat}, got {x.shape[-2]}") torch._check(x.shape[-1] == self.nlon, lambda: f"Expected longitudes shape[-1]=={self.nlon}, got {x.shape[-1]}") # apply real fft in the longitudinal direction x = 2.0 * torch.pi * rfft(x, nmodes=self.mmax, dim=-1, norm="forward") # transpose to put the contraction dim (nlat) on the fast axis x = x.transpose(-1, -2) x_re = x.real.contiguous() x_im = x.imag.contiguous() w0 = self.weights[0].to(x_re.dtype) w1 = self.weights[1].to(x_re.dtype) # contraction - spheroidal component s_re = torch.einsum("...mk,mlk->...lm", x_re[..., 0, :, :], w0) - torch.einsum("...mk,mlk->...lm", x_im[..., 1, :, :], w1) s_im = torch.einsum("...mk,mlk->...lm", x_im[..., 0, :, :], w0) + torch.einsum("...mk,mlk->...lm", x_re[..., 1, :, :], w1) # contraction - toroidal component t_re = -torch.einsum("...mk,mlk->...lm", x_im[..., 0, :, :], w1) - torch.einsum("...mk,mlk->...lm", x_re[..., 1, :, :], w0) t_im = torch.einsum("...mk,mlk->...lm", x_re[..., 0, :, :], w1) - torch.einsum("...mk,mlk->...lm", x_im[..., 1, :, :], w0) return torch.stack((torch.complex(s_re, s_im), torch.complex(t_re, t_im)), dim=-3)
[docs] class InverseRealVectorSHT(nn.Module): r""" Defines a module for computing the inverse (real-valued) vector SHT. Precomputes Legendre Gauss nodes, weights and associated Legendre polynomials on these nodes. Given spheroidal and toroidal spectral coefficients :math:`\hat{s}_l^m` and :math:`\hat{t}_l^m`, reconstructs the tangential vector field on the sphere via Legendre synthesis with the derivatives of the associated Legendre polynomials, followed by an inverse real FFT. .. seealso:: :doc:`/guide/spherical_harmonic_transforms` User guide with the full mathematical derivation of the inverse vector SHT formulas, normalization conventions, and worked examples. Parameters ---------- nlat : int Number of latitude points nlon : int Number of longitude points lmax : int Maximum spherical harmonic degree mmax : int Maximum spherical harmonic order grid : str Grid type (``"equiangular"``, ``"legendre-gauss"``, ``"lobatto"``, ``"equiangular-trapezoidal"``), by default ``"equiangular"`` norm : str Normalization convention (``"ortho"``, ``"schmidt"``, ``"unnorm"``), by default ``"ortho"``. csphase : bool Whether to include the Condon--Shortley phase factor :math:`(-1)^m`, by default ``True``. Examples -------- >>> import torch >>> import torch_harmonics as th >>> nlat, nlon = 128, 256 >>> ivsht = th.InverseRealVectorSHT(nlat, nlon).cuda() >>> coeffs = torch.randn(1, 2, 128, 129, dtype=torch.cfloat, device="cuda") >>> vector_field = ivsht(coeffs) # shape (1, 2, 128, 256), real >>> vector_field.shape torch.Size([1, 2, 128, 256]) .. note:: This module uses **cuFFT** (via :func:`torch.fft.irfft`) to compute the longitudinal inverse Fourier transform efficiently. When running in **float16** or **bfloat16** precision, cuFFT requires the transformed dimension (``nlon``) to be a **power of two**. If your grid does not satisfy this constraint and the module is called inside a :class:`torch.autocast` context, guard it with ``torch.autocast(device_type="cuda", enabled=False)``:: with torch.autocast(device_type="cuda", dtype=torch.float16): # ... other half-precision work ... with torch.autocast(device_type="cuda", enabled=False): vector_field = ivsht(coeffs.to(torch.cfloat)) .. note:: The inverse real FFT (C2R transform) expects the DC component (:math:`m = 0`) and, when ``nlon`` is even, the Nyquist component (:math:`m = N_\lambda / 2`) to be purely real. This routine zeros out the imaginary parts of these components before calling the transform. References ---------- :cite:`Schaeffer2013`, :cite:`Wang2018` """ def __init__(self, nlat, nlon, lmax=None, mmax=None, grid="equiangular", norm="ortho", csphase=True): super().__init__() self.nlat = nlat self.nlon = nlon self.grid = grid self.norm = norm self.csphase = csphase # compute quadrature points if self.grid == "legendre-gauss": cost, _ = legendre_gauss_weights(nlat, -1, 1) elif self.grid == "lobatto": cost, _ = lobatto_weights(nlat, -1, 1) elif self.grid == "equiangular": cost, _ = clenshaw_curtiss_weights(nlat, -1, 1) else: raise (ValueError("Unknown quadrature mode")) # apply cosine transform and flip them t = torch.flip(torch.arccos(cost), dims=(0,)) # determine maximum degrees based on triangular truncation self.lmax, self.mmax = truncate_sht(self.nlat, self.nlon, lmax, mmax, self.grid) # precompute associated Legendre polynomials # store as (2, mmax, nlat, lmax) so the contraction dim l is stride-1 dpct = _precompute_dlegpoly(self.mmax, self.lmax, t, norm=self.norm, inverse=True, csphase=self.csphase) dpct = dpct.permute(0, 1, 3, 2).contiguous() # register weights self.register_buffer("dpct", dpct, persistent=False) def extra_repr(self): return f"nlat={self.nlat}, nlon={self.nlon},\n lmax={self.lmax}, mmax={self.mmax},\n grid={self.grid}, csphase={self.csphase}"
[docs] def forward(self, x: torch.Tensor): """ Compute the inverse (real) vector spherical harmonic transform. Parameters ---------- x : torch.Tensor Complex vector harmonic coefficients of shape ``(..., 2, lmax, mmax)``, where the size-2 dimension holds the spheroidal and toroidal components. Returns ------- torch.Tensor Real-valued tangential vector field of shape ``(..., 2, nlat, nlon)``, where the size-2 dimension holds the two tangential (colatitude, longitude) components. """ torch._check(x.dim() >= 3, lambda: f"Expected tensor with at least 3 dimensions but got {x.dim()} instead") torch._check(x.shape[-3] == 2, lambda: f"Expected vector field shape[-3]==2, got {x.shape[-3]}") torch._check(x.shape[-2] == self.lmax, lambda: f"Expected spherical harmonic degrees (lmax) shape[-2]=={self.lmax}, got {x.shape[-2]}") torch._check(x.shape[-1] == self.mmax, lambda: f"Expected spherical harmonic orders (mmax) shape[-1]=={self.mmax}, got {x.shape[-1]}") # transpose to put the contraction dim (lmax) on the fast axis x = x.transpose(-1, -2) x_re = x.real.contiguous() x_im = x.imag.contiguous() # dpct layout: (2, mmax, nlat, lmax) — contract over l (stride-1 in both operands) d0 = self.dpct[0].to(x_re.dtype) d1 = self.dpct[1].to(x_re.dtype) # contraction - spheroidal component srl = torch.einsum("...ml,mkl->...km", x_re[..., 0, :, :], d0) - torch.einsum("...ml,mkl->...km", x_im[..., 1, :, :], d1) sim = torch.einsum("...ml,mkl->...km", x_im[..., 0, :, :], d0) + torch.einsum("...ml,mkl->...km", x_re[..., 1, :, :], d1) # contraction - toroidal component trl = -torch.einsum("...ml,mkl->...km", x_im[..., 0, :, :], d1) - torch.einsum("...ml,mkl->...km", x_re[..., 1, :, :], d0) tim = torch.einsum("...ml,mkl->...km", x_re[..., 0, :, :], d1) - torch.einsum("...ml,mkl->...km", x_im[..., 1, :, :], d0) # reassemble and apply inverse FFT xs = torch.stack((torch.complex(srl, sim), torch.complex(trl, tim)), dim=-3) x = irfft(xs, n=self.nlon, dim=-1, norm="forward") return x