Spherical Harmonics¶
The nuclei of a resolution lie on isolatitude rings with equally spaced
longitudes (see Isolatitude Rings), which is the layout that ducc0’s
ring-based spherical harmonic routines accept. rhealpixdggs.harmonics
feeds them the ring table and handles the bookkeeping: it needs the
optional harmonics extra, which brings ducc0 (wheels are published for
Linux, macOS and Windows), and the rest of the package works without it.
Conventions¶
Coefficients follow healpy: a real field band-limited at degree
\(\ell_{max}\) has one complex coefficient \(a_{\ell m}\) for each
\(0 \le m \le \ell \le \ell_{max}\), stored m-major with
\((\ell, m)\) at alm_index(lmax, l, m) in an array of
alm_size(lmax) entries, and its value is
so the \(m = 0\) coefficients of a real field are real. Values are
attached to nuclei on the authalic sphere, where the cells are equal-area
and the rings sit at their closed-form latitudes, and they travel as a
flat array in ring_order: ring by ring from the north pole, each ring
in cells_on_ring order. to_ring_order and from_ring_order
convert between that array and a dictionary keyed by cell index.
Synthesis and analysis¶
synthesis evaluates a field given by its coefficients at every
nucleus; analysis finds the coefficients that best fit values at the
nuclei. The nuclei are not a quadrature (their rings are not at
Gauss-Legendre latitudes), so analysis is ducc0’s iterative least-squares
solve, pseudo_analysis, and it returns how the fit stopped alongside
the coefficients. A round trip on resolution 2:
>>> import numpy as np
>>> from rhealpixdggs.dggs import WGS84_003
>>> from rhealpixdggs import harmonics
>>> rdggs, resolution, lmax = WGS84_003, 2, 12
>>> geometry = harmonics.ring_geometry(rdggs, resolution)
>>> sorted(geometry), int(geometry['nphi'].sum())
(['nphi', 'phi0', 'ringstart', 'theta'], 486)
>>> rng = np.random.default_rng(0)
>>> size = harmonics.alm_size(lmax)
>>> alm = rng.normal(size=size) + 1j * rng.normal(size=size)
>>> alm[:lmax + 1] = alm[:lmax + 1].real
>>> values = harmonics.synthesis(rdggs, resolution, alm, lmax)
>>> by_cell = harmonics.from_ring_order(rdggs, resolution, values)
>>> len(by_cell), harmonics.ring_order(rdggs, resolution)[0]
(486, np.str_('N44'))
>>> recovered, info = harmonics.analysis(rdggs, resolution, by_cell, lmax)
>>> info.stop_reason, info.iterations < 20
(1, True)
>>> bool(np.abs(recovered - alm).max() < 1e-8)
True
ring_geometry is the description ducc0 takes as keyword arguments,
straight from ring_table: colatitude per ring on the authalic sphere,
cells per ring, longitude of the first nucleus, and each ring’s offset in
the flat array. It is exposed so that other ducc0 routines, such as
adjoint_synthesis or the spin-weighted transforms, can be called on
the same layout.
Analysing a field sampled at the nuclei works the same way. The field \(\sin\theta \cos\lambda\) of the Isolatitude Rings page is a single spherical harmonic, and the fit finds exactly that:
>>> ids = harmonics.ring_order(rdggs, resolution)
>>> theta = np.repeat(geometry['theta'], geometry['nphi'].astype(int))
>>> lon = np.radians(rdggs.nuclei(ids)[:, 0])
>>> alm, info = harmonics.analysis(rdggs, resolution, np.sin(theta) * np.cos(lon), lmax=4)
>>> nonzero = np.abs(alm) > 1e-8
>>> [(l, m) for m in range(5) for l in range(m, 5) if nonzero[harmonics.alm_index(4, l, m)]]
[(1, 1)]
How high a degree a resolution supports¶
An equatorial ring of \(4n\) cells (\(n = N_{side}^k\)) resolves longitudinal wavenumbers up to \(2n\), and the polar rings far fewer, so a band-limited field is only recoverable below degree \(2n\), and the least-squares problem grows ill-conditioned as the degree approaches that limit. Measured on WGS84 with \(N_{side} = 3\), synthesising random coefficients and analysing them back with the default 100 iterations:
Resolution |
Cells |
\(n\) |
Degree |
Degree / \(n\) |
Iterations and relative error |
|---|---|---|---|---|---|
2 |
486 |
9 |
9 / 13 / 17 / 18 |
1.0 / 1.4 / 1.9 / 2.0 |
6, 7, 20 iterations at \(10^{-10}\) or better; at degree 18 the solver reports convergence but the coefficients are wrong (error 0.8) |
3 |
4374 |
27 |
27 / 40 / 48 / 53 / 54 |
1.0 / 1.5 / 1.8 / 2.0 / 2.0 |
5, 6, 12 iterations at \(10^{-10}\); 77 iterations at \(10^{-9}\); at degree 54 the iteration limit is hit and the coefficients are wrong (error 0.3) |
4 |
39366 |
81 |
81 / 121 / 145 / 153 / 157 |
1.0 / 1.5 / 1.8 / 1.9 / 1.9 |
5, 5, 12 iterations at \(10^{-9}\) or better; 93 iterations at \(10^{-8}\); degree 157 needs 666 iterations for \(10^{-7}\) |
At degree \(2n\) several different sets of coefficients produce the
same values at the nuclei, so the solver returns one of them, and it is
not the one the field was made from: the values are reproduced, the
residual is tiny, and the stop reason can still say converged. The
output does not warn you, so the degree has to be chosen in advance. A
safe rule is degrees up to about \(1.8 n\), where a dozen iterations
suffice; between there and \(2n\), raise maxiter and watch
info.stop_reason (1 means converged, 7 means the iteration limit was
hit) and info.residual, knowing that a clean stop is necessary but
not sufficient. These numbers are for exact
band-limited input; a real field sampled at the nuclei has power above
any chosen degree, and that power aliases into the fit, so choose the
degree from the field’s smoothness, not from this table alone.
healpy’s map2alm and alm2map cannot be used instead of this
module: they assume HEALPix’s ring populations, which differ from
rHEALPix’s.
Module reference¶
Spherical harmonic transforms on the nuclei of an rHEALPix grid.
The nuclei of a resolution lie on isolatitude rings with equally spaced
longitudes (see Isolatitude Rings), which is the layout ducc0’s ring-based
spherical harmonic routines accept. This module builds ducc0’s description
of the rings from RHEALPixDGGS.ring_table, lays cell values out in
cells_on_ring order, and wraps ducc0.sht.synthesis (coefficients to
nucleus values) and ducc0.sht.pseudo_analysis (nucleus values to
coefficients, a least-squares fit, since the nuclei are not a quadrature).
Coefficients follow the healpy convention: a real field has complex
coefficients \(a_{\ell m}\) for \(0 \le m \le \ell \le
\ell_{max}\), stored m-major with \((\ell, m)\) at alm_index(lmax, l,
m), and its value is \(\sum_\ell a_{\ell 0} Y_{\ell 0} + 2
\operatorname{Re} \sum_{m > 0} a_{\ell m} Y_{\ell m}\).
The transforms work on the authalic sphere, where the cells are equal-area
and the rings sit at the closed-form latitudes. With n cells per
base-cell side, a field band-limited up to about degree 1.8 n is
recovered by analysis in a dozen iterations; towards degree 2 n the
fit is ill-conditioned and slow, and at 2 n the short polar rings alias:
the solver returns coefficients that reproduce the values but are not the
ones the field was made from, and may still report convergence.
Requires ducc0: pip install rhealpixdggs[harmonics].
- class rhealpixdggs.harmonics.AnalysisInfo(stop_reason: int, iterations: int, residual: float, quality: float)[source]¶
Bases:
NamedTupleHow
analysisstopped: stop_reason as ducc0 reports it (1: the solution converged to the requested tolerance, 2: a least-squares solution was found, 3: the problem is too ill-conditioned, 7: maxiter was reached), the iterations taken, the residual norm relative to the norm of the values, and ducc0’s quality measure of the least-squares solution.
- rhealpixdggs.harmonics.alm_index(lmax: int, l: int, m: int) int[source]¶
Return the position of the coefficient of degree l and order m in the m-major layout healpy and ducc0 use for a field band-limited at degree lmax.
EXAMPLES:
>>> [alm_index(2, l, m) for m in range(3) for l in range(m, 3)] [0, 1, 2, 3, 4, 5]
- rhealpixdggs.harmonics.alm_size(lmax: int) int[source]¶
Return the number of coefficients of a real field band-limited at degree lmax: one for each
0 <= m <= l <= lmax.EXAMPLES:
>>> alm_size(2) 6
- rhealpixdggs.harmonics.analysis(rdggs: RHEALPixDGGS, resolution: int, values: Mapping[str, float] | NDArray[float64], lmax: int, maxiter: int = 100, epsilon: float = 1e-10, nthreads: int = 1) tuple[ndarray, AnalysisInfo][source]¶
Return the coefficients (healpy layout,
alm_size(lmax)complex numbers) of the real field band-limited at degree lmax that best fits values at the resolution resolution nuclei, and how the fit stopped. values is either a flat array inring_orderor a mapping from index string to value, which goes throughto_ring_order.The nuclei are not a quadrature, so this is
ducc0.sht.pseudo_analysis, an iterative least-squares solve with at most maxiter iterations to relative tolerance epsilon. Withn = N_side ** resolution, a field band-limited up to about degree1.8 nis recovered to rounding in a dozen iterations; between that and2 nthe fit is ill-conditioned and may need maxiter raised; at2 nand above the short polar rings alias and the result is not the coefficients you started from. Checkinfo.stop_reasonandinfo.residualrather than trusting the result.EXAMPLES:
>>> from rhealpixdggs.dggs import WGS84_003 >>> import numpy as np >>> alm = np.zeros(alm_size(2), dtype=complex) >>> alm[alm_index(2, 2, 1)] = 0.5 - 0.25j >>> values = synthesis(WGS84_003, 1, alm, lmax=2) >>> recovered, info = analysis(WGS84_003, 1, values, lmax=2) >>> bool(np.abs(recovered - alm).max() < 1e-9), info.stop_reason (True, 1)
- rhealpixdggs.harmonics.from_ring_order(rdggs: RHEALPixDGGS, resolution: int, values: NDArray[float64]) dict[str, float][source]¶
Return the flat array values, laid out as
ring_orderdescribes, as a dictionary keyed by index string: the inverse ofto_ring_order.EXAMPLES:
>>> from rhealpixdggs.dggs import WGS84_003 >>> import numpy as np >>> back = from_ring_order(WGS84_003, 1, np.arange(54.0)) >>> back['N4'], back['O0'] (0.0, 9.0)
- rhealpixdggs.harmonics.ring_geometry(rdggs: RHEALPixDGGS, resolution: int) dict[str, ndarray][source]¶
Return the description of the resolution resolution nuclei that ducc0’s ring-based routines take as keyword arguments: theta, the colatitude of each ring on the authalic sphere in radians; nphi, the cells on it; phi0, the longitude in radians of its first nucleus in
cells_on_ringorder (0 on a single-cell pole ring); and ringstart, its offset in the flat arrayto_ring_orderbuilds.EXAMPLES:
>>> from rhealpixdggs.dggs import WGS84_003 >>> geometry = ring_geometry(WGS84_003, 1) >>> geometry['nphi'].tolist() [1, 8, 12, 12, 12, 8, 1] >>> geometry['ringstart'].tolist() [0, 1, 9, 21, 33, 45, 53]
- rhealpixdggs.harmonics.ring_order(rdggs: RHEALPixDGGS, resolution: int) ndarray[source]¶
Return the index strings of every resolution resolution cell in the order of the flat array the transforms use: ring by ring from the north pole, each ring in
cells_on_ringorder.EXAMPLES:
>>> from rhealpixdggs.dggs import WGS84_003 >>> ring_order(WGS84_003, 1)[:10].tolist() ['N4', 'N6', 'N7', 'N8', 'N5', 'N2', 'N1', 'N0', 'N3', 'O0']
- rhealpixdggs.harmonics.synthesis(rdggs: RHEALPixDGGS, resolution: int, alm: Any, lmax: int, nthreads: int = 1) NDArray[float64][source]¶
Return the value at every resolution resolution nucleus, in
ring_order, of the real field with coefficients alm (healpy layout,alm_size(lmax)complex numbers), evaluated on the authalic sphere byducc0.sht.synthesis.EXAMPLES:
>>> from rhealpixdggs.dggs import WGS84_003 >>> import numpy as np >>> alm = np.zeros(alm_size(1), dtype=complex) >>> alm[alm_index(1, 1, 0)] = 1.0 # Y_10, proportional to cos(colatitude) >>> values = synthesis(WGS84_003, 1, alm, lmax=1) >>> values.round(4)[[0, 9, 21, 33]].tolist() [0.4886, 0.2172, 0.0, -0.2172]
- rhealpixdggs.harmonics.to_ring_order(rdggs: RHEALPixDGGS, resolution: int, values: Mapping[str, float]) NDArray[float64][source]¶
Return the values of every resolution resolution cell, keyed by index string in values, as the flat float64 array the transforms use (see
ring_order). Every cell of the resolution must be present: raise a ValueError naming the first missing one otherwise.EXAMPLES:
>>> from rhealpixdggs.dggs import WGS84_003 >>> values = {str(c): c.nucleus(plane=False)[1] for c in WGS84_003.grid(1)} >>> to_ring_order(WGS84_003, 1, values).round(3)[:9].tolist() [90.0, 58.528, 58.528, 58.528, 58.528, 58.528, 58.528, 58.528, 58.528]