The scheme becomes violently unstable because the diffusion term violates the stability criterion when using explicit time-stepping.
Quick diagnosis
For the explicit FTCS (forward-time centered-space) diffusion term:
$$
D \frac{\partial^2 C}{\partial x^2} \quad \Rightarrow \quad \text{stable only if} \quad \frac{D \Delta t}{\Delta x^2} \le 0.5
$$
In your run:
- dx ≈ 0.125
- dt ≈ 0.044
- D = 0.5
→ diffusion number = D Δt / Δx² ≈ 0.5 × 0.044 / (0.125)² ≈ 2.8
→ way above 0.5 → unconditionally unstable after a few dozen steps.
The upwind advection part is stable (CFL = 0.7 < 1), but the diffusion ruins everything.
Fast fixes — pick one
1. Best quick fix: reduce dt a lot (make diffusion stable)
# Force diffusion stability instead of pure advection CFLdiff_number_max = 0.45dt_diff = diff_number_max * dx**2 / Ddt = min(CFL * dx / U, dt_diff) # take the more restrictive one
With D=0.5, dx≈0.125 → dt ≤ ~0.011 → you’ll need ~3500–4000 steps instead of ~900.
2. Recommended: switch to implicit diffusion (Crank–Nicolson or fully implicit)
This removes the Δt / Δx² restriction for diffusion (you can keep CFL ~0.7–0.9 for advection).
Here’s a version using explicit upwind advection + implicit Crank–Nicolson diffusion + decay/source (very stable and accurate):
import numpy as npimport matplotlib.pyplot as pltfrom scipy.sparse import diagsfrom scipy.sparse.linalg import spsolve# ─── Parameters ───────────────────────────────────────────────Lx = 100.0Nx = 601 # bit coarser to make matrix smaller/fasterdx = Lx / (Nx - 1)x = np.linspace(0, Lx, Nx)U = 2.0D = 0.5λ = 0.12α = 3.0T_end = 40.0CFL = 0.8dt = CFL * dx / U # advection controls dt — diffusion is now unconditionalNt = int(T_end / dt) + 1print(f"dx = {dx:.4f}, dt = {dt:.4f}, Nt = {Nt}")def S(t): return np.exp(-((t - 8.0)/2.5)**2)# Initial conditionC = np.zeros(Nx)C[x <= 10] = 1.0# ─── Build tridiagonal matrix for Crank-Nicolson diffusion ────r = D * dt / (2 * dx**2) # half because CN averages two levelsmain_diag = np.ones(Nx) * (1 + 2*r + λ*dt)off_diag = np.ones(Nx-1) * (-r)# We'll modify boundaries laterA = diags([off_diag, main_diag, off_diag], [-1, 0, 1], format='csr')C_history = [C.copy()]times = [0.0]# ─── Time loop ─────────────────────────────────────────────────for n in range(Nt): t = (n+1) * dt # Right-hand side: explicit part (advection + old diffusion + decay + source) b = np.zeros(Nx) # Upwind advection (explicit) adv = np.zeros(Nx) adv[1:] -= U * (C[1:] - C[:-1]) / dx # Explicit half diffusion + decay + source diff_old = D * (np.roll(C, -1) - 2*C + np.roll(C, 1)) / dx**2 diff_old[0] = diff_old[-1] = 0 # boundaries b = C + dt * (adv + 0.5 * diff_old - λ * C + α * S(t)) # Implicit half diffusion is already in A # Boundaries: Dirichlet left = 0, Neumann right ≈ zero gradient A = A.copy() # reset if needed b[0] = 0.0 A[0, :] = 0 A[0, 0] = 1 A[-1, :] = 0 A[-1, -2] = -1 A[-1, -1] = 1 b[-1] = 0 # dC/dx ≈ 0 → C[-1] = C[-2] # Solve C_new = spsolve(A, b) C = C_new if n % max(1, int(1.0/dt)) == 0: # save ~every 1 time unit C_history.append(C.copy()) times.append(t)# ─── Plot ──────────────────────────────────────────────────────fig, ax = plt.subplots(figsize=(11, 5))for C_snap, ts in zip(C_history[::max(1,len(C_history)//12)], times[::max(1,len(times)//12)]): ax.plot(x, C_snap, lw=1.2, label=f"t={ts:.1f}")ax.set_xlabel("x")ax.set_ylabel("C(x,t)")ax.set_title(f"Upwind adv + CN diff | U={U} D={D} λ={λ} α={α}")ax.grid(True, alpha=0.25)ax.legend(ncol=2, fontsize=9)plt.show()
Summary of options
| Approach | Stability for diffusion | Max dt | Accuracy | Speed |
|---|---|---|---|---|
| Your original explicit | ≤ 0.5 Δx²/D | very small (~0.01) | low | fast |
| Reduce dt a lot | same as above | small | medium | slow |
| Crank–Nicolson diffusion (above) | unconditional | advection-limited (~0.04–0.08) | good | medium-fast |
| Fully implicit (e.g. scipy solve_banded) | unconditional | even larger possible | good | similar |
Try the Crank–Nicolson version above — it should run smoothly without explosions or huge negatives.
Let me know what parameters you’re actually using or what kind of source/initial condition you want — I can tune it further.