Tutorial 4: Reaction–Diffusion (Fisher–KPP)
This tutorial solves the Fisher–KPP equation on the sphere:
\[\frac{\partial u}{\partial t} = \mu \Delta_\Gamma u + \alpha u(1 - u)\]
The Fisher–KPP equation models logistic population growth with diffusive spread. Starting from a localized initial condition, the solution evolves into a travelling wave that invades the sphere.
Mathematical background
Equilibria: $u^* = 0$ (unstable) and $u^* = 1$ (stable attractor).
Travelling wave speed (on flat $\mathbb{R}^2$): $c^* = 2\sqrt{\mu \alpha}$
On a sphere the behaviour is more complex because the curvature modulates the wave and the geometry is bounded.
Setup
using FrontIntrinsicOps
R = 1.0
mesh = generate_icosphere(R, 4)
geom = compute_geometry(mesh)
dec = build_dec(mesh, geom)
μ = 0.02 # diffusion coefficient
α = 2.0 # reaction rate (logistic)
dt = 1e-3 # time step
T = 5.0 # final timeInitial condition
Start with a small patch near the north pole:
u = zeros(length(mesh.points))
for (i, p) in enumerate(mesh.points)
if p[3] > 0.8 # northern cap
u[i] = 1.0
end
endIntegration with the cache
cache = build_pde_cache(mesh, geom, dec; μ=μ, dt=dt, θ=1.0)
R_fkpp = fisher_kpp_reaction(α)
N_steps = round(Int, T / dt)
t = 0.0
for n in 1:N_steps
step_diffusion_cached!(u, cache, R_fkpp, t)
t += dt
end
println("Final coverage: u > 0.5 at ", count(u .> 0.5), " / ", length(u), " vertices")Monitoring the solution
To record the solution at intermediate times:
u = zeros(length(mesh.points))
for (i, p) in enumerate(mesh.points)
u[i] = p[3] > 0.8 ? 1.0 : 0.0
end
cache = build_pde_cache(mesh, geom, dec; μ=μ, dt=dt, θ=1.0)
R_fkpp = fisher_kpp_reaction(α)
snapshots = [copy(u)]
times = [0.0]
N_steps = round(Int, T / dt)
for n in 1:N_steps
step_diffusion_cached!(u, cache, R_fkpp, (n-1)*dt)
if n % 500 == 0
push!(snapshots, copy(u))
push!(times, n * dt)
println("t = ", times[end], " min=", minimum(u), " max=", maximum(u))
end
endChecking the maximum principle
The Fisher–KPP equation has a maximum principle: if $u^0 \in [0,1]$, then $u(t) \in [0,1]$ for all $t > 0$. Check this numerically:
println("u range: [", minimum(u), ", ", maximum(u), "]")
# Should remain within [0, 1] (approximately — IMEX can slightly exceed bounds)Convergence test with exact solution
For linear decay $R(u) = -\alpha u$, there is an exact solution:
# Decay test: exact solution = e^{-(μ λ₁ + α) t} z
# λ₁ = 2/R² for ℓ=1 mode on unit sphere
λ1 = 2.0 / R^2
α_decay = 0.5
R_decay = linear_decay_reaction(α_decay)
u0 = [p[3] for p in mesh.points] # ℓ=1 mode
u = copy(u0)
cache_decay = build_pde_cache(mesh, geom, dec; μ=μ, dt=dt, θ=1.0)
T_final = 1.0
N = round(Int, T_final / dt)
for n in 1:N
step_diffusion_cached!(u, cache_decay, R_decay, (n-1)*dt)
end
decay_exact = exp(-(μ * λ1 + α_decay) * T_final)
u_exact = decay_exact .* u0
err = maximum(abs, u .- u_exact)
println("Linear decay error: $err (expected ~$(dt * (μ*λ1 + α_decay)))")Custom reaction term
Define your own reaction term as a pointwise callback:
# Bistable / Allen–Cahn reaction
bistable(u, x, t) = 5.0 * u * (1 - u) * (u - 0.5)
u = rand(length(mesh.points)) # random initial data in [0,1]
cache = build_pde_cache(mesh, geom, dec; μ=0.01, dt=1e-3, θ=1.0)
for n in 1:2000
step_diffusion_cached!(u, cache, bistable, n*1e-3)
end
# Solution should approach a pattern of u ≈ 0 and u ≈ 1 domains
println("u < 0.2: ", count(u .< 0.2), " vertices")
println("u > 0.8: ", count(u .> 0.8), " vertices")