Attached below is a Python script to display spherical harmonics and spherical harmonic superpositions. Spherical harmonics are the angular wavefunctions (“eigenfunctions”) used to describe rotational states of molecules and the angular properties of electrons in atoms (i.e., “atomic orbitals”). A single eigenfunction of the Schrödinger equation is time-independent but dynamics can be described by a superposition (sum) of eigenfunctions. E.g., the square of a sum of spherical harmonics predicts the angular orientation of a molecule as function of time.

To use the script, you need to have a functional version of Python running on your computer. The header describes how to set up the correct environment using Anaconda Python. Have fun and comment if you found any issues or want to share your experience.

# PHYSICAL BACKGROUND
# -------------------
# This Python code plots a superposition of spherical harmonics (angular
# wave functions), that correspond to the energy eigenstates of a rotating
# molecule or to the angular wavefunction of the electron in the hydrogen atom.
# The square of the angular angular wavefunction describes the probability to
# observe the particle at the corresponding angle.
# Note that each eigenfunction is a time-independent solution to the angular
# Schrödinger equation. "Add" additional eigenfunctions with different J and m
# quantum numbers s to create a state superposition ("wave packet"), which
# describes time-dependent properties, such as pseudo-classical molecular rotation.
#
# RUNNING THE CODE
#---------------------------------------------------------------------------
# To run Mayavi in Conda, it is necessary to create an environment for
# Python 3.6 (or lower) including numpy, scipy and pyqtgraph. It is neccessary
# to install VTK and Mayavi via PIP ("conda install" does not work).
# I use Anaconda Python [download from: https://conda.io/miniconda.html]
# In an administrator Conda shell, run:
# > conda create -n mayavi python=3.6 pyqt=5
# > conda activate mayavi
# > conda install numpy scipy pyqtgraph ipython
# > pip install vtk
# > pip install mayavi
# It is required to activate the (mayavi) environment (line 2) each time when
# opening a new shell. Return to your (base) shell with "conda activate base".
#
# To run the script, I suggest you open an ipython shell and copy/paste the
# code in three batches (marked [1],[2], and [3]). Note that only wavefunctions
# with quantum numbers m = -J...J are allowed. In a conda shell, type:
# > conda activate mayavi
# > ipython
# and start copy/pasting the code from below.
#
# Written by Thomas Schultz, Apr. 2021
# Feel free to use and modify the code according to the Creative Commons license
# [see: https://creativecommons.org/licenses/by/4.0/]
#---------------------------------------------------------------------------
#-----------------------------------------------------------------#
# [1a] Import mathematical functions and define wave functions ---#
#-----------------------------------------------------------------#
from numpy import sin, cos, pi, exp, mgrid
from scipy.special import sph_harm
# The rotational wavefunctions are the Spherical Harmonics.
# For explicit ways to calculate the angular wavefunctions, refer to a textbook.
def TiWf (J,m,theta,phi): # Time-independent angular wave function
return sph_harm(m, J, theta, phi)
def TdWf(k,t,phase): # Time dependent wave function. WF(r,t) = TiWf(r) * TdWf(t)
return np.exp(-1j*k*(t+phase)) # k is the wavevector (angular frequency), == T(J,B)/2*pi
# To look at time-dependent wavefunctions, we need the state energies / terms
B = 3.27e9 # Rotational constant for CS2 (in Hz)
def EJ(J): return J*(J+1) # Energy of a J state in a rigid linear rotor
def T(J,B): return B*J*(J+1) # Energy term of J state (in freq. units)
#-----------------------------------------------------------#
# [1b] Plot a time-independent wave function ---#
#-----------------------------------------------------------#
from mayavi import mlab
# Spatial coordinates for plotting:
# Create a grid over a sphere defined by r, phi, theta
# More grid points look better, but take more time to calculate
r = 0.3 # radius
grid = 80j
phi, theta = mgrid[0:pi:grid, 0:2*pi:2*grid]
# Spherical to Cartesian coordinate transformation
x = r * sin(phi) * cos(theta)
y = r * sin(phi) * sin(theta)
z = r * cos(phi)
SqWF = abs(TiWf(2,1,theta,phi))**2 # Wave function psi-squared for state J=2, m=1
plot = mlab.mesh(SqWF *x, SqWF *y, SqWF *z, colormap='RdBu', opacity=1., transparent=True)
#--------------------------------------------------------------------------#
# [2a] Initialize a list of wavefunctions to plot a state superposition ---#
#--------------------------------------------------------------------------#
t = 0; deltaT = 5e-12 # Initial time (0), time-increment (1 ps)
J = [0,] # List of J-values
m = [0,] # List of m-values
Amp = [1.,] # List of amplitudes
phase = np.array([0.,]) # List of initial phases
showReal = [True,] # Show the real or imaginary part
showImag = [True,] # ... of the wavefunction
TiWfList = [] # Initialize list of time-independent wavefunctions (don't recalculate for each step)
kList = [] # Initialize list of k-vectors for propagation
for _J,_m,_a in zip(J,m,Amp): # Calculate TiWf for each state in the list. (recalculate when J, m, Amp change)
TiWfList.append(_a* TiWf(_J,_m,theta,phi)) # Time-independent wavefunctions WF(r)
kList.append(T(_J,B)) # k-vectors for WF propagation: WF(r,t) = WF(r)*exp(-ikt)
#----------------------------------------------------------------------#
# [2b] Animated plot: Update TiWf*TdWf for incrementing time values ---#
#----------------------------------------------------------------------#
@mlab.animate(delay=200)
def anim():
global TiWfList, kList, phase, t, deltaT, x,y,z
while True:
if showReal[0] and showImag[0]:
sumWF = TiWfList[0]*TdWf(kList[0],t,phase[0])
else:
if showReal[0]:
sumWF = (TiWfList[0]*TdWf(kList[0],t,phase[0])).real +(0+0j)
if showImag[0]:
sumWF = (TiWfList[0]*TdWf(kList[0],t,phase[0])).imag +(0+0j)
if not (showReal[0]) and not (showImag[0]):
sumWF = (TiWfList[0]*TdWf(kList[0],t,phase[0])).imag *(0+0j)
if len(kList)>1:
for i in range(len(kList)-1):
if (showReal[i+1] and showImag[i+1]):
sumWF += TiWfList[i+1]*TdWf(kList[i+1],t,phase[i+1])
elif showReal[i+1]:
sumWF += ((TiWfList[i+1]*TdWf(kList[i+1],t,phase[i+1])).real +(0+0j))
elif showImag[i+1]:
sumWF += ((TiWfList[i+1]*TdWf(kList[i+1],t,phase[i+1])).imag +(0+0j))
plot.mlab_source.x = np.absolute(sumWF)**2 *x # replace x data
plot.mlab_source.y = np.absolute(sumWF)**2 *y # replace y data
plot.mlab_source.z = np.absolute(sumWF)**2 *z # replace z data
yield
t += deltaT # increment time
anim()
#---------------------------------------------------------------#
# [3a] Create a user interface for easy control of the plot. ---#
#--- I use a PyQt ParameterTree as user interface ---#
#---------------------------------------------------------------#
import pyqtgraph as pg
from pyqtgraph.Qt import QtCore, QtGui, QtWidgets
import pyqtgraph.parametertree.parameterTypes as pTypes
from pyqtgraph.parametertree import Parameter, ParameterTree, ParameterItem, registerParameterType
QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_ShareOpenGLContexts)
qt_app = QtWidgets.QApplication(sys.argv)
# List of parameters for each state
rotParameters = [
{'name': 'amplitude', 'type': 'float', 'value': 1, 'limits': (0, 1), 'default': 1.},
{'name': 'J', 'type': 'int', 'value': 0, 'limits': (0, 100)},
{'name': 'm', 'type': 'int', 'value': 0, 'limits': (0, 100)},
{'name': 'phase', 'type': 'float', 'value': 0., 'default': 0.},
{'name': 'Show real**2', 'type': 'bool', 'value': True, 'tip': "add abs(real(phi)) to plot"},
{'name': 'Show imag**2', 'type': 'bool', 'value': True, 'tip': "add abs(imag(phi)) to plot"},
]
# This class allows the user to add new parameters into the parameter tree
class ScalableGroup(pTypes.GroupParameter):
""" Extendable group of parameters for parameter tree."""
def __init__(self, **opts):
opts['type'] = 'group'
opts['addText'] = "Add"
opts['addList'] = ['State']
pTypes.GroupParameter.__init__(self, **opts)
self.counter = 1
self.addChild(dict(name="State %d" %(self.counter), children=rotParameters, removable=True, renamable=True))
def addNew(self, typ):
self.addChild(dict(name="State %d" %(self.counter+1), children=rotParameters, removable=True, renamable=True))
self.counter += 1
# List of global property parameters
parameters = [
{'name': 'Global Properties', 'type': 'group', 'expanded': True, 'children': [
{'name': 'Radius', 'type': 'float', 'value': 0.3, 'limits': (0.001, 10), 'default': 0.3, 'tip': "radius of image"},
# {'name': 'Animate', 'type': 'bool', 'value': False, 'tip': "animate plot"},
{'name': 'Speed (ps)', 'type': 'float', 'value': 10, 'limits': (0.01, 200), 'default': 0.5, 'tip': "time step for animation"},
]},
ScalableGroup(name="Rotational State Properties"), # Call the ScalableGroup to add rotational parameters to the tree
]
# If anything changes in the parameter tree, update the wavefunction parameters
def change(parameterTree, changes):
""" This function must handle all user requests by interpreting the
parameter origin, name, and value"""
global B, Amp, J, m, phase, TiWfList, kList, showReal, showImag, theta, phi, deltaT, x,y,z
for parameterName, change, data in changes:
path = parameterTree.childPath(parameterName)
print(f'path: {path}')
if path is not None:
childName = '.'.join(path)
else:
childName = parameterName.name()
if change == "childAdded":
print("== child added ==") # New state added: Now update all wavefunctions
Amp, J, m, phase, TiWfList, kList, showReal, showImag = updateWaveFunctions(parameterTree)
if change == "value": # Value change: Update only the affected wavefunction
if path[0] == 'Rotational State Properties':
stateNo = int(path[1].split()[1])-1
parameter = path[2]
print(f'state: {stateNo}; parameter: {parameter} = {data}')
if parameter == 'amplitude': Amp[stateNo] = float(data)
if parameter == 'J': J[stateNo] = int(data)
if parameter == 'm': m[stateNo] = int(data)
if parameter == 'phase': phase[stateNo] = float(data)
if parameter == 'Show real**2': showReal[stateNo] = float(data)
if parameter == 'Show imag**2': showImag[stateNo] = float(data)
TiWfList[stateNo] = Amp[stateNo]* TiWf(J[stateNo],m[stateNo],theta,phi)
kList[stateNo] = T(J[stateNo],B)
elif path[0] == 'Global Properties': # Global property change: update the affected parameters
parameter = path[1]
if parameter == 'Speed (ps)': deltaT = data*1e-12;
if parameter == 'Radius':
r = data
x = r * sin(phi) * cos(theta)
y = r * sin(phi) * sin(theta)
z = r * cos(phi)
# This recalculates the whole list of wavefunctions.
def updateWaveFunctions(p):
global B, theta, phi
Amp,J,m,phase,TiWfList,kList,showReal,showImag = [],[],[],[],[],[],[],[]
values = p.getValues()["Rotational State Properties"][1] # Table values for States
for state in list(values):
data = values[state][1]
_a = float(data['amplitude'][0])
Amp.append(_a)
_j = data['J'][0]
J.append(_j)
_m = data['m'][0]
m.append(_m)
_p = float(data['phase'][0])
phase.append(_p)
_real = data['Show real**2'][0]
showReal.append(_real)
_imag = data['Show imag**2'][0]
showImag.append(_imag)
TiWfList.append(_a* TiWf(_j,_m,theta,phi))
kList.append(T(_j,B))
return Amp,J,m,phase,TiWfList,kList,showReal,showImag
#Now we actually create a window and show the parameter tree
win = QtGui.QWidget()
win.resize(280,500)
layout = QtGui.QGridLayout()
win.setLayout(layout)
p = Parameter.create(name='params', type='group', children=parameters)
p.sigTreeStateChanged.connect(change)
tree = ParameterTree()
tree.setParameters(p, showTop=False)
tree.setWindowTitle('Angular states')
layout.addWidget(tree)
win.show()