Tutorial · Quantum Physics · Numerical Methods · JavaScript
📅 July 2026 ⏱ ≈ 30 min 🎯 Intermediate – Advanced

Numerical Schrödinger Equation in 1D: a Crank-Nicolson Solver

Only a handful of quantum systems (the box, the harmonic oscillator, the hydrogen atom) have closed-form eigenstates. Everything else — an arbitrary potential well, a moving barrier, a double well — needs a numerical solver. This tutorial builds one from scratch: a finite-difference Hamiltonian and the unconditionally stable Crank-Nicolson time-stepping scheme used in the Schrödinger equation simulation on this site.

1. Discretising Space

Divide the domain [x_min, x_max] into N grid points spaced by dx = (x_max − x_min)/(N−1). The complex wave function ψⱼ = ψ(xⱼ, t) is stored as two parallel Float64Array buffers, real and imaginary — never as a single complex type, since JavaScript has none built in:

const N  = 400;
const xMin = -20, xMax = 20;
const dx = (xMax - xMin) / (N - 1);
const x  = Float64Array.from({ length: N }, (_, j) => xMin + j * dx);
const psiRe = new Float64Array(N);
const psiIm = new Float64Array(N);

// Initial Gaussian wave packet: psi(x,0) = exp(-(x-x0)^2/(4*sigma^2)) * exp(i*k0*x)
function initGaussian(x0, sigma, k0) {
  let norm = 0;
  for (let j = 0; j < N; j++) {
    const env = Math.exp(-(x[j] - x0) ** 2 / (4 * sigma * sigma));
    psiRe[j] = env * Math.cos(k0 * x[j]);
    psiIm[j] = env * Math.sin(k0 * x[j]);
    norm += (psiRe[j] ** 2 + psiIm[j] ** 2) * dx;
  }
  const invSqrtNorm = 1 / Math.sqrt(norm);
  for (let j = 0; j < N; j++) { psiRe[j] *= invSqrtNorm; psiIm[j] *= invSqrtNorm; }
}

2. The Finite-Difference Hamiltonian

The second spatial derivative in Ĥ = −ℏ²/(2m)∂²/∂x² + V(x) is approximated by the standard three-point central difference:

ψ''(xⱼ) ≈ (ψⱼ₊₁ − 2ψⱼ + ψⱼ₋₁) / dx² (Ĥψ)ⱼ = −ℏ²/(2m·dx²) · (ψⱼ₊₁ − 2ψⱼ + ψⱼ₋₁) + Vⱼψⱼ = −α·ψⱼ₋₁ + (2α + Vⱼ)·ψⱼ − α·ψⱼ₊₁, α = ℏ²/(2m·dx²)

Written as a matrix, Ĥ is tridiagonal: −α on the two off-diagonals, (2α + Vⱼ) on the diagonal. This sparsity is the entire reason the whole scheme stays fast — a direct dense solve would cost O(N³) per time step; the tridiagonal structure brings it down to O(N).

3. Why Explicit Euler Fails

The obvious first attempt — ψ(t+dt) = ψ(t) − i(dt/ℏ)Ĥψ(t) — is the explicit (forward) Euler method applied to iℏ∂ψ/∂t = Ĥψ. It is unconditionally unstable: because Ĥ is Hermitian with real eigenvalues E, the exact time evolution factor e−iEdt/ℏ has magnitude exactly 1, but its first-order Taylor approximation 1 − iEdt/ℏ has magnitude √(1+(Edt/ℏ)²) > 1 for any non-zero dt. Every mode grows exponentially, however small the time step.

Symptom: if you see the total probability Σ|ψⱼ|²dx slowly (or explosively) increasing frame by frame instead of staying pinned at 1, you are looking at exactly this instability — the fix is not a smaller dt, it's a different scheme.

4. The Crank-Nicolson Scheme

Crank-Nicolson averages the Hamiltonian's action at the old and new time steps, giving a scheme that is second-order accurate in dt and exactly unitary (norm-preserving) for any time step:

(I + iĤdt/2ℏ) ψ(t+dt) = (I − iĤdt/2ℏ) ψ(t) // This is the Cayley approximation to e^{-iHdt/hbar}: // (1 - ix/2)/(1 + ix/2) ≈ e^{-ix}, and has |.| = 1 exactly for real x

The right-hand side is a simple tridiagonal matrix-vector product (explicit, cheap). The left-hand side requires solving a tridiagonal linear system each step — still O(N), but a genuine solve rather than a direct evaluation.

5. Solving the Tridiagonal System: the Thomas Algorithm

A tridiagonal system Ax = d (sub-diagonal a, diagonal b, super-diagonal c) is solved in a single forward-then-back sweep, the Thomas algorithm — Gaussian elimination specialised to bandwidth 1:

