Conditioning of the SHT#

This notebook investigates the conditioning of the spherical harmonic transform (SHT) by constructing the Vandermonde matrix \(S\) and analyzing its Gramian \(G = S^T \mathrm{diag}(\omega)\, S\).

A well-conditioned SHT means that the analysis (forward) and synthesis (inverse) steps are numerically stable. We verify this by checking that the condition number of the Gramian is close to 1 and that the round-trip \(\mathrm{ISHT}(\mathrm{SHT}(u)) \approx u\) reproduces the input.

Setup#

import numpy as np
import torch

from torch_harmonics.quadrature import legendre_gauss_weights, clenshaw_curtiss_weights
from torch_harmonics import RealSHT, InverseRealSHT

import matplotlib.pyplot as plt

from torch_harmonics.plotting import plot_sphere
nlat = 32
nlon = 2*nlat
grid = "legendre-gauss"

# for quadrature and plotting
if grid == "legendre-gauss":
    lmax = mmax = nlat
    xq, wq = legendre_gauss_weights(nlat)
elif grid =="equiangular":
    lmax = mmax = nlat//2-1
    xq, wq = clenshaw_curtiss_weights(nlat)

sht = RealSHT(nlat, nlon, lmax=lmax, mmax=mmax, grid=grid)
isht = InverseRealSHT(nlat, nlon, lmax=lmax, mmax=mmax, grid=grid)

lat = torch.arccos(xq)
omega = torch.pi * wq.float() / nlat
omega = omega.reshape(-1, 1).repeat(1, nlon)

omega.sum()
tensor(12.5664)

Constructing the Vandermonde matrix#

The Vandermonde matrix \(S\) encodes the action of the inverse SHT: \(u = S\,\theta\), where \(\theta\) are the spectral coefficients and \(u\) the grid values. We construct it column by column, applying the ISHT to each unit basis vector \(e_{l,m}\) in spectral space.

# forming the Vandermonde matrix
nmodes = int(lmax * (lmax+1) / 2)
e = torch.zeros(nmodes, lmax, mmax, dtype=torch.complex64)

midx = lambda l,m : l*(l+1)//2 + m

for l in range(lmax):
    for m in range(l+1):
        e[midx(l,m), l, m] = 1.0

vdm = isht(e) # nmodes x nlat x nlon

Each column of the Vandermonde matrix corresponds to a spherical harmonic \(Y_l^m\). Let us visualize the first few to verify the construction:

plt_lmax = 6

fig = plt.figure(figsize=(12, 8), layout='constrained')
subfigs = fig.subfigures(plt_lmax, plt_lmax)

for l in range(plt_lmax):
    for m in range(l+1):
        plot_sphere(vdm[midx(l,m)], fig=subfigs[l, m], projection="orthographic", title=f"l={l}, m={m}", lat=(lat - np.pi/2))
../_images/8f3cb4bf9ff0686847d90dca9eb1f3252c8758897f0a12f3f22c5cb31ed276ab.png

Gramian analysis#

The Gramian \(G = S^T \mathrm{diag}(\omega)\, S\) captures how well the quadrature weights preserve orthogonality of the spherical harmonics. For an exact quadrature rule, \(G\) is the identity matrix (up to a scaling factor). Any deviation indicates numerical error in the transform.

gramian = torch.einsum("iqr,jqr,qr->ij", vdm, vdm, omega)
plt.imshow(gramian, interpolation="nearest", cmap="plasma")
plt.colorbar()
<matplotlib.colorbar.Colorbar at 0x16b444050>
../_images/0468085c85ef69e4617d9bfcc3e4fe2653a68084f9255feeb68d24df42dd2eb1.png
print(torch.max(gramian))
print(torch.argmax(gramian))
print(torch.min(gramian))
print(torch.argmin(gramian))
tensor(2.0000)
tensor(113206)
tensor(-2.2352e-07)
tensor(178452)

The condition number of \(G\) tells us how much numerical error is amplified through the analysis–synthesis cycle. A condition number close to 1 indicates a well-conditioned transform:

np.linalg.cond(gramian)
np.float32(2.000001)

The diagonal entries of \(G\) correspond to the norms \(\|Y_l^m\|^2\) under the discrete quadrature. A flat diagonal confirms that all modes are weighted equally:

plt.plot(np.diag(gramian))
[<matplotlib.lines.Line2D at 0x16b4fddc0>]
../_images/65a4d81846bec0b86dd5f4f9e4edb636db5da54588d8fb2662a3b37f90f4f45e.png

Projection operator \(S^{-1} S\)#

We can also construct the full forward (\(S\)) and inverse (\(S^{-1}\)) matrices explicitly and examine their product. The composition \(S^{-1} S\) acts as a projection operator from grid space onto the space of band-limited functions. For a point that lies in the column space of \(S^{-1}\), the round-trip is exact.

# forming the Vandermonde matrix
nmodes = int(lmax * (lmax+1) / 2)

e = torch.zeros(lmax, mmax, lmax, mmax, dtype=torch.complex64)
for l in range(lmax):
    for m in range(l+1):
        e[l, m, l, m] = 1.0

Sinv = isht(e).cfloat() # l x m x nlat x nlon

e = torch.zeros(nlat, nlon, nlat, nlon, dtype=torch.float32)
for lat in range(nlat):
    for lon in range(nlon):
        e[lat, lon, lat, lon] = 1.0

S = sht(e) # nlat x nlon x l x m
out = torch.einsum("lmop,lmqr->lopqr", Sinv, Sinv.conj())
plot_sphere(out[3, 29, 16].abs(), projection="orthographic", colorbar=True)
<cartopy.mpl.geocollection.GeoQuadMesh at 0x16b667d10>
../_images/9ebf5798abf2d978377dfab052fa3d7f69d4d0a2c946f1078253c9792c06e7b0.png

The product \(S^{-1} S\) should approximate an identity matrix. Let us visualize it as a dense matrix and inspect individual slices:

SS = torch.einsum("lmqr, oplm->qrop", Sinv, S)
plt.imshow(SS.reshape(nlat*nlon, nlat*nlon).abs(), interpolation="nearest", cmap="plasma")
plt.colorbar()
<matplotlib.colorbar.Colorbar at 0x16b6fa8d0>
../_images/5113a144ebcb7f66afa54e0da1319f198a52c231c389d1370bcfb234c7c94334.png
plt.imshow(SS[:,0,:,0].abs(), interpolation="nearest", cmap="plasma")
plt.colorbar()
<matplotlib.colorbar.Colorbar at 0x16b7c50a0>
../_images/d72113877fb1cc8cd546b024674af16287835e399a5efa02889386a93d3b4750.png

Round-trip test#

As a final sanity check, we apply the SHT round-trip to a point source (delta function on the grid). A well-conditioned transform will spread the point source into a smooth band-limited interpolant, then reconstruct it faithfully:

field = torch.zeros(nlat, nlon)
field[16,3] = 1.0

plot_sphere(field, projection="orthographic", colorbar=True)
plot_sphere(isht(sht(field)), projection="orthographic", colorbar=True)
<cartopy.mpl.geocollection.GeoQuadMesh at 0x16e204ec0>
../_images/87f9ece8b124492a87e8a374845beb6a19de0332fac7eb9d3a3db255f159e809.png ../_images/16dbce78f82ade055e52f0a13814924bf1bfbf88afd0d1732f2c2f131795ed0b.png