HEALPixSHT — spherical harmonics on the full sky#
healpix_sht is the fast spherical harmonic transform for a full-sky HEALPix
map: analysis, synthesis and power spectra, differentiable throughout. It
handles scalar fields (spin-0), CMB polarisation (spin-2) and vector fields
(spin-1), including a one-call split of any (u, v) flow into its divergence
and curl.
from healpix_analyse.healpix_sht import HEALPixSHT
Spin-0 needs only torch, numpy and healpix-geo. Spin ≥ 1 additionally
needs quaternionic and spherical:
pip install quaternionic spherical # only for spin > 0
For a grid that is not full-sky HEALPix — ERA5, a regular lat/lon grid, a Gaussian grid — use alm_latlon instead.
This page is the guide. The methods are listed in HEALPixSHT — API reference, and the conventions and the ring-FFT algorithm are in HEALPixSHT — conventions and algorithm.
Quickstart — scalar field (spin-0)#
import numpy as np
import torch
import healpy as hp
from healpix_analyse.healpix_sht import HEALPixSHT
level = 6
nside = 2**level
t_map = np.random.randn(12 * nside**2).astype(np.float32)
# --- create the transform object (precomputes geometry once) ---
sht = HEALPixSHT(level=level)
print(sht)
# HEALPixSHT(level=6, nside=64, lmax=191, n_rings=255, n_alm=18528, dtype=torch.float32, device=cpu)
# --- analysis: map → alm ---
alm = sht.map2alm(t_map) # torch.Tensor, shape (18528,), complex64
alm_hp = hp.map2alm(t_map, iter=0) # healpy reference
# --- synthesis: alm → map ---
t_rec = sht.alm2map(alm) # torch.Tensor, shape (49152,), float32
# --- power spectrum ---
cl = sht.anafast(t_map) # shape (192,)
cl_hp = hp.anafast(t_map, iter=0)
# --- verify against healpy ---
print(np.max(np.abs(alm.numpy() - alm_hp))) # < 1e-6 (float32)
print(np.max(np.abs(cl.numpy() - cl_hp))) # < 1e-10
NESTED ordering#
Both analysis and synthesis accept a nest=True keyword for NESTED-ordered maps:
alm = sht.map2alm(t_map_nested, nest=True)
t_rec = sht.alm2map(alm, nest=True)
Batched maps#
The leading dimensions are arbitrary, so you can process a whole batch at once:
batch = torch.randn(16, 12 * nside**2) # 16 maps
alm = sht.map2alm(batch) # shape (16, 18528)
t_rec = sht.alm2map(alm) # shape (16, 49152)
Spin-2: CMB polarisation Q/U → E/B#
Spin-2 decomposition separates a polarisation field (Q, U) into its E-mode (gradient, curl-free) and B-mode (divergence-free) components — the standard decomposition used throughout CMB cosmology.
import healpy as hp
from healpix_analyse.healpix_sht import HEALPixSHT
nside = 64
sht = HEALPixSHT(level=6)
Q = np.random.randn(12 * nside**2).astype(np.float32)
U = np.random.randn(12 * nside**2).astype(np.float32)
# --- analysis: (Q, U) → (almE, almB) ---
almE, almB = sht.map2alm_spin(Q, U, spin=2)
# almE, almB: complex Tensors of shape (n_alm,)
# --- synthesis: (almE, almB) → (Q_rec, U_rec) ---
Q_rec, U_rec = sht.alm2map_spin(almE, almB, spin=2)
# --- cross-check against healpy ---
almE_hp, almB_hp = hp.map2alm_spin(np.vstack([Q, U]), 2)
print(np.max(np.abs(almE.numpy() - almE_hp))) # < 1e-5
# --- EE / BB / EB power spectra ---
maps_QU = torch.stack([
torch.as_tensor(Q),
torch.as_tensor(U),
], dim=-2) # shape (2, N)
cl_EEB = sht.anafast(maps_QU, spin=2) # shape (3, lmax+1)
cl_EE = cl_EEB[0]
cl_BB = cl_EEB[1]
cl_EB = cl_EEB[2]
Spin-1: curl and divergence decomposition#
This is perhaps the most powerful feature of healpix_sht for Earth-observation
and fluid-dynamics applications.
The problem#
Any smooth vector field v = (u, v) defined on the sphere can be uniquely decomposed into two scalar potentials:
v = ∇Φ + ∇×Ψ
where ∇Φ is the divergent (irrotational) part and ∇×Ψ is the rotational (non-divergent) part. This is the Helmholtz–Hodge decomposition on the sphere.
The two scalar maps div and curl (or equivalently Φ and Ψ) are reconstructed via spin-1 spherical harmonics. In terms of the E/B decomposition of a spin-1 field:
almE encodes the divergent component (gradient mode)
almB encodes the rotational component (curl mode)
One-call interface: uv_to_curl_div#
from healpix_analyse.healpix_sht import HEALPixSHT
import numpy as np
nside = 64
sht = HEALPixSHT(level=6)
# Wind or ocean current field: u = east component, v = north component
u = np.random.randn(12 * nside**2).astype(np.float32)
v = np.random.randn(12 * nside**2).astype(np.float32)
# --- one call: vector field → divergence and curl maps ---
div, curl = sht.uv_to_curl_div(u, v)
# div : divergence map (∇·v), shape (N,) real Tensor
# curl : vorticity map (∇×v), shape (N,) real Tensor
What each output represents#
Output |
Physical meaning |
Zero when… |
|---|---|---|
|
Divergence ∇·v — sources and sinks |
the field is purely rotational (e.g. geostrophic flow) |
|
Vorticity ∇×v — rotation intensity |
the field is purely potential (e.g. gravity waves) |
Step-by-step version#
If you need the intermediate harmonic coefficients (e.g. for filtering or cross-spectra), use the two underlying calls directly:
# decompose (u, v) → E-mode (divergent) and B-mode (rotational) alm
almE, almB = sht.map2alm_spin(u, v, spin=1)
# optional: filter in harmonic space (e.g. low-pass at l < 50)
almE_filtered = almE.clone()
almB_filtered = almB.clone()
# ... set high-l coefficients to zero ...
# reconstruct
div_filtered, curl_filtered = sht.alm2map_spin(almE_filtered, almB_filtered, spin=1)
Application: separating wind regimes#
# Decompose ERA5-style wind into rotational and divergent parts
nside = 128
sht = HEALPixSHT(level=7)
# u10, v10: 10-metre wind components on a HEALPix grid, ring-ordered
div, curl = sht.uv_to_curl_div(u10, v10)
# div captures convergence zones (precipitation, fronts)
# curl captures cyclones, anticyclones, jet streams
Application: ocean surface currents#
u_ssh, v_ssh = compute_geostrophic_currents(ssh_map) # from SSH gradients
div, curl = sht.uv_to_curl_div(u_ssh, v_ssh)
# div ≈ 0 for geostrophic flow (purely rotational)
# curl ≠ 0 shows the eddy field
Power spectra#
sht.anafast below is the production-ready angular power spectrum for a
full-sky HEALPix map, validated against healpy. The separate
powerspectra/powerspectra_lonlat functions in powerspectra.py compute a
different, flat-sky-style spectrum through AlmTransform and are still under
development — see Flat-sky power spectra: powerspectra, powerspectra_lonlat, ps for their current status before using
them instead of anafast.
Scalar auto-spectrum#
cl = sht.anafast(t_map) # shape (lmax+1,)
Scalar cross-spectrum#
cl_cross = sht.anafast(t_map1, map2=t_map2) # shape (lmax+1,)
Polarisation (spin-2) spectra: EE, BB, EB#
maps_QU = torch.stack([torch.as_tensor(Q), torch.as_tensor(U)], dim=-2) # (2, N)
cl3 = sht.anafast(maps_QU, spin=2) # shape (3, lmax+1)
cl_EE, cl_BB, cl_EB = cl3[0], cl3[1], cl3[2]
Batched spectra#
maps_batch = torch.randn(32, 12 * nside**2)
cl_batch = sht.anafast(maps_batch) # shape (32, lmax+1)
Gradient-based learning#
All operations in the forward pass — torch.fft.fft, torch.fft.ifft,
torch.einsum — are fully supported by torch.autograd. Precomputed geometry
(Legendre tables, phase matrices, permutation indices) is stored as plain NumPy
arrays or non-grad tensors and does not participate in the gradient graph.
This means you can use map2alm, alm2map, map2alm_spin, and alm2map_spin
directly inside a neural network or an optimisation loop:
import torch
from healpix_analyse.healpix_sht import HEALPixSHT
nside = 64
sht = HEALPixSHT(level=6, dtype=torch.float64)
target = torch.randn(12 * nside**2, dtype=torch.float64)
# Learnable map
x = torch.randn(12 * nside**2, dtype=torch.float64, requires_grad=True)
optimizer = torch.optim.Adam([x], lr=1e-2)
for step in range(200):
optimizer.zero_grad()
alm = sht.map2alm(x)
cl = sht.anafast(x)
loss = ((cl - sht.anafast(target)) ** 2).mean()
loss.backward() # gradients flow through fft and einsum
optimizer.step()
if step % 50 == 0:
print(f"step {step:4d} loss = {loss.item():.6e}")
Inside a neural network#
class SpectralLayer(torch.nn.Module):
def __init__(self, level, lmax_out):
super().__init__()
self.sht = HEALPixSHT(level=level)
self.weight = torch.nn.Parameter(
torch.ones(self.sht.n_alm, dtype=torch.complex64)
)
def forward(self, x):
alm = self.sht.map2alm(x) # analysis
alm = alm * self.weight # learnable spectral filter
return self.sht.alm2map(alm) # synthesis