Easy-to-use Spherical Harmonics, based on
FastTransforms.jl
The
FastSphericalHarmonics.jl
package wraps the
FastTransforms.jl
Julia package to calculate spherical harmonics.
FastTransforms.jl is a powerful, efficient, and well thought
out package. Unfortunately, its user interface is difficult to use for
beginners, and its documentation is very technical.
FastSphericalHarmonics.jl provides functions and documentation that
are easier to use. It would be worthwhile to fold
FastSphericalHarmonics.jl into FastTransforms.jl at some point.
This package provides scalar and spin spherical harmonic transforms for real and complex fields. The normalizations and conventions are chosen to be convenient for real fields, and are different from those usually used for complex spherical harmonics. This is documented by FastTransforms.jl, its underlying C implementation FastTransforms, and in the references listed there (see bottom of the page there).
The documentation lists the implemented functions as well as their Julia signatures. Most functions come in two versions, one that mutates its arguments and one that allocates its output. See also the test cases for more examples.
Make the example reproducible by choosing a particular random number seed:
julia> using Random
julia> Random.seed!(42);Load the package and create a random scalar field on the sphere:
julia> using FastSphericalHarmonics
julia> lmax = 4;
julia> F = randn(lmax+1, 2lmax+1)
5×9 Matrix{Float64}:
-0.556027 -1.1449 1.08238 -0.886205 -1.05099 0.168341 0.36901 0.681085 -0.145211
-0.444383 -0.468606 0.187028 0.684565 0.502079 0.284259 -0.00761298 -1.33913 0.642896
0.0271553 0.156143 0.518149 -1.59058 -0.216248 0.569829 0.562669 -0.238284 1.81935
-0.299484 -2.64199 1.49138 0.410653 -0.706424 -1.42206 0.106869 1.01936 -0.36726
1.77786 1.00331 0.367563 -0.85635 -3.86593 -0.372401 0.569458 0.701771 0.756569Transform to spherical harmonics:
julia> C = sph_transform(F)
5×9 Matrix{Float64}:
-0.0971079 -0.480385 0.250188 -0.237836 -0.344185 -1.15475 -0.390183 -0.828615 -0.0736505
0.173019 0.399055 -0.601621 0.235944 1.15235 -0.0698683 -0.222073 0.0436882 -1.03784
-0.35473 0.28504 -0.0294237 -0.492649 -0.931321 -0.478315 0.684783 -0.000600234 0.577805
-0.309621 -0.0516482 -0.99214 -0.318465 -0.243649 0.0434071 -0.452602 -0.338297 0.332204
0.296832 -1.16363 1.65583 0.606001 -0.281404 -0.555203 -0.424356 0.21506 0.123637
Note that the coefficient array C contains (lmax+1) * (2lmax+1) = 45 coefficients, more than the (lmax+1)^2 = 25 coefficients we
expected. There are some higher modes (with l > lmax) present as
well. This makes the spherical harmonic transform invertible:
julia> F′ = sph_evaluate(C)
5×9 Matrix{Float64}:
-0.556027 -1.1449 1.08238 -0.886205 -1.05099 0.168341 0.36901 0.681085 -0.145211
-0.444383 -0.468606 0.187028 0.684565 0.502079 0.284259 -0.00761298 -1.33913 0.642896
0.0271553 0.156143 0.518149 -1.59058 -0.216248 0.569829 0.562669 -0.238284 1.81935
-0.299484 -2.64199 1.49138 0.410653 -0.706424 -1.42206 0.106869 1.01936 -0.36726
1.77786 1.00331 0.367563 -0.85635 -3.86593 -0.372401 0.569458 0.701771 0.756569Evaluating the modes at the grid points gives the same values back.
Let's create a new coefficient array C′ that contains only some of
the modes, and leaves all other modes set to zero:
julia> C′ = zeros(size(C));
julia> for l in 0:lmax, m in -l:l
C′[sph_mode(l,m)] = C[sph_mode(l,m)]
end
julia> C′
5×9 Matrix{Float64}:
-0.0971079 -0.480385 0.250188 -0.237836 -0.344185 -1.15475 -0.390183 -0.828615 -0.0736505
0.173019 0.399055 -0.601621 0.235944 1.15235 -0.0698683 -0.222073 0.0 0.0
-0.35473 0.28504 -0.0294237 -0.492649 -0.931321 0.0 0.0 0.0 0.0
-0.309621 -0.0516482 -0.99214 0.0 0.0 0.0 0.0 0.0 0.0
0.296832 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0We can examine which of the coefficient array elements contains what mode, and which are unused (or rather, used by the supernumerary higher modes):
julia> lm = similar(C, Any);
julia> for l in 0:lmax, m in -l:l
lm[sph_mode(l,m)] = (l,m)
end
julia> lm
5×9 Matrix{Any}:
(0, 0) (1, -1) (1, 1) (2, -2) (2, 2) (3, -3) (3, 3) (4, -4) (4, 4)
(1, 0) (2, -1) (2, 1) (3, -2) (3, 2) (4, -3) (4, 3) #undef #undef
(2, 0) (3, -1) (3, 1) (4, -2) (4, 2) #undef #undef #undef #undef
(3, 0) (4, -1) (4, 1) #undef #undef #undef #undef #undef #undef
(4, 0) #undef #undef #undef #undef #undef #undef #undef #undefEvaluating these modes gives us scalar field values F″ that contain
only these modes, which are now different from the original values in
F:
julia> F″ = sph_evaluate(C′)
5×9 Matrix{Float64}:
-1.09395 -0.814816 -0.0626794 0.497403 0.649439 0.440388 0.0488995 -0.36331 -0.783888
-0.188812 -0.38777 0.535328 -0.220322 0.260065 0.224342 -0.309924 -0.529559 0.657753
0.290636 -0.415552 0.643938 -1.07714 0.0812705 0.622275 0.631893 -0.554523 1.38539
-1.23983 -1.27778 0.806158 0.115463 -1.27163 -1.4813 0.322699 0.908434 0.708826
0.485627 0.410934 0.464254 -0.108253 -1.02271 -1.1944 -0.337374 0.589485 0.794295Load the library and define the resolution of the images:
julia> using FastSphericalHarmonics
julia> lmax = 40;
julia> Θ, Φ = sph_points(lmax+1);Load Makie to create the images:
julia> using CairoMakie
julia> ticks = (xticks=MultiplesTicks(4, π, "π"), yticks=MultiplesTicks(4, π, "π"));Create images of some modes:
julia> for l in 4:4, m in 0:l
C = zeros(lmax+1, 2lmax+1)
C[sph_mode(l,m)] = 1
F = sph_evaluate(C)
scene, layout = layoutscene(; resolution=(400, 200))
axis = layout[1, 1] = Axis(scene; xticks=MultiplesTicks(4, π, "π"), yticks=MultiplesTicks(4, π, "π"))
heatmap!(axis, Φ, Θ, F')
scale!(scene, 1, 1)
save("mode$l$m.png", scene)
endThe l=0, m≥0 modes of the spherical harmonics look like this:
We use both scalar and spin-weighted spherical harmonics to calculate derivatives. Spin-weighted spherical harmonics with spin-weight 0 are the same as scalar spherical harmonics, except with a different normalization.
Some preliminaries:
julia> using FastSphericalHarmonics
julia> chop(x) = abs2(x) < 100eps(x) ? zero(x) : x;
julia> chop(x::Complex) = Complex(chop(real(x)), chop(imag(x)));Choose a function (here z + 2x with z = cos θ and x = sin θ cos ϕ:
julia> lmax = 4;
julia> Θ, Φ = sph_points(lmax+1);
julia> F = [cos(θ) + sin(θ)*cos(ϕ) for θ in Θ, ϕ in Φ]
5×9 Matrix{Float64}:
1.26007 1.18778 1.00472 … 0.796548 1.00472 1.18778
1.3968 1.20753 0.72827 0.183277 0.72827 1.20753
1.0 0.766044 0.173648 -0.5 0.173648 0.766044
0.221232 0.0319577 -0.447301 -0.992294 -0.447301 0.0319577
-0.64204 -0.714336 -0.897396 -1.10557 -0.897396 -0.714336We transform to scalar spherical harmonics, calculate the Laplacian (which is efficient in spectral space), and convert back to point values:
julia> C = sph_transform(F); chop.(C)
5×9 Matrix{Float64}:
0.0 0.0 2.04665 0.0 0.0 0.0 0.0 0.0 0.0
2.04665 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
julia> ΔC = sph_laplace(C); chop.(ΔC)
5×9 Matrix{Float64}:
0.0 0.0 -4.09331 0.0 0.0 0.0 0.0 0.0 0.0
-4.09331 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
julia> ΔF = sph_evaluate(ΔC)
5×9 Matrix{Float64}:
-2.52015 -2.37555 -2.00943 … -1.5931 -2.00943 -2.37555
-2.7936 -2.41506 -1.45654 -0.366554 -1.45654 -2.41506
-2.0 -1.53209 -0.347296 1.0 -0.347296 -1.53209
-0.442463 -0.0639154 0.894602 1.98459 0.894602 -0.0639154
1.28408 1.42867 1.79479 2.21113 1.79479 1.42867Since we chose F to consist of two l=1 modes, we know its
Laplacian: ΔF = -l(l+1) F:
julia> ΔF ≈ -2F
trueNow let's do the same calculation with spin-weighted spherical
harmonics. These are defined for complex functions, so we first
convert the real array to a complex array, and then transform to
spin-weighted spherical harmonics (of spin-weight 0).
julia> F⁰ = Complex.(F);
julia> C⁰ = spinsph_transform(F⁰, 0); chop.(C⁰)
5×9 Matrix{ComplexF64}:
0.0+0.0im 1.4472+0.0im … 0.0+0.0im 0.0+0.0im 0.0+0.0im
2.04665+0.0im 0.0+0.0im 0.0+0.0im 0.0+0.0im 0.0+0.0im
0.0+0.0im 0.0+0.0im 0.0+0.0im 0.0+0.0im 0.0+0.0im
0.0+0.0im 0.0+0.0im 0.0+0.0im 0.0+0.0im 0.0+0.0im
0.0+0.0im 0.0+0.0im 0.0+0.0im 0.0+0.0im 0.0+0.0imDue to the way in which FastTransforms.jl defines spherical
harmonics, and because we started with a real function F, the
imaginary part of C⁰ is actually zero.
We test that evaluating the spin-weighted spherical harmonics gives us
back the original function F:
julia> spinsph_evaluate(C⁰, 0) ≈ F
trueWe then apply the ð ("eth") operator, which is the gradient when applied to a spin-0 function, yielding a spin-1 function.
julia> ðC¹ = spinsph_eth(C⁰, 0); chop.(ðC¹)
5×9 Matrix{ComplexF64}:
0.0+0.0im -2.04665+0.0im … 0.0+0.0im 0.0+0.0im 0.0+0.0im
2.89441+0.0im 0.0+0.0im 0.0+0.0im 0.0+0.0im 0.0+0.0im
0.0+0.0im 0.0+0.0im 0.0+0.0im 0.0+0.0im 0.0+0.0im
0.0+0.0im 0.0+0.0im 0.0+0.0im 0.0+0.0im 0.0+0.0im
0.0+0.0im 0.0+0.0im 0.0+0.0im 0.0+0.0im 0.0+0.0imWe can evaluate this spin-1 function on the points on the sphere and
thus read off the gradient of F:
julia> ðF¹ = spinsph_evaluate(ðC¹, 1);
julia> ∂θF = real(ðF¹)
5×9 Matrix{Float64}:
0.657164 0.28605 0.0885849 … 1.15716 1.22574 1.02828
1.06331 0.6922 0.494734 1.56331 1.63189 1.43443
1.37051e-16 -0.371114 -0.568579 0.5 0.568579 0.371114
-1.06331 -1.43443 -1.63189 -0.563314 -0.494734 -0.6922
-0.657164 -1.02828 -1.22574 -0.157164 -0.0885849 -0.28605
julia> cscθ∂ϕF = imag(ðF¹)
5×9 Matrix{Float64}:
8.66754e-16 0.642788 0.984808 … -0.866025 -0.984808 -0.642788
-5.4187e-17 0.642788 0.984808 -0.866025 -0.984808 -0.642788
-2.88227e-16 0.642788 0.984808 -0.866025 -0.984808 -0.642788
1.87818e-16 0.642788 0.984808 -0.866025 -0.984808 -0.642788
-4.87207e-16 0.642788 0.984808 -0.866025 -0.984808 -0.642788We thus have julia> ∇F = (∂θF, sin(θ) * cscθ∂ϕF). However, since
sin(θ) has a coordinate singularity at the pole, it's best not to
actually perform this multiplication.
Next we apply the ð̄ ("eth-bar") operator, which is the divergence when applied to a spin-1 function, yielding a spin-0 function again, which we evaluate on the grid points.
julia> ð̄ðC⁰ = spinsph_ethbar(ðC¹, 1); chop.(ð̄ðC⁰)
5×9 Matrix{ComplexF64}:
0.0+0.0im -2.89441+0.0im … 0.0+0.0im 0.0+0.0im 0.0+0.0im
-4.09331+0.0im 0.0+0.0im 0.0+0.0im 0.0+0.0im 0.0+0.0im
0.0+0.0im 0.0+0.0im 0.0+0.0im 0.0+0.0im 0.0+0.0im
0.0+0.0im 0.0+0.0im 0.0+0.0im 0.0+0.0im 0.0+0.0im
0.0+0.0im 0.0+0.0im 0.0+0.0im 0.0+0.0im 0.0+0.0im
julia> ð̄ðF⁰ = spinsph_evaluate(ð̄ðC⁰, 0); chop.(ð̄ðF⁰)
5×9 Matrix{ComplexF64}:
-2.52015+0.0im -2.37555+0.0im … -2.00943+0.0im -2.37555+0.0im
-2.7936+0.0im -2.41506+0.0im -1.45654+0.0im -2.41506+0.0im
-2.0+0.0im -1.53209+0.0im -0.347296+0.0im -1.53209+0.0im
-0.442463+0.0im -0.0639154+0.0im 0.894602+0.0im -0.0639154+0.0im
1.28408+0.0im 1.42867+0.0im 1.79479+0.0im 1.42867+0.0imThis function ð̄ðF⁰ is the Laplacian of our original function F
above. It is complex, but since we started with a real function F,
ð̄ðF⁰ has a zero imaginay part (up to round-off):
julia> maximum(abs.(imag.(ð̄ðF⁰)))
1.1102230246251565e-16Of course, both ways of evaluating the Laplacian give the same result:
julia> real.(ð̄ðF⁰) ≈ ΔF
trueWe can also apply the ð̄ operator first, and then the ð operator:
julia> ð̄C⁻¹ = spinsph_ethbar(C⁰, 0);
julia> ðð̄C⁰ = spinsph_eth(ð̄C⁻¹, -1);
julia> ðð̄F⁰ = spinsph_evaluate(ðð̄C⁰, 0);
julia> ðð̄F⁰ ≈ ΔF
true