// Complex tridiagonal solve via Thomas algorithm.
// a, b, c, d are arrays of {re, im} complex numbers; b/d are overwritten.
function thomasSolve(a, b, c, d, N) {
  // Forward sweep: eliminate sub-diagonal
  for (let j = 1; j < N; j++) {
    const w = cdiv(a[j], b[j - 1]);         // w = a[j] / b[j-1]
    b[j] = csub(b[j], cmul(w, c[j - 1]));  // b[j] -= w * c[j-1]
    d[j] = csub(d[j], cmul(w, d[j - 1]));  // d[j] -= w * d[j-1]
  }
  // Back substitution
  const x = new Array(N);
  x[N - 1] = cdiv(d[N - 1], b[N - 1]);
  for (let j = N - 2; j >= 0; j--) {
    x[j] = cdiv(csub(d[j], cmul(c[j], x[j + 1])), b[j]);
  }
  return x;
}
// cmul/cdiv/csub: trivial complex arithmetic helpers on {re, im} objects
function cmul(p, q) { return { re: p.re*q.re - p.im*q.im, im: p.re*q.im + p.im*q.re }; }
function csub(p, q) { return { re: p.re - q.re, im: p.im - q.im }; }
function cdiv(p, q) { const d2 = q.re*q.re + q.im*q.im; return { re: (p.re*q.re + p.im*q.im)/d2, im: (p.im*q.re - p.re*q.im)/d2 }; }
O(N) per frame: this is the entire cost of one time step (building the right-hand side is also O(N)) — a 1D Crank-Nicolson solver comfortably runs at 60 fps for N in the thousands, entirely on the CPU main thread.

6. Boundary Conditions and Potentials

Two boundary choices are common. Hard walls fix ψ(x_min) = ψ(x_max) = 0 for all t (equivalent to an infinite square well containing the whole grid) — simplest to implement, but wave packets reflect unphysically off the domain edges. Absorbing boundaries add a smooth imaginary potential −iΓ(x) near the edges (a "complex absorbing potential", CAP) that damps ψ before it reaches the boundary, mimicking an open, infinite domain:

// Potential: finite square well/barrier + absorbing edges
function buildPotential(x, N, wellDepth, wellWidth, gamma0 = 2.0, absorbWidth = 3) {
  const V = new Float64Array(N);
  const Vi = new Float64Array(N);  // imaginary part: absorbing layer
  for (let j = 0; j < N; j++) {
    V[j] = Math.abs(x[j]) < wellWidth / 2 ? -wellDepth : 0;
    const distFromEdge = Math.min(x[j] - x[0], x[N-1] - x[j]);
    if (distFromEdge < absorbWidth) {
      Vi[j] = -gamma0 * (1 - distFromEdge / absorbWidth) ** 2;
    }
  }
  return { V, Vi };
}

7. Full Working Code

Putting it all together: build the tridiagonal Ĥ, assemble the (I ± iĤdt/2ℏ) matrices, and step forward each frame.

function createSolver({ N, dx, hbar, mass, V, dt }) {
  const alpha = hbar * hbar / (2 * mass * dx * dx);
  const r = { re: 0, im: dt / (2 * hbar) };   // i*dt/(2*hbar) split into re/im

  // Diagonal of H (tridiagonal): 2*alpha + V[j]; off-diagonals: -alpha
  const diagH = Array.from({ length: N }, (_, j) => 2 * alpha + V[j]);

  function step(psiRe, psiIm) {
    // Right-hand side: (I - i*H*dt/2hbar) psi, tridiagonal matvec
    const dRe = new Float64Array(N), dIm = new Float64Array(N);
    for (let j = 0; j < N; j++) {
      let hRe = diagH[j] * psiRe[j], hIm = diagH[j] * psiIm[j];
      if (j > 0)     { hRe -= alpha * psiRe[j-1]; hIm -= alpha * psiIm[j-1]; }
      if (j < N - 1) { hRe -= alpha * psiRe[j+1]; hIm -= alpha * psiIm[j+1]; }
      // psi - i*(dt/2hbar)*H*psi = psi_re + H_im*(dt/2hbar), psi_im - H_re*(dt/2hbar)
      dRe[j] = psiRe[j] + r.im * hIm;
      dIm[j] = psiIm[j] - r.im * hRe;
    }
    // Left-hand side matrix (I + i*H*dt/2hbar): tridiagonal, solved with Thomas algorithm
    const a = Array.from({ length: N }, () => ({ re: 0, im: -r.im * -alpha }));
    const b = Array.from({ length: N }, (_, j) => ({ re: 1, im: r.im * diagH[j] }));
    const c = a.slice();
    const d = Array.from({ length: N }, (_, j) => ({ re: dRe[j], im: dIm[j] }));
    const x = thomasSolve(a, b, c, d, N);
    for (let j = 0; j < N; j++) { psiRe[j] = x[j].re; psiIm[j] = x[j].im; }
  }
  return { step };
}

8. Verifying Accuracy: Normalisation and Energy

Two cheap diagnostics catch nearly every discretisation bug before it becomes a visual artefact:

Choosing dt: stability is unconditional, but accuracy still requires dt small enough to resolve the fastest phase oscillation e−iE_max t/ℏ in the initial state — a good rule of thumb is dt ≲ ℏ/(10·E_max), where E_max is estimated from the highest-momentum component of the initial wave packet (ℏ²k_max²/2m).

Frequently Asked Questions

What will I learn in this tutorial?

Build a 1D time-dependent Schrödinger equation solver in JavaScript: spatial discretisation, the finite-difference Hamiltonian, the Crank-Nicolson scheme, the Thomas algorithm, and stability/normalisation checks.

What topics are covered in this tutorial?

This tutorial covers: Discretising Space, The Finite-Difference Hamiltonian, Why Explicit Euler Fails, The Crank-Nicolson Scheme, Solving the Tridiagonal System, Boundary Conditions and Potentials, Full Working Code, Verifying Accuracy.

How long does this tutorial take?

This tutorial takes approximately 30 minutes to complete.

What prerequisites do I need before starting?

This is a Intermediate – Advanced-level tutorial — no special preparation beyond basic JavaScript is assumed.