Ordinary Differential Equations (ODEs)#
Here we consider solving ODEs of the generic form
Reduction to 1st order.
All higher-order ODEs can be reduced to a system of first-order ODEs by introducing intermediate variables. For example, consider Newton’s law:
We can express this as the system.
To complete the problem we need to specify boundary conditions. The simplest form an initial-value problem (IVP) where we specify the values of \(\vect{y}(t_0) = \vect{y}_0\) at an initial time \(t=t_0\). As long as \(\vect{f}\) is single-valued, this guarantees a unique solution up to such a point as the system becomes singular.
More complicated boundary-value problems (BVPs) arise when various conditions are specified at different times. These can be generically solved using an IVP solver through a process called shooting methods that we will discuss later.
Initial Value Problems (IVPs)#
To get work done, I recommend using scipy.integrate.solve_ivp(), which wraps
several different IVP solvers with adaptive step-size algorithms to ensure convergence.
However, you should really understand what is happening under the hood. Here we will do
this with a simple problem following [Gezerlis, 2023] §8.2:
where \(\mu\) is a real constant. This has the analytic solution
Do It!
Write some simple code and tests to solve for \(y(x)\) with \(x\in [0, 1]\) for various step-sizes. To make this concrete, you might consider expressing your solution in terms of a function like the following:
Here we reproduce Fig. 8.2 from [Gezerlis, 2023]:
mu = -20.0
def f(x, y, mu=mu):
return mu*y
def solve_euler_forward(f, Nx, x0=0, x1=1, y0=1.0):
"""Solve the IVP using forward Euler."""
xs = np.linspace(x0, x1, Nx+1)
dx = np.diff(xs).mean()
ys = [y0]
for n in range(Nx):
x, y = xs[n], ys[-1]
dy_dx = f(x=x, y=y)
dy = dy_dx * dx
ys.append(y + dy)
return xs, np.asarray(ys)
def solve_euler_backward(f, Nx, x0=0, x1=1, y0=1.0):
"""Solve the IVP using forward Euler."""
xs = np.linspace(x0, x1, Nx+1)
dx = np.diff(xs).mean()
ys = [y0]
for n in range(Nx):
x, y = xs[n], ys[-1]
dy_dx = f(x=x, y=y)
ys.append(y/(1-dy_dx * dx))
return xs, np.asarray(ys)
fig, ax = plt.subplots()
for Nx in [9, 11]:
xs, ys = solve_euler_forward(f, Nx=Nx)
ax.plot(xs, ys, ":o", label=f"Forward Euler {Nx=}")
xs, ys = solve_euler_backward(f, Nx=Nx)
ax.plot(xs, ys, "--s", label=f"Backward Euler {Nx=}")
x = np.linspace(0, 1)
ax.plot(x, np.exp(mu*x), label="Exact")
ax.legend()
<matplotlib.legend.Legend at 0x7825a2618c20>
Do It!
Explain what is happening by performing an error analysis - consider only truncation errors at this point.
What is happening can be fully understood by looking at what Euler’s method does. For this example we have
Solving these for \(y_{n+1}\) we have
Consider integrating up to \(x=1\) in \(N\) steps so that \(h = 1/N\), then \(x = nh\) and
Noting that for \(\mu < 0\), the solution \(y(x) = e^{\mu x}\) decays exponentially, we see that the forward Euler solution is unstable if \(N < -\mu\) in the sense that the solution grows rather than decays. In contrast, the backward Euler method decays for all values of \(N\) when \(\mu < 0\).
Explicit versus Implicit#
Implicit methods like backward Euler are generally much more stable than explicit methods like forward Euler, but this stability comes at a cost:
Implementing backward Euler thus requires solving a potentially non-linear equation at each step. For this reason, we generally prefer explicit methods, falling back to implicit methods only when needed.
Time-Dependent Schrödinger Equation#
The time-dependent Schrödinger equation (here in 1D) is a partial differential equation:
It can be treated as a set of coupled ODEs once we specify a procedure for computing the spatial derivatives. We will thus express it as:
where \(\ket{\psi}\) is an \(N\)-component complex vector:
consisting of the wavefunction evaluated at a set of abscissa \(\{x_n\}\), and \(\mat{H}\) is an \(N\times N\) matrix approximating the differential operator.
To start, a simple approximation can be made using finite differences
Do It!
Compute the truncation error made by this approximation: specifically, how it scales with the lattice spacing \(h\).
In this approach, we choose our abscissa to have equal “lattice” spacing \(h = x_{n+1} - x_{n}\).
The matrix representing the second-derivative is
We can thus implement the time-dependent Schrödinger equation as a complex vector-valued ODE:
Harmonic Oscillator#
Here we consider as a test case, a shifted ground state of the quantum harmonic oscillator. This state should return with the trap oscillation period, providing a test case for our code.
from scipy.integrate import solve_ivp
N = 64
L = 10.0
hbar = m = T = 1.0 # These set our units
w = 2*np.pi / T # Angular frequency
a0 = np.sqrt(hbar/m/w) # Oscillator length
x0 = 2.0 # Shift of initial state
t_unit = T
x_unit = a0
dx = L/N
n = np.arange(N)
x = n * dx - L/2
ones = np.ones(N)
D2 = (np.diag(ones[1:], k=1) + np.diag(ones[1:], k=-1) - 2 * np.diag(ones)) / dx**2
D2[0,0] = -1/dx**2
K = -hbar**2/2/m * D2
V = np.diag(m/2 * (w*x)**2)
H = K + V
def compute_dy_dt(t, psi):
Hpsi = H @ psi
return Hpsi / (1j*hbar)
# Note: It is crucial here that we make psi0 complex!
psi0 = np.exp(-((x-x0)/a0)**2/2) + 0j
res = solve_ivp(compute_dy_dt, t_span=(0, T), y0=psi0)
ts = res.t
fig, ax = plt.subplots()
psis = res.y.T
n = abs(psis)**2
mesh = ax.pcolormesh(ts / t_unit, x / x_unit, n.T)
ax.set(xlabel="$t$ [$T$]", ylabel="$x$ [$a_0$]")
plt.colorbar(mesh, label="$n$");
Aside: What is happening?#
When we first solved this problem, we used \(N=64\) points and obtained the following results which look very interesting. While this is an issue of insufficient resolution, there is actually interesting physics hiding here that we now describe. To see this, note that momentum eigenstates are eigenstates of finite-difference operator:
Thus, we can regard the finite-difference approximation as exactly implementing a modified Hamiltonian (modified dispersion) of the form
Here is a high-resolution simulation of this Hamiltonian with modified dispersion, demonstrating that the observed behaviour is indeed a result of the dispersion, and not a complete numerical artifice.
N = 256
L = 10.0
hbar = m = T = 1.0 # These set our units
w = 2*np.pi / T # Angular frequency
a0 = np.sqrt(hbar/m/w) # Oscillator length
x0 = 2.0 # Shift of initial state
t_unit = T
x_unit = a0
dx = L/N
n = np.arange(N)
x = n * dx - L/2
ones = np.ones(N)
h = L/64 # Value of h from previous attempt
k = 2*np.pi * np.fft.fftfreq(N, dx)
K = hbar**2 / m / h**2 * (1- np.cos(k*h))
V = m/2 * (w*x)**2
def compute_dy_dt(t, psi):
Hpsi = np.fft.ifft(K * np.fft.fft(psi)) + V*psi
return Hpsi / (1j*hbar)
# Note: It is crucial here that we make psi0 complex!
psi0 = np.exp(-((x-x0)/a0)**2/2) + 0j
res = solve_ivp(compute_dy_dt, t_span=(0, 2*T), y0=psi0)
ts = res.t
fig, ax = plt.subplots(figsize=(10,5))
psis = res.y.T
n = abs(psis)**2
mesh = ax.pcolormesh(ts / t_unit, x / x_unit, n.T, vmax=1)
ax.set(xlabel="$t$ [$T$]", ylabel="$x$ [$a_0$]")
plt.colorbar(mesh, label="$n$");