Usage

Calculating scalar values

At its simplest, the associated Legendre polynomial $P_\ell^m(x)$ is computed by calling Plm. For example, to compute $P_2^1(0.5)$,

julia> using AssociatedLegendrePolynomials

julia> Plm(2, 1, 0.5)
-1.299038105676658

In the context of CMB analysis, a common use of the associated Legendre polynomials is to compute the spherical harmonics $Y_{\ell m}(\theta,\phi)$:

\[\begin{align} \begin{aligned} Y_{\ell m}(\theta,\phi) &\equiv N_\ell^m P_\ell^m(\cos \theta) e^{im\phi} \\ &\text{where } N_\ell^m \equiv \sqrt{\frac{2\ell+1}{4\pi} \frac{(\ell-m)!}{(\ell+m)!}} \end{aligned} \end{align}\]

The function Nlm calculates the normalization factor $N_\ell^m$:

julia> Nlm(2, 0)
0.6307831305050401

julia> Nlm(2, 0) * Plm(2, 0, 0.5)
-0.07884789131313001

An important fact about the associated Legendre polynomials is that for $m > 0$, $P_\ell^m(x)$ diverges to $\infty$ as $\ell \rightarrow \infty$ [1]. For even moderately large pairs of $(\ell,m)$, numerical underflow and overflow make computing the spherical harmonics impossible this way:

julia> n = Nlm(157, 150)      # Underflows
0.0

julia> p = Plm(157, 150, 0.5) # Overflows
Inf

julia> n * p                  # Undefined
NaN

One way around this would be to just use extended precision arithmetic

julia> n = Nlm(BigFloat, 157, 150)
4.14800666209481424285411223457923933542541063872695815968861285171699012214351e-314

julia> p = Plm(157, 150, big"0.5")
4.768286486602206390406601862422168575170463348990958242752608686436785229641823e+308

julia> Float64(n * p)
1.9778884113202627e-5

but at the expense of much more computationally expensive calculations.

An alternative way forward is to directly calculate the spherical harmonic normalized associated Legendre polynomials $\lambda_\ell^m(x)$ so that the spherical harmonics are defined as

\[\begin{align} \begin{aligned} Y_{\ell m}(\theta,\phi) &= \lambda_\ell^m(\cos \theta) e^{im\phi} \\ & \text{where } \lambda_\ell^m(x) \equiv N_\ell^m P_\ell^m(x) \end{aligned} \end{align}\]

λlm implements this scheme and avoids the under/overflow of computing the normalization separately from the function:

julia> λlm(157, 150, 0.5)
1.977888411320263e-5
Note

We are not just limited to efficient and numerically stable computation of $\lambda_\ell^m(x)$; the package supports arbitrary normalizations. For further information on implementing custom Legendre normalizations, see the Custom normalizations section.

Calculating multiple degrees/orders

Because calculating a particular Legendre polynomial value is the end result of running a recurrence relation, looping evaluation of $P_\ell^m(x)$ for all $\ell$ is inefficient and redoes a lot of work:

julia> @time [l < 2 ? 0.0 : λlm(l, 2, 0.5) for l in 2:700];
  0.039210 seconds (71.21 k allocations: 3.285 MiB)

It's far more efficient to accumulate the intermediate terms while running the recurrence relations. Using a UnitRange as the input degree causes the functions to allocate and fill the vector with all polynomials values:

julia> λ = @time λlm(0:700, 2, 0.5);
  0.000012 seconds (6 allocations: 5.703 KiB)

On my machine, this is roughly 3000 times faster!

Likewise, calculating the [lower triangular] matrix of values for some $x$ over all degrees $\ell \in [0,\ell_\mathrm{max}]$ and all orders $m \in [0,\ell]$ is done by also specifying the orders as a UnitRange.

julia> Λ = @time λlm(0:700, 0:700, 0.5);
  0.002980 seconds (7 allocations: 3.749 MiB)

julia> Λ[:,3] == λ   # N.B. 1-based indexing of the array!
true
Note

There are two things in particular to remember with the range-based calls:

  1. The ranges must start at 0, otherwise an ArgumentError will be thrown.
  2. Calculating a range of orders $m$ for a fixed degree $\ell$ is not supported; to calculate multiple orders requires the output matrix be at least square (but may "tall and skinny" with $\ell_\mathrm{max} > m_\mathrm{max}$ if desired).

It is also more efficient to operate upon an array of arguments $x$ than to loop over them one-by-one, so the functions also accept the input argument x as an array of any shape.

For a specific degree and order, the output array will have the same shape as the argument:

julia> λlm(2, 0, reshape(range(0, 1, length=4), 2, 2))
2×2 Matrix{Float64}:
 -0.315392  0.105131
 -0.210261  0.630783

Then adding a range of degrees increases the dimensionality by 1, with the trailing dimension being over $\ell$,

julia> λlm(0:2, 0, reshape(range(0, 1, length=4), 2, 2))
2×2×3 Array{Float64, 3}:
[:, :, 1] =
 0.282095  0.282095
 0.282095  0.282095

