name: reaction-diffusion description: Spatiotemporal modeling using partial differential equations (PDEs). Captures both local reactions and spatial diffusion. Essential for pattern formation, disease spread, pollution dispersal, and ecological invasion problems in MCM/ICM.
Reaction-Diffusion Models
Partial differential equations (PDEs) describing how quantities evolve in both time and space through local reactions and spatial diffusion.
When to Use
- Spatial Epidemic Spread: Disease propagating across geographic regions.
- Ecological Invasion: Invasive species spreading through a habitat.
- Pattern Formation: Animal coat patterns, chemical oscillations (Turing patterns).
- Pollution Dispersal: Contaminant diffusion in air/water.
- Fire Spread: Forest fire propagation.
- Tumor Growth: Cancer cells invading tissue.
When NOT to Use
- No Spatial Structure: Use ODE models (
logistic-growth,lotka-volterra). - Discrete Space: Use cellular automata or agent-based models.
- Advection Dominant: Use advection-diffusion equations (different PDE).
- Very Small Scale: Use stochastic particle models.
Mathematical Foundation
General Form
$$\frac{\partial u}{\partial t} = D \nabla^2 u + f(u)$$
Where:
- $u(x, t)$: Concentration/density at position $x$ and time $t$
- $D$: Diffusion coefficient (spatial spread rate)
- $\nabla^2 u$: Laplacian operator (measures local curvature)
- $f(u)$: Reaction term (local dynamics)
1D Laplacian (Finite Difference)
$$\nabla^2 u \approx \frac{u_{i+1} - 2u_i + u_{i-1}}{\Delta x^2}$$
2D Laplacian
$$\nabla^2 u \approx \frac{u_{i+1,j} + u_{i-1,j} + u_{i,j+1} + u_{i,j-1} - 4u_{i,j}}{\Delta x^2}$$
Classic Models
1. Fisher-KPP Equation (Single Species)
$$\frac{\partial u}{\partial t} = D \frac{\partial^2 u}{\partial x^2} + ru\left(1 - \frac{u}{K}\right)$$
- Interpretation: Population diffusion + Logistic growth
- Application: Invasive species, gene spread
- Wave Speed: $c = 2\sqrt{Dr}$ (minimum speed of traveling wave)
2. Gray-Scott Model (Pattern Formation)
$$\begin{cases} \frac{\partial u}{\partial t} = D_u \nabla^2 u - uv^2 + F(1-u) \ \frac{\partial v}{\partial t} = D_v \nabla^2 v + uv^2 - (F+k)v \end{cases}$$
- Interpretation: Two chemicals reacting and diffusing
- Application: Turing patterns, self-organization
- Parameters: $F$ (feed rate), $k$ (kill rate)
3. SIR Spatial Model (Epidemic Spread)
$$\begin{cases} \frac{\partial S}{\partial t} = D_S \nabla^2 S - \beta SI \ \frac{\partial I}{\partial t} = D_I \nabla^2 I + \beta SI - \gamma I \ \frac{\partial R}{\partial t} = D_R \nabla^2 R + \gamma I \end{cases}$$
- Interpretation: Susceptible-Infected-Recovered with spatial movement
- Application: Disease spread across cities/regions
Implementation Template
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.animation as animation
from matplotlib.colors import LinearSegmentedColormap
import warnings
warnings.filterwarnings('ignore')
class ReactionDiffusion1D:
"""
1D Reaction-Diffusion solver using finite difference method
"""
def __init__(self, L, N, D, reaction_func, boundary='neumann'):
"""
Args:
L (float): Spatial domain length
N (int): Number of spatial grid points
D (float): Diffusion coefficient
reaction_func (callable): Reaction term f(u)
boundary (str): 'neumann' (no-flux) or 'dirichlet' (fixed) or 'periodic'
"""
self.L = L
self.N = N
self.dx = L / (N - 1)
self.x = np.linspace(0, L, N)
self.D = D
self.reaction_func = reaction_func
self.boundary = boundary
# Stability condition: dt <= dx^2 / (2*D)
self.dt_max = self.dx**2 / (2 * D)
self.dt = 0.9 * self.dt_max # Use 90% of max for safety
self.u_history = []
self.t_history = []
def laplacian(self, u):
"""
Compute Laplacian using finite differences
"""
lapl = np.zeros_like(u)
# Interior points
lapl[1:-1] = (u[2:] - 2*u[1:-1] + u[:-2]) / self.dx**2
# Boundary conditions
if self.boundary == 'neumann':
# Zero flux: du/dx = 0 at boundaries
lapl[0] = (u[1] - u[0]) / self.dx**2
lapl[-1] = (u[-2] - u[-1]) / self.dx**2
elif self.boundary == 'dirichlet':
# Fixed value (already set to 0 by initialization)
lapl[0] = 0
lapl[-1] = 0
elif self.boundary == 'periodic':
lapl[0] = (u[1] - 2*u[0] + u[-2]) / self.dx**2
lapl[-1] = (u[1] - 2*u[-1] + u[-2]) / self.dx**2
return lapl
def step(self, u):
"""
Single time step using explicit Euler method
"""
lapl = self.laplacian(u)
reaction = self.reaction_func(u)
u_new = u + self.dt * (self.D * lapl + reaction)
return u_new
def solve(self, u0, T, save_interval=10):
"""
Solve the PDE from t=0 to t=T
Args:
u0 (array): Initial condition
T (float): Final time
save_interval (int): Save every N steps
Returns:
tuple: (u_history, t_history)
"""
u = u0.copy()
t = 0
step_count = 0
self.u_history = [u.copy()]
self.t_history = [t]
while t < T:
u = self.step(u)
t += self.dt
step_count += 1
if step_count % save_interval == 0:
self.u_history.append(u.copy())
self.t_history.append(t)
return np.array(self.u_history), np.array(self.t_history)
class ReactionDiffusion2D:
"""
2D Reaction-Diffusion solver for pattern formation
"""
def __init__(self, Lx, Ly, Nx, Ny, Du, Dv, reaction_func):
"""
Args:
Lx, Ly (float): Domain size
Nx, Ny (int): Grid points
Du, Dv (float): Diffusion coefficients for u and v
reaction_func (callable): Returns (f_u, f_v) reaction terms
"""
self.Lx, self.Ly = Lx, Ly
self.Nx, self.Ny = Nx, Ny
self.dx = Lx / (Nx - 1)
self.dy = Ly / (Ny - 1)
self.Du, self.Dv = Du, Dv
self.reaction_func = reaction_func
# Stability condition (2D)
dt_max = min(self.dx**2, self.dy**2) / (4 * max(Du, Dv))
self.dt = 0.5 * dt_max
self.x = np.linspace(0, Lx, Nx)
self.y = np.linspace(0, Ly, Ny)
self.X, self.Y = np.meshgrid(self.x, self.y)
def laplacian_2d(self, u):
"""
Compute 2D Laplacian with periodic boundary
"""
lapl = np.zeros_like(u)
# Use roll for periodic boundaries
lapl += (np.roll(u, 1, axis=0) - 2*u + np.roll(u, -1, axis=0)) / self.dx**2
lapl += (np.roll(u, 1, axis=1) - 2*u + np.roll(u, -1, axis=1)) / self.dy**2
return lapl
def step(self, u, v):
"""
Single time step for two-component system
"""
lapl_u = self.laplacian_2d(u)
lapl_v = self.laplacian_2d(v)
f_u, f_v = self.reaction_func(u, v)
u_new = u + self.dt * (self.Du * lapl_u + f_u)
v_new = v + self.dt * (self.Dv * lapl_v + f_v)
return u_new, v_new
def solve(self, u0, v0, T, save_interval=100):
"""
Solve 2D system
"""
u, v = u0.copy(), v0.copy()
t = 0
step_count = 0
u_history = [u.copy()]
v_history = [v.copy()]
t_history = [t]
n_steps = int(T / self.dt)
for i in range(n_steps):
u, v = self.step(u, v)
t += self.dt
step_count += 1
if step_count % save_interval == 0:
u_history.append(u.copy())
v_history.append(v.copy())
t_history.append(t)
return np.array(u_history), np.array(v_history), np.array(t_history)
# --- Fisher-KPP Example ---
def fisher_kpp_example():
"""
Fisher-KPP equation: Invasive species spread
"""
print("=" * 60)
print("FISHER-KPP EQUATION (Invasive Species)")
print("=" * 60)
# Parameters
L = 100 # Domain length
N = 200 # Grid points
D = 0.1 # Diffusion coefficient
r = 1.0 # Growth rate
K = 1.0 # Carrying capacity
# Reaction term: Logistic growth
def reaction(u):
return r * u * (1 - u / K)
# Initialize solver
solver = ReactionDiffusion1D(L, N, D, reaction, boundary='neumann')
# Initial condition: localized population
u0 = np.zeros(N)
center = N // 4
width = 10
u0[center-width:center+width] = K
# Solve
T = 50
u_history, t_history = solver.solve(u0, T, save_interval=20)
# Theoretical wave speed
wave_speed = 2 * np.sqrt(D * r)
print(f"\nTheoretical wave speed: c = 2√(Dr) = {wave_speed:.3f}")
# Plot snapshots
fig, axes = plt.subplots(2, 1, figsize=(12, 8))
# Spatial profiles at different times
ax1 = axes[0]
for i in range(0, len(t_history), len(t_history)//5):
ax1.plot(solver.x, u_history[i], label=f't={t_history[i]:.1f}')
ax1.set_xlabel('Position (x)', fontsize=12)
ax1.set_ylabel('Population Density (u)', fontsize=12)
ax1.set_title('Fisher-KPP: Traveling Wave', fontsize=14, fontweight='bold')
ax1.legend()
ax1.grid(True, alpha=0.3)
# Spacetime plot
ax2 = axes[1]
im = ax2.imshow(u_history, aspect='auto', origin='lower',
extent=[0, L, 0, T], cmap='viridis')
ax2.set_xlabel('Position (x)', fontsize=12)
ax2.set_ylabel('Time (t)', fontsize=12)
ax2.set_title('Spacetime Diagram', fontsize=14, fontweight='bold')
plt.colorbar(im, ax=ax2, label='Density')
plt.tight_layout()
plt.savefig('fisher_kpp_analysis.png', dpi=300)
plt.show()
return solver, u_history, t_history
# --- Gray-Scott Example ---
def gray_scott_example():
"""
Gray-Scott model: Pattern formation (Turing patterns)
"""
print("\n" + "=" * 60)
print("GRAY-SCOTT MODEL (Turing Patterns)")
print("=" * 60)
# Parameters
Lx, Ly = 2.5, 2.5
Nx, Ny = 200, 200
Du = 2e-5 # Diffusion of u
Dv = 1e-5 # Diffusion of v
# Reaction parameters (spots pattern)
F = 0.055 # Feed rate
k = 0.062 # Kill rate
def reaction(u, v):
uvv = u * v * v
f_u = -uvv + F * (1 - u)
f_v = uvv - (F + k) * v
return f_u, f_v
# Initialize solver
solver = ReactionDiffusion2D(Lx, Ly, Nx, Ny, Du, Dv, reaction)
# Initial condition: uniform with small perturbation
u0 = np.ones((Ny, Nx))
v0 = np.zeros((Ny, Nx))
# Add random perturbation in center
center_x, center_y = Nx // 2, Ny // 2
size = 20
u0[center_y-size:center_y+size, center_x-size:center_x+size] = 0.5
v0[center_y-size:center_y+size, center_x-size:center_x+size] = 0.25
# Add random noise
u0 += 0.01 * np.random.random((Ny, Nx))
v0 += 0.01 * np.random.random((Ny, Nx))
# Solve
T = 10000
print(f"Simulating for T={T} time units...")
u_history, v_history, t_history = solver.solve(u0, v0, T, save_interval=500)
print(f"Generated {len(t_history)} frames")
# Plot final pattern
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
ax1 = axes[0]
im1 = ax1.imshow(u_history[-1], cmap='viridis', origin='lower')
ax1.set_title('Component u (Final)', fontsize=14, fontweight='bold')
ax1.set_xlabel('x')
ax1.set_ylabel('y')
plt.colorbar(im1, ax=ax1)
ax2 = axes[1]
im2 = ax2.imshow(v_history[-1], cmap='plasma', origin='lower')
ax2.set_title('Component v (Final)', fontsize=14, fontweight='bold')
ax2.set_xlabel('x')
ax2.set_ylabel('y')
plt.colorbar(im2, ax=ax2)
plt.tight_layout()
plt.savefig('gray_scott_patterns.png', dpi=300)
plt.show()
return solver, u_history, v_history, t_history
# --- Run Examples ---
if __name__ == "__main__":
# 1. Fisher-KPP
solver_fisher, u_hist, t_hist = fisher_kpp_example()
# 2. Gray-Scott
solver_gs, u_hist_gs, v_hist_gs, t_hist_gs = gray_scott_example()
print("\n" + "=" * 60)
print("ANALYSIS COMPLETE")
print("=" * 60)
print("Generated visualizations:")
print(" - fisher_kpp_analysis.png")
print(" - gray_scott_patterns.png")
Numerical Stability
Stability Condition (1D Explicit Euler)
$$\Delta t \leq \frac{\Delta x^2}{2D}$$
If violated, solution will explode (numerical instability).
Improving Stability
- Implicit Methods: Crank-Nicolson (unconditionally stable but requires solving linear system).
- Smaller Time Step: Reduce $\Delta t$ (but slower computation).
- Adaptive Time Stepping: Adjust $\Delta t$ based on solution behavior.
Pattern Formation (Turing Instability)
For two-component systems, patterns emerge when:
- Differential diffusion: $D_u \neq D_v$ (typically $D_v < D_u$)
- Activator-inhibitor: One species activates, other inhibits
- Specific parameter ranges: Critical values of $F$, $k$ in Gray-Scott
Gray-Scott Parameter Regimes
| F | k | Pattern |
|---|---|---|
| 0.055 | 0.062 | Spots |
| 0.035 | 0.065 | Stripes |
| 0.012 | 0.050 | Waves |
| 0.090 | 0.059 | Holes |
Common Pitfalls
- Violating Stability Condition: Always check $\Delta t \leq \frac{\Delta x^2}{2D}$.
- Wrong Boundary Conditions: Neumann (no-flux) is most common for biological systems.
- Insufficient Resolution: Use at least 100 grid points for 1D, 100×100 for 2D.
- Too Short Simulation: Patterns may take 10,000+ time units to develop.
- Ignoring Wave Speed: For Fisher-KPP, measure and compare with theory $c = 2\sqrt{Dr}$.
Integration Workflow
- Parameter Estimation: Use
automated-sweepto find parameter ranges that produce desired patterns. - Sensitivity Analysis: Use
sensitivity-masterto identify which parameters most affect wave speed or pattern type. - Comparison: Compare with ODE models (
logistic-growth) to show spatial effects. - Visualization: Use
visual-engineerto create publication-quality heatmaps and animations. - Uncertainty: Use
monte-carlo-engineto propagate parameter uncertainty to wave speed predictions.
Output Requirements for Paper
- PDE Formulation: Display the full equation with all terms explained.
- Numerical Method: "We used finite difference with explicit Euler (Δx=0.5, Δt=0.001)."
- Stability Check: "The stability condition Δt ≤ Δx²/(2D) was satisfied throughout."
- Wave Speed: "The invasion front propagated at c=0.63, matching the theoretical prediction of 2√(Dr)=0.63."
- Spatial Visualization: Show spacetime diagrams or pattern snapshots at multiple times.
- Biological Interpretation: "The diffusion coefficient D=0.1 corresponds to a dispersal distance of ~3 km/year."
Extensions
1. Advection-Diffusion
$$\frac{\partial u}{\partial t} = D \frac{\partial^2 u}{\partial x^2} - v \frac{\partial u}{\partial x} + f(u)$$
Adds directed flow (wind, river current).
2. Anisotropic Diffusion
$$\frac{\partial u}{\partial t} = D_x \frac{\partial^2 u}{\partial x^2} + D_y \frac{\partial^2 u}{\partial y^2} + f(u)$$
Different diffusion rates in different directions.
3. Fractional Diffusion
$$\frac{\partial u}{\partial t} = D \frac{\partial^\alpha u}{\partial x^\alpha} + f(u)$$
Models anomalous diffusion (Lévy flights).
Decision Guide: When to Use RD Models
| Scenario | Use Reaction-Diffusion? |
|---|---|
| Spatial spread is critical | ✅ Yes |
| Only temporal dynamics matter | ❌ No (use ODEs) |
| Pattern formation needed | ✅ Yes (Gray-Scott) |
| Discrete space (cities, networks) | ❌ No (use graphs/networks) |
| Need wave speed predictions | ✅ Yes (Fisher-KPP) |
| Stochastic effects dominant | ❌ No (use stochastic PDEs) |
Advanced: Animation Creation
def create_animation(u_history, t_history, filename='rd_animation.gif'):
"""
Create animated GIF of reaction-diffusion evolution
"""
fig, ax = plt.subplots(figsize=(10, 6))
def update(frame):
ax.clear()
ax.imshow(u_history[frame], cmap='viridis', origin='lower')
ax.set_title(f'Time: {t_history[frame]:.2f}')
ax.set_xlabel('x')
ax.set_ylabel('y')
anim = animation.FuncAnimation(fig, update, frames=len(t_history),
interval=100, repeat=True)
anim.save(filename, writer='pillow', fps=10)
plt.close()