Skip to content

C.2

The split-operator method

The algorithm behind this site's wave-packet animations: multiply a potential phase in position space, a kinetic phase in momentum space, and let the FFT hop between them — strictly norm-preserving, second-order convergent, with full code and a trap list.

Recommended first

After this section you should be able to

  • State the operator-splitting idea and the error order of Strang splitting
  • Write a complete program that evolves a wave packet using the FFT
  • Explain why the algorithm preserves the norm exactly, and know the traps that periodic boundaries bring

The solution of the time-dependent Schrödinger equation, Ψ(t)=eiH^t/Ψ(0)\Psi(t)=\ee^{-\ii\hat Ht/\hbar}\Psi(0), takes one line to write and considerable trouble to compute: inside H^=T^+V^\hat H=\hat T+\hat V, the kinetic term T^\hat T is a differential operator in the position representation. The observation behind the split-operator method:

  • eiV^Δt/\ee^{-\ii\hat V\Delta t/\hbar} is diagonal in the position representation — a pointwise phase multiplication, essentially free;
  • eiT^Δt/\ee^{-\ii\hat T\Delta t/\hbar} is diagonal in the momentum representation — also a pointwise phase;
  • the change of basis between the two representations is exactly the Fourier transform (A.2), and the FFT does it in O(NlogN)O(N\log N).

So each evolution step = multiply a phase → FFT → multiply a phase → inverse FFT. The animations in the main text — the wave packet hitting a barrier, the spreading free packet — are all this algorithm running.

The splitting formula and its error order

The trouble is that T^\hat T and V^\hat V do not commute, so ei(T^+V^)Δt/eiT^Δt/eiV^Δt/\ee^{-\ii(\hat T+\hat V)\Delta t/\hbar}\ne\ee^{-\ii\hat T\Delta t/\hbar}\ee^{-\ii\hat V\Delta t/\hbar}. But for small Δt\Delta t they can be approximately pulled apart, and the symmetry of the split determines the accuracy:

The complete flow of one evolution step (Strang splitting):

Ψ  ×eiVΔt/2   FFT   ×eik2Δt/2m   FFT1   ×eiVΔt/2  Ψ(C.2.3)\Psi\ \xrightarrow{\ \times\,\ee^{-\ii V\Delta t/2\hbar}\ } \ \xrightarrow{\ \text{FFT}\ } \ \xrightarrow{\ \times\,\ee^{-\ii\hbar k^2\Delta t/2m}\ } \ \xrightarrow{\ \text{FFT}^{-1}\ } \ \xrightarrow{\ \times\,\ee^{-\ii V\Delta t/2\hbar}\ }\ \Psi'\tag{C.2.3}

Complete code: a wave packet hits a square barrier

A packet with EV0E\approx V_0 strikes a barrier and roughly half passes, half bounces back (natural units =m=1\hbar=m=1):

import numpy as np

# ---- Grid ----
L, N = 40.0, 1024
x = np.linspace(-L/2, L/2, N, endpoint=False)
dx = x[1] - x[0]
k = 2 * np.pi * np.fft.fftfreq(N, d=dx)     # momentum grid in FFT convention

# ---- Potential: a square barrier in the middle ----
V0, a = 2.0, 1.0
V = np.where(np.abs(x) < a / 2, V0, 0.0)

# ---- Initial state: Gaussian packet incident from the left (central momentum k0, kinetic energy k0²/2 = 2 = V0) ----
x0, k0, sigma = -10.0, 2.0, 1.5
psi = np.exp(-(x - x0)**2 / (4 * sigma**2) + 1j * k0 * x)
psi /= np.sqrt(np.sum(np.abs(psi)**2) * dx)   # normalise

# ---- Precompute the evolution factors (Strang splitting: V/2 → T → V/2) ----
dt, steps = 0.005, 2000
expV_half = np.exp(-0.5j * V * dt)
expT      = np.exp(-0.5j * k**2 * dt)

for _ in range(steps):
    psi = expV_half * psi                        # half step of potential
    psi = np.fft.ifft(expT * np.fft.fft(psi))    # full kinetic step (momentum space)
    psi = expV_half * psi                        # half step of potential

# ---- Checks: norm conservation + transmission/reflection probabilities ----
norm = np.sum(np.abs(psi)**2) * dx
T = np.sum(np.abs(psi[x >  a/2])**2) * dx
R = np.sum(np.abs(psi[x < -a/2])**2) * dx
print(f"after {steps} steps  norm = {norm:.12f}")
print(f"transmission T = {T:.4f}   reflection R = {R:.4f}   T + R = {T+R:.4f}")

Measured output:

after 2000 steps  norm = 1.000000000000
transmission T = 0.5119   reflection R = 0.4865   T + R = 0.9984

The key lines:

  • np.fft.fftfreq(N, d=dx): gives the wavenumber corresponding to each array index in FFT convention (the first half is positive kk, the second half negative kk); multiplied by 2π2\pi it is the momentum grid. Hand-rolling the kk array yourself is this algorithm’s number-one source of bugs — get the ordering wrong and the packet shatters instantly.
  • endpoint=False: the FFT implies periodic boundaries; x=L/2x=-L/2 and x=+L/2x=+L/2 are the same point and must not be counted twice.
  • The evolution factors are precomputed outside the loop: each step is then only pointwise multiplications and two FFTs.
  • The norm is exact to 12 digits — norm preservation is structural, not dependent on Δt\Delta t being small.
  • T+R=0.9984T+R=0.9984 is not an error: the missing 0.0016 is the probability still lingering inside the barrier region x<a/2\lvert x\rvert<a/2.
  • Plot np.abs(psi)**2 every few steps along the way, and you have the tunnelling animation from the main text.

Convergence-order check

Evolve a coherent state in the oscillator potential, using a tiny-step solution as reference, and watch the error against Δt\Delta t:

import numpy as np

L, N = 40.0, 1024
x = np.linspace(-L/2, L/2, N, endpoint=False)
dx = x[1] - x[0]
k = 2 * np.pi * np.fft.fftfreq(N, d=dx)
V = 0.5 * x**2                        # oscillator potential

psi0 = np.exp(-(x - 3.0)**2 / 2)      # coherent state: sloshes back and forth without changing shape
psi0 = psi0 / np.sqrt(np.sum(np.abs(psi0)**2) * dx)

def evolve(psi, dt, steps):
    expV_half = np.exp(-0.5j * V * dt)
    expT = np.exp(-0.5j * k**2 * dt)
    for _ in range(steps):
        psi = expV_half * np.fft.ifft(expT * np.fft.fft(expV_half * psi))
    return psi

T_total = 2.0
ref = evolve(psi0.copy(), T_total / 6400, 6400)   # tiny step as reference solution

print("   dt        error ‖ψ - ψ_ref‖    ratio")
prev = None
for steps in [50, 100, 200, 400]:
    psi = evolve(psi0.copy(), T_total / steps, steps)
    err = np.sqrt(np.sum(np.abs(psi - ref)**2) * dx)
    ratio = f"{prev/err:.2f}" if prev else "  --"
    print(f" {T_total/steps:.4f}     {err:.3e}      {ratio}")
    prev = err

Measured output:

   dt        error ‖ψ - ψ_ref‖    ratio
 0.0400     1.162e-03        --
 0.0200     2.905e-04      4.00
 0.0100     7.257e-05      4.00
 0.0050     1.809e-05      4.01

Halve the step, the error drops precisely to a quarter — the O(Δt2)O(\Delta t^2) global error of Strang splitting, in perfect agreement with the derivation.

Section 104 of 106 · use to turn the page