An Easy-to-implement Numerical Recipe for Computing Spherical Harmonics
Background:
The calculation of high-order spherical harmonics (e.g. up to )
remains relevant to model development and data analysis in the geosciences.
However, I recently discovered that if one is working in a language that lacks built-in functions for computing
spherical harmonics, there appears to be no succinct summary of how to calculate these functions yourself in a numerically stable way.
In implementations such as Python's
scipy
, functions such as special.sph_harm
obscure the fact that
there is a recursion beneath the hood. Therefore, calculation of e.g. the th spherical harmonic,
,
necessitates calculating
for some some set of
for
,
.
The purpose of this document is to describe a simple, easy to implement, numerically stable algorithm for performing this
recursion to compute for
in a codebase where you do not have access to an off-the-shelf library for doing so.
The crux of what makes this difficult is calculating the so-called associated Legendre polynomials
.
If you need more esoteric computations, such as calculating
for
, or where use of recursion is unacceptable,
I recommend persuing a method like the one advanced in Bremer (2017). However, this method is
significantly more complicated to implement. Furthermore, computation must be performed offline and read into memory or included as data in the executable.
The author precomputes the necessary lookup tables for
However, the required data are 138MB in size,
almost all of which is entirely extraneous for applications where only integral
are required.
Mathematical Statement
We use the convention that denotes latitude.
The Integrated Forecast System of the European Center for Numerical Range Weather Forecasting is built atop
a global spectral decomposition and can be run down to a grid spacing of 9 km.
It's therefore not surprising that they provide a recipe for calculating spherical harmonics
which can achieve numerical stability for large
(though quad precision must be used for
).
Although a reasonably readable summary is provided here,
this summary contains several crucial errors. I therefore follow their derivation and note corrections where I make them.
There are several ways of normalizing the associated Legendre polynomials. The choice used here is that the un-normalized
associated Legendre polynomial is multiplied by
to obtain the normalized associated Legendre polynomials
. One advantage of this normalization
is that
(this is mis-stated in the ECMWF document)
, omitting the factorial term that arises in the un-normalized associated Legendre polynomials.
To start the recursion we use the definition of the associated Legendre polynomials given by the Rodriguez formula
As a consequence of this definition, if
, then
the derivative term vanishes and
.
The ECMWF document recommends a special recurrence relation
for
The red
is a correction of a crucial omission in the reference document. When
, we can use the fact that the Legendre and Associated Legendre polynomials coincide.
The recurrence relation for
is
.
Initializing the recursion
and .
The spherical harmonics are finally calculated as
Algorithm pseudocode
In the following algorithm, whenever an associated Legendre polynomial is calculated,
use the above equation to calculate
. Any computation that requires the
th spherical harmonic
should performed at this point in the code (see example implementation below).
For the maximum total wavenumber (i.e.
) and a single point
,
allocate a
array
with zeros. The component
is the associated legendre polynomial
and
is
. We will not store
but rather compute it when needed.
- Initialize
for
using the above analytic equations
- For
do
- Use recurrence relation for
given above to calculate
from
and
. Stick this in
- For
do
- Use
,
and
and the recurrence relation to calculate
. If
, then
. If
for one of the
recursive dependencies, then it is equal to zero. Stick this value in
- Use
to find
- Use
- end do
- Move
into
- Move
into
- Use recurrence relation for
- end do
To calculate points in parallel (note that the calculation is embarassingly parallel), one needs only allocate a
array.
A simple python implementation
In double precision arithmetic, the following routine agrees with scipy
's special.sph_harm
to . Beyond this,
quad precision is required.
def n_m_to_d(n,m):
return int(n**2 + n + m)
def assoc_leg_to_sph_harm(p, lon, m):
pronk = np.exp(1j * m * lon) * p / np.sqrt(4*np.pi)
return pronk
def assoc_leg_m_n(lat, lon, out, example_fn):
# ==================
# lat, lon are given in radians
# out returns spherical harmonic evaluations at provided points
# example_fn(Y_n_m, n, m) takes
# * Y_n_m: an array of spherical harmonic evaluations
# * n, m: the order and total wavenumber of Y_m_n
# ===================
x = np.sin(lat)
p_prev = np.zeros((3, n_max, x.size))
p_prev[0, 0, :] = np.ones_like(x) # P_0^0 = 1
out[n_m_to_d(0,0),:] = assoc_leg_to_sph_harm(p_prev[0,0,:], lon, 0)
example_fn(out[n_m_to_d(0,0),:], 0, 0)
p_prev[1, 0, :] = np.sqrt(3) * x # P_1^0 = \sqrt{3} x
out[n_m_to_d(1,0),:] = assoc_leg_to_sph_harm(p_prev[1,0,:], lon, 0)
example_fn(out[n_m_to_d(1,0),:], 1, 0)
p_prev[1, 1, :] = -np.sqrt(3/2) * np.sqrt(1 - x**2)# P_1^1 = \sqrt{\frac{3}{2}(1-x^2)}
out[n_m_to_d(1,1),:] = assoc_leg_to_sph_harm(p_prev[1,1,:], lon, 1)
out[n_m_to_d(1,-1),:] = -1 * assoc_leg_to_sph_harm(p_prev[1,1,:], lon, -1)
example_fn(out[n_m_to_d(1,1),:], 1, 1)
example_fn(out[n_m_to_d(1,-1),:], 1, -1)
for n in range(2, n_max):
p_prev[2, 0, :] = np.sqrt(2 *n + 1 )/n * ((2 * n -1 ) * x * (p_prev[1, 0, :]/np.sqrt(2*(n-1)+1)) - (n-1) * (p_prev[0, 0, :]/np.sqrt(2 * (n-2) + 1)) )
out[n_m_to_d(n,0),:] = assoc_leg_to_sph_harm(p_prev[2,0,:], lon, 0)
example_fn(out[n_m_to_d(2,0),:], 2, 0)
for m in range(1, n+1):
mm2 = abs(m-2)
m_neg = (-1)**m if m-2 < 0 else 1
c_mn = np.sqrt(((2*n+1)/(2*n-3)) * ((m + n - 1)/(m + n)) * ((m + n - 3)/(m + n - 2)))
d_mn = np.sqrt(((2*n+1)/(2*n-1)) * ((m + n - 1)/(m + n)) * ((n-m + 1)/(m + n - 2)))
e_mn = np.sqrt(((2*n+1)/(2*n-1))* ((n-m)/(n+m)))
p_nm2_mm2 = m_neg * p_prev[0, mm2, :]
p_nm1_mm2 = m_neg * p_prev[1, mm2, :]
p_nm1_m = p_prev[1, m, :]
p_prev[2, m, :] = c_mn * p_nm2_mm2 - d_mn * x * p_nm1_mm2 + e_mn *x * p_nm1_m
out[n_m_to_d(n,m),:] = assoc_leg_to_sph_harm(p_prev[2,m,:], lon, m)
out[n_m_to_d(n,-m),:] = (-1)**m * assoc_leg_to_sph_harm(p_prev[2,m,:], lon, -m)
example_fn(out[n_m_to_d(n,m),:], n, m)
example_fn(out[n_m_to_d(n,-m),:], n, -m)
p_prev[0, :, :] = p_prev[1, :, :]
p_prev[1, :, :] = p_prev[2, :, :]
p_prev[2, :, :] = 0