
Simulation of Wave Functions in Quantum Mechanics
(Cambridge Summer School’s Presentation)
written in jupyter notebook by Yiran Feng (Leon) 2025-08-02
Step = QMSimStep.Gaussian = [-5, 0.5, 4]Step.Potential = np.array([0] * 120 + [20] * 80)%manim -qh Step
Introduction
In chapter 2 and chapter 3, we’ve learnt about wave function in quantum mechanics and its characteristics, however, in a rather mathematical level, with equations and deductions. In this presentation, I’ll present you a python simulation of wave functions in different situations, giving you a intuitive way of understanding quantum mechanics.
For simplicity, all constants are set to one in this simulation, including mass, planck’s constant, etc.
# Librariesimport numpy as npimport scipy as spfrom matplotlib import pyplot as pltfrom manim import *from IPython.display import HTMLfrom copy import deepcopy as dcfrom random import randomimport tqdm
# Variables & Constantsinf = 100x = np.linspace(-10, 10, 200)y = np.linspace(-10, 10, 200)X, Y = np.meshgrid(x, y)
# Useful functionsdef find_nearest(array, value): array = np.asarray(array) idx = (np.abs(array - value)).argmin() return idx
def find_nearest_val(array, value): array = np.asarray(array) idx = (np.abs(array - value)).argmin() return array[idx]
Simulation on 1D
Basically, the simulation consists of two parts: Initialization and Evolution.
Initialization
The state of the system can be initialized with two functions: the initial state of the wave function and the potential function.
By considering different initial states of the wave function, we can change the position, the spread, and the momentum of the particle.
By changing the potential function, we can simulate different situations, for example, harmonic oscillator and quantum channel.
Look into the code. The class Simulation1D
takes in two variables. phi_0
means the initial state of the wave function, and v
defines the potential. The wave function is automatically normalised by the function _normalise
, which is called inside the initialization, and probability
( which is basically ) gives us the probability, of course.
class Simulation1D(): def __init__(self, phi_0, v): self.phi = dc(phi_0); self.v = dc(v) self.dx = x[1] - x[0]; self.len = len(phi_0) self._normalise(); self._hamiltonian() self.prob = []; self.prob_updated = False
def _normalise(self): self.phi /= np.linalg.norm(self.phi) self.prob_updated = True self.prob = (self.phi * self.phi.conjugate()).real
def probability(self, pos = None): if not self.prob_updated: self._normalise() if pos: return self.prob[find_nearest(x, pos)] else: return self.prob
Evolution
From chapter 2 and chapter 3, we’ve learnt that when a system is free from interactions with measuring devices, the evolution of the wave function can be calculated by the Schrödinger equation (44) (Postulate 6)
In our cases, the mass and is set to 1. Introducing the hamiltonian operator , the 1-dimensional time-dependent Schrödinger equation (TDSE) can be deduced
where
In this equation, the discrete Laplace operator can be given as convolution with the finite-difference kernel, , results in a symmetric Tridiagonal Toeplitz matrix with the finite-difference coefficients along the diagonals. Combining this matrix with the potential , a full Hamiltonian operator in matrix form can be obtained
def _hamiltonian(self): """Returns Hamiltonian using finite differences.""" L = [[0 for j in range(self.len)] for i in range(self.len)] for i in range(self.len): for j in range(self.len): if i == j: L[i][j] = -2 if abs(i-j) == 1: L[i][j] = 1
# Somehow it doesn't work... # L = sp.sparse.diags([1, -2, 1], offsets=[-1, 0, 1], shape=(self.len, self.len))
L = np.array(L) / self.dx ** 2 H = -L / 2 + sp.sparse.spdiags(self.v, 0, self.len, self.len) self.H = sp.sparse.csc_matrix(H)
Simulation1D._hamiltonian = _hamiltonian
If the Hamiltonian is independent of time, a time evolution operator(U) can be obtainted
where
Multiplying the wave function with the operator times, we can advance the time steps to
The code below calculates the time evolution operator based on the hamiltonian and dt
, and applies the operator to the wave function, which is also normalized after the procedure.
def simulate(self, dt = 1e-3): """Generates wavefunction and time at the next time step.""" U = sp.sparse.linalg.expm(-1j * self.H * dt) #U[1E-10 > (U.real**2 + U.imag**2)] = 0 self.phi = U @ self.phi self.phi[self.v > inf-1] = 0 self._normalise()
Simulation1D.simulate = simulate
Wave Function Representation
We use a localized Gaussian wave packet centred at with average initial momentum, , and Gaussian width, . The wave function is given by
def gaussian_wavepacket(x0, sigma, p0): """Gaussian wavepacket at x0 +/- sigma, with average momentum, p0.""" global x return np.exp(1j*p0*x - ((x - x0)/(2 * sigma))**2)
Simulation
After the calculations, some simulations can be done.
The class QSim
takes in the parameters of the gaussian wavepacket and the potential function. It produces an animation of the evolution of the wave function in 2 seconds using manim
.
class QMSim(Scene): def construct(self): phi_0 = gaussian_wavepacket(self.Gaussian[0], self.Gaussian[1], self.Gaussian[2]) V = self.Potential o = Simulation1D(phi_0, V)
axes = Axes( x_range=[-10, 10, 1], y_range=[0, 0.17, 1], x_length=10, axis_config={"color": GREEN}, x_axis_config={ "numbers_to_include": np.arange(-10, 10.01, 2), "numbers_with_elongated_ticks": np.arange(-10, 10.01, 2), }, tips=True, )
axes_labels = axes.get_axis_labels() axes_ref = axes.copy()
pot_graph = axes.plot((lambda k: (V/100)[find_nearest(x,k)]), color=BLUE) pot_label = axes.get_graph_label( pot_graph, "V(x)", x_val=10, direction=UP / 2 )
prob_graph = axes.plot(o.probability, color=ORANGE) prob_label = axes.get_graph_label( prob_graph, r"|\phi(x)|^2", x_val=-10, direction=UP / 2 )
plot = VGroup(axes, pot_graph, prob_graph) labels = VGroup(axes_labels, prob_label, pot_label)
def prob_updater(mobj, dt): o.simulate(dt=dt*2) mobj.become(axes.plot(o.probability, color=ORANGE)) return mobj
prob_graph.add_updater(prob_updater) prob_label.add_updater(lambda x:x.become(axes.get_graph_label(prob_graph, r"|\phi(x)|^2", x_val=-10, direction=UP / 2))) self.add(plot, labels) self.wait(2 + random()) # force update
Harmonic Oscillator
Oscillator = QMSimOscillator.Gaussian = [-5, 0.5, 0.1]Oscillator.Potential = x*x%manim -qh Oscillator
Potential Step
Step = QMSimStep.Gaussian = [-5, 0.5, 4]Step.Potential = np.array([0] * 140 + [7] * 60)%manim -qh Step
Quantum Channel
Channel = QMSimChannel.Gaussian = [-5, 0.5, 4]Channel.Potential = np.array([0] * 100 + [6] * 10 + [0] * 90)%manim -qh Channel
The real and imagery part of the wave function can also be illustrated
class QMSim2(Scene): def construct(self): phi_0 = gaussian_wavepacket(-5, 0.4, 1.4) V = np.array([0] * 200) o = Simulation1D(phi_0, V)
axes = Axes( x_range=[-10, 10, 1], y_range=[-0.3, 0.3, 1], x_length=10, axis_config={"color": GREEN}, x_axis_config={ "numbers_to_include": np.arange(-10, 10.01, 2), "numbers_with_elongated_ticks": np.arange(-10, 10.01, 2), }, tips=True, )
axes_labels = axes.get_axis_labels() axes_ref = axes.copy()
real_graph = axes.plot(lambda x:find_nearest_val(o.phi.real,x), color=BLUE) real_label = axes.get_graph_label( real_graph, r"Re{\phi(x)}", x_val=-10, direction=UP / 2 )
imag_graph = axes.plot(lambda x:find_nearest_val(o.phi.imag,x), color=YELLOW) imag_label = axes.get_graph_label( imag_graph, r"Im{\phi(x)}", x_val=10, direction=UP / 2 )
plot = VGroup(axes, real_graph, imag_graph) labels = VGroup(axes_labels, real_label, imag_label)
def real_updater(mobj, dt): o.simulate(dt=dt*2) mobj.become(axes.plot(lambda k:(o.phi.real)[find_nearest(x,k)], color=BLUE)) return mobj
def imag_updater(mobj, dt): mobj.become(axes.plot(lambda k:(o.phi.imag)[find_nearest(x,k)], color=YELLOW)) return mobj
real_graph.add_updater(real_updater) imag_graph.add_updater(imag_updater) real_label.add_updater(lambda x:x.become(axes.get_graph_label(real_graph, r"Re{\phi(x)}", x_val=-10, direction=UP / 2))) imag_label.add_updater(lambda x:x.become(axes.get_graph_label(imag_graph, r"Im{\phi(x)}", x_val=10, direction=UP / 2)))
self.add(plot, labels) self.wait(2 + random()) # force update
%manim -qh QMSim2
Simulation on 2D
Applying the Schrödinger equation in 2D, we can simulate how a 2D wave function evolves.
However, the calculation for 2D simulation is too complicated for my laptop to run an animation of it. Hence merely an image of the initial state of a 2D wave function is obtained in a relatively low resolution.
# Constantsx = np.linspace(-10, 10, 100)y = np.linspace(-10, 10, 100)X, Y = np.meshgrid(x, y)
class Simulation2D(): # For simplicity, some code's folded up def __init__(self, phi_0, v): self.phi = dc(phi_0); self.v = dc(v) self.dx = x[1] - x[0]; self.dy = y[1] - y[0] self.lenx = len(phi_0[0]); self.leny = len(phi_0) self._normalise(); self._hamiltonian()
def _normalise(self): self.phi /= np.linalg.norm(self.phi)
def _hamiltonian(self): """Returns Hamiltonian using finite differences.""" L1 = [[0 for j in range(self.lenx)] for i in range(self.lenx)] for i in range(self.lenx): for j in range(self.lenx): if i == j: L1[i][j] = -2 if abs(i-j) == 1: L1[i][j] = 1
L2 = [[0 for j in range(self.leny)] for i in range(self.leny)] for i in range(self.leny): for j in range(self.leny): if i == j: L2[i][j] = -2 if abs(i-j) == 1: L2[i][j] = 1
# 2D Laplacian using Kronecker products L1 = np.array(L1) / self.dx ** 2; L2 = np.array(L2) / self.dy ** 2 I_x = sp.sparse.identity(self.lenx); I_y = sp.sparse.identity(self.leny) L2D = sp.sparse.kron(I_y, L1) + sp.sparse.kron(L2, I_x) H = (-L2D / 2).toarray() for i in range(len(self.v)): # Somehow sp.sparse.diags doesn't work qaq H[i+i*self.lenx] = self.v[i//self.lenx][i%self.lenx]
self.H = sp.sparse.csr_matrix(H)
def simulate(self, dt = 1e-3): """Generates wavefunction and time at the next time step.""" U = sp.linalg.expm(-1j * self.H.toarray() * dt) U[(U.real**2 + U.imag**2) < 1E-10] = 0 phi_flat = self.phi.flatten() phi_flat = U @ phi_flat self.phi = phi_flat.reshape((self.lenx, self.leny)) self.phi[self.v > inf-1] = 0
def probability(self): return (self.phi * self.phi.conjugate()).real
def gaussian_wavepacket_2D(x0, y0, sigma, p0): """Gaussian wavepacket at x0 +/- sigma, y0 +/- sigma with average momentum, p0.""" global x, y return np.exp(1j*p0*x - (((X - x0)**2+(Y - y0)**2)/(2 * sigma**2)))
def Free2D(): phi_0 = gaussian_wavepacket_2D(3, 0, 0.5, -1) V = X*X+Y*Y o = Simulation2D(phi_0, V) for i in tqdm.trange(0): o.simulate(0.5)
from matplotlib import cm from matplotlib.ticker import LinearLocator
fig, ax = plt.subplots(subplot_kw={"projection": "3d"})
# Plot the surface. surf = ax.plot_surface(X, Y, o.probability(), cmap=cm.coolwarm, linewidth=0, antialiased=False)
# Customize the z axis. #ax.set_zlim(-1.01, 1.01) ax.zaxis.set_major_locator(LinearLocator(10)) # A StrMethodFormatter is used automatically ax.zaxis.set_major_formatter('{x:.02f}')
# Add a color bar which maps values to colors. fig.colorbar(surf, shrink=0.5, aspect=5)
plt.show()
Free2D()
Prospect
Some improvements could also be made regarding this simulation and animation …
- Record physical quantities (momentum, energy, …) during the simulation
- Increase the resolution for a higher accuracy
- Complete simulations in 2D
- …
Thanks for listening ~
Reference
https://www.astro.utoronto.ca/~mahajan/notebooks/quantum_tunnelling.html