[:, :, 2] =
 0.0       0.325735
 0.162868  0.488603

[:, :, 3] =
 -0.315392  0.105131
 -0.210261  0.630783

and a further extra dimension is added for a range over orders $m$.

In-place calculations

Both of Plm and λlm also have in-place modifying counterparts, Plm! and λlm! respectively, which fill an appropriately sized vector for a specified $\ell_\mathrm{max}$ and $m_\mathrm{max}$. Instead of using integer or range arguments, whether to calculate a value for a single degree/order, a range of degrees for fixed order, or for all degrees and orders is inferred based on the dimensionality of the output array.

For example, to calculate the single value $\lambda_{700}^{200}(0.5)$, provide a 0-dimensional output array (to match the 0-dimensionality of the scalar 0.5)

julia> λlm!(fill(NaN), 700, 2, 0.5)
0-dimensional Array{Float64, 0}:
0.24148976866924293

and filling a vector or matrix instead calculates all degrees up to the given maximum degree/order as appropriate:

julia> λlm!(λ, 700, 2, 0.5) == λlm(0:700, 2, 0.5)
true

julia> λlm!(Λ, 700, 700, 0.5) == λlm(0:700, 0:700, 0.5)
true

The in-place interface accepts input arguments x of any shape as well, with the output array Λ having to have between 0 and 2 more dimensions than x, where the leading dimensions of the input and output arrays have the same axes, and the trailing dimensions are sized appropriate for the number of degrees/orders to be calculated.

Precomputed recursion factors

A final trick to accelerating calculation of any normalization of the associated Legendre polynomials is to pre-compute the appropriate recursion relation coefficients.

At a low level, Plm/Plm! and λlm/λlm! are simple wrappers around the general legendre/legendre! functions. The trait type LegendreUnitNorm dispatches internal functions to compute $P_\ell^m(x)$:

julia> legendre(LegendreUnitNorm(), 5, 2, 0.5) == Plm(5, 2, 0.5)
true

and LegendreSphereNorm does the same for $\lambda_ℓ^m(x)$:

julia> legendre(LegendreSphereNorm(), 5, 2, 0.5) == λlm(5, 2, 0.5)
true

The type LegendreNormCoeff stores the coefficients for a particular normalization (and value type) so that the coefficients must only be calculated once. Aliases for the unit and spherical normalizations are provided by default, LegendreUnitCoeff and LegendreSphereCoeff respectively.

julia> coeff = LegendreSphereCoeff{Float64}(700);

julia> legendre(coeff, 5, 2, 0.5)
-0.15888479843070935
Performance Note

Choosing whether to use the pre-computed coefficients or not should be guided by benchmarking and performance profiling. Modern processors can perform many floating point operations in the time it takes to load the coefficients from memory, so depending on the complexity of the normalization, you may actually achieve better performance by recomputing the recursion coefficients on demand.

Notice that due to its flexibility, legendre! requires explicit lmax and mmax arguments even though the LegendreNormCoeff has a lmax and mmax set during construction. This allows us to pass both a coefficient cache and output array which are larger than the computed set of coefficients. For example, the output matrix and cache used above each support computing the Legendre polynomials up to $\ell = 700$, but if we only need $\ell \le 2$, we can avoid computing terms beyond our required problem size.

julia> fill!(Λ, 0);

julia> legendre!(coeff, Λ, 2, 2, 0.5);

julia> Λ[1:5, 1:5]
5×5 Matrix{Float64}:
  0.282095    0.0       0.0       0.0  0.0
  0.244301   -0.299207  0.0       0.0  0.0
 -0.0788479  -0.334523  0.289706  0.0  0.0
  0.0         0.0       0.0       0.0  0.0
  0.0         0.0       0.0       0.0  0.0

Footnotes

  • 1

    Specifically, the envelope of $P_\ell^m(x)$ which bounds the local extrema for all values of $x$ can be shown to be

    \[ \left| P_\ell^m(\cos \theta) \right| \le \frac{\Gamma(\ell+m+1)}{\Gamma(\ell+\frac{3}{2})} \left( \frac{2}{\pi \sin \theta} \right)^{1/2}\]

    (see Eq. 8.10.7 (p336) of Abramowitz and Stegun, “Handbook of Mathematical Functions” 10th printing (1972)). For fixed $m$ and any $x$, we take the asymptotic limit as $\ell \rightarrow \infty$ and simplify $\Gamma(z)$ via Stirling's approximation to get the scaling of the associated Legendre polynomial envelope

    \[ \DeclareMathOperator*{\env}{env} \env_{\ell\rightarrow\infty}\left( P_\ell^m \right) \propto \ell^{m - 1/2} \text{ .}\]

    In contrast, the normalization factor $N_\ell^m$ scales as $\ell^{1/2 - m}$, exactly canceling the scaling of $\env\left(P_\ell^m\right)$, so overall the spherical harmonic normalized Legendre polynomials $\lambda_\ell^m(x)$ asymptote to some constant envelope:

    \[ \env_{\ell\rightarrow\infty} \left( \lambda_\ell^m \right) \propto \ell^0 = \text{constant .}\]