1 Introduction to Devito
This chapter introduces Devito, a domain-specific language (DSL) for solving partial differential equations using finite differences. We begin with the motivation for symbolic PDE specification, then work through a complete example using the 1D wave equation.
1.1 What is Devito?
Devito is a Python-based domain-specific language (DSL) for expressing and solving partial differential equations using finite difference methods. Rather than writing low-level loops that update arrays at each time step, you write the mathematical equations symbolically and let Devito generate optimized code automatically.
1.1.1 The Traditional Approach
Consider solving the 1D diffusion equation: \[ \frac{\partial u}{\partial t} = \alpha \frac{\partial^2 u}{\partial x^2} \]
A traditional NumPy implementation might look like:
import numpy as np
# Parameters
Nx, Nt = 100, 1000
dx, dt = 0.01, 0.0001
alpha = 1.0
F = alpha * dt / dx**2 # Fourier number
# Initialize
u = np.zeros(Nx + 1)
u_new = np.zeros(Nx + 1)
u[Nx//2] = 1.0 # Initial impulse
# Time stepping loop
for n in range(Nt):
for i in range(1, Nx):
u_new[i] = u[i] + F * (u[i+1] - 2*u[i] + u[i-1])
u, u_new = u_new, u # Swap arraysThis approach has several limitations:
- Error-prone: Manual index arithmetic is easy to get wrong
- Hard to optimize: Achieving good performance requires expertise in vectorization, parallelization, and cache optimization
- Dimension-specific: The code must be rewritten for 2D or 3D problems
- Not portable: Optimizations for one architecture don’t transfer to others
1.1.2 The Devito Approach
With Devito, the same problem becomes:
from devito import Grid, TimeFunction, Eq, Operator, solve, Constant
# Problem parameters
Nx = 100
L = 1.0
alpha = 1.0 # diffusion coefficient
F = 0.5 # Fourier number (for stability, F <= 0.5)
# Compute dt from stability condition: F = alpha * dt / dx^2
dx = L / Nx
dt = F * dx**2 / alpha
# Create computational grid
grid = Grid(shape=(Nx + 1,), extent=(L,))
# Define the unknown field
u = TimeFunction(name='u', grid=grid, time_order=1, space_order=2)
# Set initial condition
u.data[0, Nx // 2] = 1.0
# Define the PDE symbolically and solve for u.forward
a = Constant(name='a')
pde = u.dt - a * u.dx2
update = Eq(u.forward, solve(pde, u.forward))
# Create and run the operator
op = Operator([update])
op(time=1000, dt=dt, a=alpha)This approach offers significant advantages:
- Mathematical clarity: The PDE
u.dt - a * u.dx2 = 0is written symbolically, and Devito derives the update formula automatically usingsolve() - Automatic optimization: Devito generates C code with loop tiling, SIMD vectorization, and OpenMP parallelization
- Dimension-agnostic: The same code structure works for 1D, 2D, or 3D
- Portable performance: Generated code adapts to the target architecture
1.1.3 How Devito Works
Devito’s workflow consists of three stages:
Python DSL → Symbolic Processing → C Code Generation → Compilation → Execution
Symbolic representation: Your Python code creates SymPy expressions that represent the PDE and its discretization
Code generation: Devito analyzes the expressions and generates optimized C code with appropriate loop structures
Just-in-time compilation: The C code is compiled (and cached) the first time the operator runs
Execution: Subsequent runs use the cached compiled code for maximum performance
1.1.4 When to Use Devito
Devito excels at:
- Explicit time-stepping schemes: Forward Euler, leapfrog, Runge-Kutta
- Structured grids: Regular Cartesian meshes in 1D, 2D, or 3D
- Stencil computations: Any PDE discretized with finite differences
- Large-scale problems: Where performance optimization matters
Common applications include:
- Wave propagation (acoustic, elastic, electromagnetic)
- Heat conduction and diffusion
- Computational fluid dynamics
- Seismic imaging (reverse time migration, full waveform inversion)
1.1.5 Installation
Devito can be installed via pip:
pip install devitoFor this book, we recommend installing the optional dependencies as well:
pip install devito[extras]This includes visualization tools and additional solvers that we’ll use in later chapters.
1.1.6 What You’ll Learn
In this chapter, you will:
- Solve your first PDE (the 1D wave equation) using Devito
- Understand the core abstractions:
Grid,Function,TimeFunction,Eq, andOperator - Implement boundary conditions in Devito
- Verify your numerical solutions using convergence testing
1.2 Your First PDE: The 1D Wave Equation
We begin our exploration of Devito with the one-dimensional wave equation, a fundamental PDE that describes vibrations in strings, sound waves in tubes, and many other physical phenomena.
1.2.1 The Mathematical Model
The 1D wave equation is: \[ \frac{\partial^2 u}{\partial t^2} = c^2 \frac{\partial^2 u}{\partial x^2} \tag{1.1}\]
where:
- \(u(x, t)\) is the displacement at position \(x\) and time \(t\)
- \(c\) is the wave speed (a constant)
We solve this on a domain \(x \in [0, L]\) for \(t \in [0, T]\) with:
- Initial conditions: \(u(x, 0) = I(x)\) and \(\frac{\partial u}{\partial t}(x, 0) = 0\)
- Boundary conditions: \(u(0, t) = u(L, t) = 0\) (fixed ends)
1.2.2 Finite Difference Discretization
Using central differences in both space and time, we approximate: \[ \frac{\partial^2 u}{\partial t^2} \approx \frac{u_i^{n+1} - 2u_i^n + u_i^{n-1}}{\Delta t^2} \] \[ \frac{\partial^2 u}{\partial x^2} \approx \frac{u_{i+1}^n - 2u_i^n + u_{i-1}^n}{\Delta x^2} \]
Substituting into 1.1 and solving for \(u_i^{n+1}\): \[ u_i^{n+1} = 2u_i^n - u_i^{n-1} + C^2(u_{i+1}^n - 2u_i^n + u_{i-1}^n) \tag{1.2}\]
where \(C = c\Delta t/\Delta x\) is the Courant number. The scheme is stable for \(C \le 1\).
1.2.3 The Devito Implementation
Let’s implement this step by step:
from devito import Grid, TimeFunction, Eq, Operator
import numpy as np
# Problem parameters
L = 1.0 # Domain length
c = 1.0 # Wave speed
T = 1.0 # Final time
Nx = 100 # Number of grid points
C = 0.5 # Courant number (for stability)
# Derived parameters
dx = L / Nx
dt = C * dx / c
Nt = int(T / dt)
# Create the computational grid
grid = Grid(shape=(Nx + 1,), extent=(L,))
# Create a time-varying field
# time_order=2 because we have second derivative in time
# space_order=2 for standard second-order accuracy
u = TimeFunction(name='u', grid=grid, time_order=2, space_order=2)
# Set initial condition: a Gaussian pulse
x = grid.dimensions[0]
x_coord = 0.5 * L # Center of domain
sigma = 0.1 # Width of pulse
u.data[0, :] = np.exp(-((np.linspace(0, L, Nx+1) - x_coord)**2) / (2*sigma**2))
u.data[1, :] = u.data[0, :] # Zero initial velocity
# Define the update equation
# u.forward is u at time n+1, u is at time n, u.backward is at time n-1
# u.dx2 is the second spatial derivative
eq = Eq(u.forward, 2*u - u.backward + (c*dt)**2 * u.dx2)
# Create the operator
op = Operator([eq])
# Run the simulation
op(time=Nt, dt=dt)
# The solution is now in u.data
print(f"Simulation complete: {Nt} time steps")
print(f"Max amplitude at t={T}: {np.max(np.abs(u.data[0, :])):.6f}")1.2.4 Understanding the Code
Let’s examine each component:
Grid creation:
grid = Grid(shape=(Nx + 1,), extent=(L,))This creates a 1D grid with Nx + 1 points spanning a domain of length L. The grid spacing is automatically computed as dx = L / Nx.
TimeFunction:
u = TimeFunction(name='u', grid=grid, time_order=2, space_order=2)name='u': The symbolic name for this fieldtime_order=2: We need values at three time levels (\(n-1\), \(n\), \(n+1\)) for the second time derivativespace_order=2: Use second-order accurate spatial stencils
Initial conditions:
u.data[0, :] = ... # u at t=0
u.data[1, :] = ... # u at t=dt (for zero initial velocity, same as t=0)The data attribute provides direct access to the underlying NumPy arrays. Index 0 and 1 represent the two most recent time levels.
Update equation:
eq = Eq(u.forward, 2*u - u.backward + (c*dt)**2 * u.dx2)u.forward: The solution at the next time step (\(u^{n+1}\))u: The solution at the current time step (\(u^n\))u.backward: The solution at the previous time step (\(u^{n-1}\))u.dx2: The second spatial derivative, computed using finite differences
Operator and execution:
op = Operator([eq])
op(time=Nt, dt=dt)The Operator compiles the equations into optimized C code. Calling it runs the time-stepping loop for Nt steps with time increment dt.
1.2.5 Visualizing the Solution
import matplotlib.pyplot as plt
# Get spatial coordinates
x_vals = np.linspace(0, L, Nx + 1)
# Plot the solution at the final time
plt.figure(figsize=(10, 4))
plt.plot(x_vals, u.data[0, :], 'b-', linewidth=2)
plt.xlabel('x')
plt.ylabel('u')
plt.title(f'Wave equation solution at t = {T}')
plt.grid(True)
plt.show()1.2.6 The CFL Condition
The Courant-Friedrichs-Lewy (CFL) condition states that for stability: \[ C = \frac{c \Delta t}{\Delta x} \le 1 \]
Physically, this means information cannot travel more than one grid cell per time step. If \(C > 1\), the numerical solution will grow without bound.
Exercise: Try running the code with C = 1.5 and observe what happens.
1.2.7 What Devito Does Behind the Scenes
When you create the Operator, Devito:
- Analyzes the symbolic equations
- Determines the stencil pattern and data dependencies
- Generates optimized C code with:
- Proper loop ordering for cache efficiency
- SIMD vectorization where possible
- OpenMP parallelization for multi-core execution
- Compiles the code and caches the result
You can inspect the generated code:
print(op.ccode)This reveals the low-level implementation that Devito creates automatically.
1.3 Core Devito Abstractions
Devito provides a small set of powerful abstractions for expressing PDEs. Understanding these building blocks is essential for writing effective Devito code.
1.3.1 Grid: The Computational Domain
The Grid defines the discrete domain on which we solve our PDE:
from devito import Grid
# 1D grid: 101 points over [0, 1]
grid_1d = Grid(shape=(101,), extent=(1.0,))
# 2D grid: 101x101 points over [0, 1] x [0, 1]
grid_2d = Grid(shape=(101, 101), extent=(1.0, 1.0))
# 3D grid: 51x51x51 points over [0, 2] x [0, 2] x [0, 2]
grid_3d = Grid(shape=(51, 51, 51), extent=(2.0, 2.0, 2.0))Key properties:
shape: Number of grid points in each dimensionextent: Physical size of the domaindimensions: Symbolic dimension objects (x, y, z)spacing: Grid spacing in each dimension (computed automatically)
grid = Grid(shape=(101, 101), extent=(1.0, 1.0))
x, y = grid.dimensions # Symbolic dimensions
dx, dy = grid.spacing # Symbolic spacing (h_x, h_y)
print(f"Grid spacing: dx={float(dx)}, dy={float(dy)}")1.3.2 Function: Static Fields
A Function represents a field that does not change during time-stepping. Use it for material properties, source terms, or any spatially-varying coefficient:
from devito import Function
grid = Grid(shape=(101,), extent=(1.0,))
# Wave velocity field
c = Function(name='c', grid=grid)
c.data[:] = 1500.0 # Constant velocity (m/s)
# Spatially varying velocity
import numpy as np
x_vals = np.linspace(0, 1, 101)
c.data[:] = 1500 + 500 * x_vals # Linear velocity gradientThe space_order parameter controls the stencil width for derivatives:
# Higher-order derivatives need wider stencils
c = Function(name='c', grid=grid, space_order=4)1.3.3 TimeFunction: Time-Varying Fields
A TimeFunction represents the solution field that evolves in time:
from devito import TimeFunction
grid = Grid(shape=(101,), extent=(1.0,))
# For first-order time derivatives (diffusion equation)
u = TimeFunction(name='u', grid=grid, time_order=1, space_order=2)
# For second-order time derivatives (wave equation)
u = TimeFunction(name='u', grid=grid, time_order=2, space_order=2)Key parameters:
time_order: Number of time levels needed (1 for first derivative, 2 for second)space_order: Accuracy order for spatial derivatives
Time indexing shortcuts:
| Syntax | Meaning | Mathematical notation |
|---|---|---|
u |
Current time level | \(u^n\) |
u.forward |
Next time level | \(u^{n+1}\) |
u.backward |
Previous time level | \(u^{n-1}\) |
u.dt |
First time derivative | \(\partial u/\partial t\) |
u.dt2 |
Second time derivative | \(\partial^2 u/\partial t^2\) |
1.3.4 Derivative Notation
Devito provides intuitive notation for spatial derivatives:
| Syntax | Meaning | Stencil |
|---|---|---|
u.dx |
\(\partial u/\partial x\) | Centered difference |
u.dy |
\(\partial u/\partial y\) | Centered difference |
u.dx2 |
\(\partial^2 u/\partial x^2\) | Second derivative |
u.dy2 |
\(\partial^2 u/\partial y^2\) | Second derivative |
u.laplace |
\(\nabla^2 u\) | Laplacian (dimension-agnostic) |
The laplace operator is particularly useful because it works in any number of dimensions:
# These are equivalent for 2D:
laplacian_explicit = u.dx2 + u.dy2
laplacian_auto = u.laplace
# In 3D, u.laplace automatically becomes u.dx2 + u.dy2 + u.dz21.3.5 Eq: Defining Equations
The Eq class creates symbolic equations:
from devito import Eq
# Explicit update: u^{n+1} = expression
update = Eq(u.forward, 2*u - u.backward + dt**2 * c**2 * u.laplace)
# Using the solve() helper for implicit forms
from devito import solve
pde = u.dt2 - c**2 * u.laplace # The PDE residual
update = Eq(u.forward, solve(pde, u.forward))The solve() function is useful when the update formula is complex. It symbolically solves for the target variable.
1.3.6 Operator: Compilation and Execution
The Operator takes a list of equations and generates executable code:
from devito import Operator
# Single equation
op = Operator([update])
# Multiple equations (e.g., with boundary conditions)
op = Operator([update, bc_left, bc_right])
# Run for Nt time steps
op(time=Nt, dt=dt)The operator compiles the equations into optimized C code on first execution. Subsequent calls reuse the cached compiled code.
1.3.7 Complete Example: 2D Diffusion
Let’s put these abstractions together for a 2D diffusion problem:
from devito import Grid, TimeFunction, Eq, Operator
import numpy as np
# Create a 2D grid
grid = Grid(shape=(101, 101), extent=(1.0, 1.0))
# Time-varying field (first-order in time for diffusion)
u = TimeFunction(name='u', grid=grid, time_order=1, space_order=2)
# Parameters
alpha = 0.1 # Diffusion coefficient
dx = 1.0 / 100
F = 0.25 # Fourier number (for stability)
dt = F * dx**2 / alpha
# Initial condition: hot spot in the center
u.data[0, 45:55, 45:55] = 1.0
# The diffusion equation: u_t = alpha * (u_xx + u_yy)
# Using .laplace for dimension-agnostic code
eq = Eq(u.forward, u + alpha * dt * u.laplace)
# Create and run
op = Operator([eq])
op(time=500, dt=dt)
# Visualize
import matplotlib.pyplot as plt
plt.imshow(u.data[0, :, :], origin='lower', cmap='hot')
plt.colorbar(label='Temperature')
plt.title('2D Diffusion')
plt.show()1.3.8 Summary of Core Abstractions
| Abstraction | Purpose | Key Parameters |
|---|---|---|
Grid |
Define computational domain | shape, extent |
Function |
Static fields (coefficients) | name, grid, space_order |
TimeFunction |
Time-varying fields | name, grid, time_order, space_order |
Eq |
Define equations | LHS, RHS |
Operator |
Compile and execute | List of equations |
These five abstractions form the foundation of all Devito programs. In the following sections, we’ll see how to handle boundary conditions and verify our numerical solutions.
1.4 Boundary Conditions in Devito
Properly implementing boundary conditions is crucial for accurate PDE solutions. Devito provides several approaches, each suited to different situations.
1.4.1 Dirichlet Boundary Conditions
Dirichlet conditions specify the solution value at the boundary: \[ u(0, t) = g_0(t), \quad u(L, t) = g_L(t) \]
Method 1: Explicit equations
The most direct approach adds equations that set boundary values:
from devito import Grid, TimeFunction, Eq, Operator
grid = Grid(shape=(101,), extent=(1.0,))
u = TimeFunction(name='u', grid=grid, time_order=2, space_order=2)
# Get the time dimension for indexing
t = grid.stepping_dim
# Interior update (wave equation)
update = Eq(u.forward, 2*u - u.backward + dt**2 * c**2 * u.dx2)
# Boundary conditions: u = 0 at both ends
bc_left = Eq(u[t+1, 0], 0)
bc_right = Eq(u[t+1, 100], 0)
# Include all equations in the operator
op = Operator([update, bc_left, bc_right])Method 2: Using subdomain
For interior-only updates, use subdomain=grid.interior:
# Update only interior points (automatically excludes boundaries)
update = Eq(u.forward, 2*u - u.backward + dt**2 * c**2 * u.dx2,
subdomain=grid.interior)
# Set boundaries explicitly
bc_left = Eq(u[t+1, 0], 0)
bc_right = Eq(u[t+1, 100], 0)
op = Operator([update, bc_left, bc_right])The subdomain=grid.interior approach is often cleaner because it explicitly separates the physics (interior PDE) from the boundary treatment.
1.4.2 Neumann Boundary Conditions
Neumann conditions specify the derivative at the boundary: \[ \frac{\partial u}{\partial x}(0, t) = h_0(t), \quad \frac{\partial u}{\partial x}(L, t) = h_L(t) \]
For a zero-flux condition (\(\partial u/\partial x = 0\)), we use the ghost point method. The central difference at the boundary requires a point outside the domain: \[ \frac{\partial u}{\partial x}\bigg|_{i=0} \approx \frac{u_1 - u_{-1}}{2\Delta x} = 0 \]
This gives \(u_{-1} = u_1\), which we substitute into the interior equation:
grid = Grid(shape=(101,), extent=(1.0,))
u = TimeFunction(name='u', grid=grid, time_order=1, space_order=2)
x = grid.dimensions[0]
t = grid.stepping_dim
# Interior update (diffusion equation)
update = Eq(u.forward, u + alpha * dt * u.dx2, subdomain=grid.interior)
# Neumann BC at left (du/dx = 0): use one-sided update
# u_new[0] = u[0] + alpha*dt * 2*(u[1] - u[0])/dx^2
dx = grid.spacing[0]
bc_left = Eq(u[t+1, 0], u[t, 0] + alpha * dt * 2 * (u[t, 1] - u[t, 0]) / dx**2)
# Neumann BC at right (du/dx = 0)
bc_right = Eq(u[t+1, 100], u[t, 100] + alpha * dt * 2 * (u[t, 99] - u[t, 100]) / dx**2)
op = Operator([update, bc_left, bc_right])1.4.3 Mixed Boundary Conditions
Often we have different conditions on different boundaries:
# Dirichlet on left, Neumann on right
bc_left = Eq(u[t+1, 0], 0) # u(0,t) = 0
bc_right = Eq(u[t+1, 100], u[t+1, 99]) # du/dx(L,t) = 0 (copy from interior)
op = Operator([update, bc_left, bc_right])1.4.4 2D Boundary Conditions
For 2D problems, boundary conditions apply to all four edges:
grid = Grid(shape=(101, 101), extent=(1.0, 1.0))
u = TimeFunction(name='u', grid=grid, time_order=2, space_order=2)
x, y = grid.dimensions
t = grid.stepping_dim
Nx, Ny = 100, 100
# Interior update
update = Eq(u.forward, 2*u - u.backward + dt**2 * c**2 * u.laplace,
subdomain=grid.interior)
# Dirichlet BCs on all four edges
bc_left = Eq(u[t+1, 0, y], 0)
bc_right = Eq(u[t+1, Nx, y], 0)
bc_bottom = Eq(u[t+1, x, 0], 0)
bc_top = Eq(u[t+1, x, Ny], 0)
op = Operator([update, bc_left, bc_right, bc_bottom, bc_top])1.4.5 Time-Dependent Boundary Conditions
For boundaries that vary in time, use the time index:
from devito import Constant
# Time-varying amplitude
A = Constant(name='A')
# Sinusoidal forcing at left boundary
# u(0, t) = A * sin(omega * t)
import sympy as sp
omega = 2 * sp.pi # Angular frequency
# The time value at step n
t_val = t * dt # Symbolic time value
bc_left = Eq(u[t+1, 0], A * sp.sin(omega * t_val))
# Set the amplitude before running
op = Operator([update, bc_left, bc_right])
op(time=Nt, dt=dt, A=1.0) # Pass A as keyword argument1.4.6 Absorbing Boundary Conditions
For wave equations, we often want waves to exit the domain without reflection. A simple first-order absorbing condition is: \[ \frac{\partial u}{\partial t} + c\frac{\partial u}{\partial x} = 0 \quad \text{at } x = L \]
This can be discretized as:
# Absorbing BC at right boundary (waves traveling right)
dx = grid.spacing[0]
bc_right_absorbing = Eq(
u[t+1, Nx],
u[t, Nx] - c * dt / dx * (u[t, Nx] - u[t, Nx-1])
)More sophisticated absorbing conditions use damping layers (sponges) near the boundaries. This is covered in detail in Section 2.13.10.
1.4.7 Periodic Boundary Conditions
For periodic domains, the solution wraps around: \[ u(0, t) = u(L, t) \]
Devito doesn’t directly support periodic BCs, but they can be implemented by copying values:
# Periodic BCs: u[0] = u[Nx-1], u[Nx] = u[1]
bc_periodic_left = Eq(u[t+1, 0], u[t+1, Nx-1])
bc_periodic_right = Eq(u[t+1, Nx], u[t+1, 1])Note: The order of equations matters. Update the interior first, then copy for periodicity.
1.4.8 Best Practices
Use
subdomain=grid.interiorfor interior updates to clearly separate physics from boundary treatmentCheck boundary equation order: Boundary equations should typically come after interior updates in the operator
Verify boundary values: After running, check that boundaries have the expected values
Test with known solutions: Use problems with analytical solutions to verify boundary condition implementation
1.4.9 Example: Complete Wave Equation Solver
Here’s a complete example combining interior updates with boundary conditions:
from devito import Grid, TimeFunction, Eq, Operator
import numpy as np
# Setup
L, c, T = 1.0, 1.0, 2.0
Nx = 100
C = 0.9 # Courant number
dx = L / Nx
dt = C * dx / c
Nt = int(T / dt)
# Grid and field
grid = Grid(shape=(Nx + 1,), extent=(L,))
u = TimeFunction(name='u', grid=grid, time_order=2, space_order=2)
t = grid.stepping_dim
# Initial condition: plucked string
x_vals = np.linspace(0, L, Nx + 1)
u.data[0, :] = np.sin(np.pi * x_vals)
u.data[1, :] = u.data[0, :] # Zero initial velocity
# Equations
update = Eq(u.forward, 2*u - u.backward + (c*dt)**2 * u.dx2,
subdomain=grid.interior)
bc_left = Eq(u[t+1, 0], 0)
bc_right = Eq(u[t+1, Nx], 0)
# Solve
op = Operator([update, bc_left, bc_right])
op(time=Nt, dt=dt)
# Verify: solution should return to initial shape at t = 2L/c
print(f"Initial max: {np.max(u.data[1, :]):.6f}")
print(f"Final max: {np.max(u.data[0, :]):.6f}")For a string with fixed ends and initial shape \(\sin(\pi x)\), the solution oscillates with period \(2L/c\). After one period, it should return to the initial configuration.
1.5 Verification and Convergence Testing
How do we know our numerical solution is correct? Verification is the process of confirming that our code correctly solves the mathematical equations we intended. This section introduces key verification techniques.
1.5.1 The Importance of Verification
Numerical codes can produce plausible-looking but incorrect results due to:
- Programming errors (typos, off-by-one errors)
- Incorrect boundary condition implementation
- Stability violations
- Insufficient resolution
Systematic verification catches these problems before they corrupt scientific results.
1.5.2 Convergence Rate Testing
The most powerful verification technique is convergence rate testing. For a scheme with truncation error \(O(\Delta x^p)\), the error should decrease as: \[ E(\Delta x) \approx C \Delta x^p \]
By measuring errors at different resolutions, we can estimate \(p\): \[ p \approx \frac{\log(E_1/E_2)}{\log(\Delta x_1/\Delta x_2)} \]
If the measured rate matches the theoretical order, we have strong evidence the implementation is correct.
1.5.3 Implementing a Convergence Test
import numpy as np
from devito import Grid, TimeFunction, Eq, Operator
def solve_wave_equation(Nx, L=1.0, T=0.5, c=1.0, C=0.5):
"""Solve 1D wave equation and return error vs exact solution."""
dx = L / Nx
dt = C * dx / c
Nt = int(T / dt)
grid = Grid(shape=(Nx + 1,), extent=(L,))
u = TimeFunction(name='u', grid=grid, time_order=2, space_order=2)
t_dim = grid.stepping_dim
# Initial condition: sin(pi*x)
x_vals = np.linspace(0, L, Nx + 1)
u.data[0, :] = np.sin(np.pi * x_vals)
u.data[1, :] = np.sin(np.pi * x_vals) * np.cos(np.pi * c * dt)
# Wave equation
update = Eq(u.forward, 2*u - u.backward + (c*dt)**2 * u.dx2,
subdomain=grid.interior)
bc_left = Eq(u[t_dim+1, 0], 0)
bc_right = Eq(u[t_dim+1, Nx], 0)
op = Operator([update, bc_left, bc_right])
op(time=Nt, dt=dt)
# Exact solution: u(x,t) = sin(pi*x)*cos(pi*c*t)
t_final = Nt * dt
u_exact = np.sin(np.pi * x_vals) * np.cos(np.pi * c * t_final)
# Return max error
error = np.max(np.abs(u.data[0, :] - u_exact))
return error, dx
def convergence_test(grid_sizes):
"""Run convergence test and compute rates."""
errors = []
dx_values = []
for Nx in grid_sizes:
error, dx = solve_wave_equation(Nx)
errors.append(error)
dx_values.append(dx)
print(f"Nx = {Nx:4d}, dx = {dx:.6f}, error = {error:.6e}")
# Compute convergence rates
rates = []
for i in range(len(errors) - 1):
rate = np.log(errors[i] / errors[i+1]) / np.log(dx_values[i] / dx_values[i+1])
rates.append(rate)
print("\nConvergence rates:")
for i, rate in enumerate(rates):
print(f" {grid_sizes[i]} -> {grid_sizes[i+1]}: rate = {rate:.2f}")
return errors, dx_values, rates
# Run the test
grid_sizes = [20, 40, 80, 160, 320]
errors, dx_values, rates = convergence_test(grid_sizes)
# Check: rates should be close to 2 for second-order scheme
expected_rate = 2.0
assert all(abs(r - expected_rate) < 0.2 for r in rates), \
f"Convergence rates {rates} differ from expected {expected_rate}"1.5.4 Method of Manufactured Solutions (MMS)
For problems without analytical solutions, we use the Method of Manufactured Solutions:
- Choose a solution \(u_{\text{mms}}(x, t)\) (any smooth function)
- Compute the source term by substituting into the PDE
- Solve the modified PDE with the computed source
- Compare the numerical solution to \(u_{\text{mms}}\)
Example: Diffusion equation
Let’s verify a diffusion solver using MMS:
import sympy as sp
# Symbolic variables
x_sym, t_sym = sp.symbols('x t')
alpha_sym = sp.Symbol('alpha')
# Manufactured solution (arbitrary smooth function)
u_mms = sp.sin(sp.pi * x_sym) * sp.exp(-t_sym)
# Compute required source term: f = u_t - alpha * u_xx
u_t = sp.diff(u_mms, t_sym)
u_xx = sp.diff(u_mms, x_sym, 2)
f_mms = u_t - alpha_sym * u_xx
print("Manufactured solution:")
print(f" u_mms = {u_mms}")
print(f"Required source term:")
print(f" f = {sp.simplify(f_mms)}")Now implement the solver with this source term:
from devito import Grid, TimeFunction, Function, Eq, Operator
import numpy as np
def solve_diffusion_mms(Nx, alpha=1.0, T=0.5, F=0.4):
"""Solve diffusion with MMS source term."""
L = 1.0
dx = L / Nx
dt = F * dx**2 / alpha
Nt = int(T / dt)
grid = Grid(shape=(Nx + 1,), extent=(L,))
u = TimeFunction(name='u', grid=grid, time_order=1, space_order=2)
t_dim = grid.stepping_dim
# Spatial coordinates for evaluation
x_vals = np.linspace(0, L, Nx + 1)
# MMS: u = sin(pi*x) * exp(-t)
# Source: f = sin(pi*x) * exp(-t) * (alpha*pi^2 - 1)
def u_exact(x, t):
return np.sin(np.pi * x) * np.exp(-t)
def f_source(x, t):
return np.sin(np.pi * x) * np.exp(-t) * (alpha * np.pi**2 - 1)
# Initial condition from MMS
u.data[0, :] = u_exact(x_vals, 0)
# We need to add source term at each time step
# For simplicity, use time-lagged source
f = Function(name='f', grid=grid)
# Update equation with source
update = Eq(u.forward, u + alpha * dt * u.dx2 + dt * f,
subdomain=grid.interior)
bc_left = Eq(u[t_dim+1, 0], 0) # u_mms(0,t) = 0
bc_right = Eq(u[t_dim+1, Nx], 0) # u_mms(1,t) = 0
op = Operator([update, bc_left, bc_right])
# Time stepping with source update
for n in range(Nt):
t_current = n * dt
f.data[:] = f_source(x_vals, t_current)
op(time=1, dt=dt)
# Compare to exact solution
t_final = Nt * dt
u_exact_final = u_exact(x_vals, t_final)
error = np.max(np.abs(u.data[0, :] - u_exact_final))
return error, dx
# Convergence test with MMS
print("MMS Convergence Test for Diffusion Equation:")
grid_sizes = [20, 40, 80, 160]
errors = []
dx_vals = []
for Nx in grid_sizes:
error, dx = solve_diffusion_mms(Nx)
errors.append(error)
dx_vals.append(dx)
print(f"Nx = {Nx:4d}, error = {error:.6e}")
# Compute rates
for i in range(len(errors) - 1):
rate = np.log(errors[i] / errors[i+1]) / np.log(2)
print(f"Rate {grid_sizes[i]}->{grid_sizes[i+1]}: {rate:.2f}")1.5.5 Quick Verification Checks
Before running full convergence tests, use these quick checks:
1. Conservation properties
For problems that should conserve mass or energy:
# Check mass conservation for diffusion with Neumann BCs
mass_initial = np.sum(u.data[1, :]) * dx
mass_final = np.sum(u.data[0, :]) * dx
print(f"Mass change: {abs(mass_final - mass_initial):.2e}")2. Symmetry
For symmetric initial conditions and domains:
# Check symmetry is preserved
u_left = u.data[0, :Nx//2]
u_right = u.data[0, Nx//2+1:][::-1] # Reversed
symmetry_error = np.max(np.abs(u_left - u_right))
print(f"Symmetry error: {symmetry_error:.2e}")3. Steady state
For problems with known steady states:
# Run to steady state and check
u_steady_numerical = u.data[0, :]
u_steady_exact = ... # Known analytical steady state
error = np.max(np.abs(u_steady_numerical - u_steady_exact))1.5.6 Debugging Tips
When convergence tests fail:
Check boundary conditions: Are they correctly implemented? Plot the solution near boundaries.
Check stability: Is the CFL/Fourier number within limits? Try smaller time steps.
Check initial conditions: Are they set correctly? Verify
u.data[0, :]andu.data[1, :].Inspect generated code: Use
print(op.ccode)to see what Devito actually computes.Test components separately: Verify spatial derivatives work on known functions before testing full PDE.
1.5.7 Summary
Verification is essential for trustworthy numerical results:
| Technique | When to Use | What It Checks |
|---|---|---|
| Convergence testing | Always | Correct order of accuracy |
| MMS | No analytical solution | Correct PDE implementation |
| Conservation | Physics requires it | No spurious sources/sinks |
| Symmetry | Symmetric problems | Consistent treatment |
A well-verified code gives confidence that results represent the physics, not numerical artifacts.