Code
import plotly.express as px
from plotly.subplots import make_subplots
import plotly.graph_objects as go
import numpy as np
October 10, 2021
Spherical harmonics are powerful mathematical tools, allowing us to represent any function on a sphere as the sum of simpler basis functions (much like a Fourier series!). This post aims to explain spherical harmonics through the lens of Fourier series, by “lifting” them from a circle to a sphere, extensively relying on visual explanations.
Suppose we have a periodic function \(f(\theta)\), with a period \(2\pi\). As an example, let \(f(\theta) = \sin(\theta) - 0.5\cos(2\theta) + 0.25\sin(3\theta)\). This is the standard way to view the function: angle \(θ\) on the x-axis, \(f(θ)\) on the y-axis.
Since the function is periodic, there is an equivalent way to view this: as a function defined on a circle with radius 1 (and in turn, a circumference of 2π). The period here is represented counter-clockwise (as is convention in trigonometry). Just as in the previous plot, we can see that the function first rises to a large “hill”, before descending into two small “valleys”, returning to the starting point.
One of the most widely used discoveries in mathematics is that any real-valued, periodic function1 can be represented as the weighted sum of sines and cosines2 of varying frequencies, called a Fourier Series. Specifically, for a function with a period of \(2\pi\), there exists weights \(a_n\) (weights for the cosines) and \(b_n\) (weights for the sines) such that:
\[f(\theta) = \frac{a_0}{2} + \sum_{n=1}^\infty a_n \cos(n\theta) + b_n \sin(n\theta)\]
The process of finding the exact weights belong to the study of Fourier Analysis. In our case, since the function we’ve chosen is already neatly written as a sum of sines and cosines, \(f(\theta) = \sin(\theta) - 0.5\cos(2\theta) + 0.25\sin(3\theta)\), the weights can be read off directly: \(b_1 = 1\), \(a_1 = -0.5\) and \(b_3 = 0.25\); all the other weights are zero3. We can also visualize \(f(θ)\) with the relevant sine and cosine functions (a.k.a the “basis functions”)4.
Note that for a finite number of terms, this is exact only when the function can be expressed in terms of sines and cosines; otherwise (with function such as say, a square wave) this only exact when the sum uses an infinite number of terms.
Looping back5 to earlier, these basis functions can also be represented on a circle:
The takeaway here is that even though the circle plots look very different from the standard plot (with \(θ\) on the x-axis), they’re simply two different ways of depicting the exact same function on a circle.
If you’ve followed the visuals so far, you already know what a spherical harmonic is: instead of basis functions defined on a circle, they’re basis functions defined on a sphere 6. Just like how \(\sin(\theta)\) forms a function on a circle, the spherical harmonics are functions on a sphere. Here’s one (it’s interactive!):
fig = make_subplots(rows=1, cols=1, specs=[[{'is_3d': True}]])
s = np.linspace(0, 2 * np.pi, 100)
t = np.linspace(0, np.pi, 100)
tGrid, sGrid = np.meshgrid(s, t)
r = 1
x = r * np.cos(sGrid) * np.sin(tGrid)
y = r * np.sin(sGrid) * np.sin(tGrid)
z = r * np.cos(tGrid)
fig.add_trace(
go.Surface(x=x, y=y, z=z,
surfacecolor=np.sqrt(3/4*np.pi)*y,
colorscale='RdBu',
colorbar=dict(thickness=10)
)
)
fig.update_layout(
font_family="JuliaMono",
showlegend=False,
margin=go.layout.Margin(l=0, r=0, b=0, t=0),
paper_bgcolor='rgba(0,0,0,0)',
)
fig.update_traces(showscale=False)
fig.show()
On the circle, Fourier Analysis allows us to convert any function defined on a circle (i.e. a periodic function) into the weighted sum of sines and cosines of varying frequencies. Now, on the sphere, any function on a sphere can be decomposed into a weighted sum of the spherical harmonic functions \(Y_{lm}\). That is, there exist \(a_{lm}\) such that:
\[ \begin{align*} f(x, y, z) &= \sum_{l=0}^\infty \sum_{m=-l}^l a_{lm} Y_{lm}(x,y,z) \\ &\text{where } x^2+y^2+z^2 = 1 \end{align*} \]
There’s a few things to note here:
Real vs Complex: Both indices are subscripts on \(Y\), i.e. of the form \(Y_{lm}\). This convention reflects that we’re looking at the real spherical harmonics here (complex ones have indices of the form \(Y_l^m\))
Coordinates: Note that we’re using cartesian coordinates (x, y, z) as inputs to the spherical harmonics, not angles. We could equivalently use harmonics of the form \(Y_{lm}(\theta, \phi)\) to stay consistent with the Fourier series, but they’re simpler in cartesian form (and as we’ll see in a bit, equivalent).
Often, spherical harmonics are not depicted on the sphere directly, but in a different form. For instance, in the alternate depiction \(Y_{1, -1}\) looks as such:
fig = make_subplots(rows=1, cols=1,
specs=[[{'is_3d': True}]])
theta = np.linspace(0, np.pi, 100)
phi = np.linspace(0, 2*np.pi, 100)
theta, phi = np.meshgrid(theta, phi)
x = np.sin(theta) * np.cos(phi)
y = np.sin(theta) * np.sin(phi)
z = np.cos(theta)
r0a = np.sqrt(3/4*np.pi)*y
r0 = np.abs(r0a)
fig.add_trace(
go.Surface(
x=r0*x, y=r0*y, z=r0*z,
surfacecolor=r0a,
colorscale='RdBu'
)
)
fig.update_layout(
font_family="JuliaMono",
showlegend=False,
margin=go.layout.Margin(l=0, r=0, b=0, t=0),
paper_bgcolor='rgba(0,0,0,0)',
)
fig.update_layout(
scene = dict(
xaxis = dict(nticks=4, range=[-1.8,1.8],),
yaxis = dict(nticks=4, range=[-1.8,1.8],),
zaxis = dict(nticks=4, range=[-1.8,1.8],)
),
scene1_aspectratio=dict(x=1, y=1, z=1)
)
fig.update_traces(showscale=False)
fig.show()