Physics Simulation Testing: Validation Strategies for FEA and CFD Solvers
Physics simulation software is tested differently from business software. There is rarely a "correct answer" in a database to compare against. Instead, verification and validation (V&V) relies on mathematical relationships: convergence rates, conservation laws, symmetry properties, and known analytical solutions. This guide covers the full stack of simulation testing strategies, with concrete examples in Python using FEniCS-style PDE solvers.
The V&V Framework
The simulation community distinguishes two categories of testing:
Verification — does the code correctly implement the mathematical model? Unit tests, convergence tests, and the method of manufactured solutions all verify the implementation.
Validation — does the mathematical model correctly represent the physical reality? This requires comparison against experimental data or high-fidelity reference simulations.
This guide focuses on verification, which is automatable. Validation requires experimental data and domain expertise.
Method of Manufactured Solutions (MMS)
MMS is the most rigorous technique for verifying PDE solvers. The idea:
- Choose a smooth, known solution
u_exact - Compute the forcing function
fthat makesu_exactsatisfy your PDE - Solve the PDE with that forcing function
- Compare the numerical solution against
u_exact
This works for any PDE, any boundary condition, any discretization — without needing an analytical solution to a physically meaningful problem.
MMS Example: Poisson Equation
The Poisson equation: -∇²u = f in Ω, with Dirichlet boundary conditions.
import numpy as np
from scipy.sparse import diags
from scipy.sparse.linalg import spsolve
def manufactured_poisson_1d(n: int) -> tuple[np.ndarray, np.ndarray, float]:
"""
Solve -u'' = f on [0, 1] with u(0) = u(1) = 0.
Manufactured solution: u_exact(x) = sin(pi * x)
Forcing function: f(x) = pi^2 * sin(pi * x)
Returns: (numerical solution, exact solution, L2 error)
"""
dx = 1.0 / (n + 1)
x = np.linspace(dx, 1.0 - dx, n) # interior points
# Exact solution and forcing
u_exact = np.sin(np.pi * x)
f = np.pi**2 * np.sin(np.pi * x)
# Assemble FD stiffness matrix: tridiagonal
diagonals = [
-np.ones(n - 1), # subdiag
2.0 * np.ones(n) / dx**2, # main diag
-np.ones(n - 1), # superdiag
]
A = diags(diagonals, [-1, 0, 1], format='csr') / dx**2
# Solve
u_numerical = spsolve(A, f)
# L2 error
l2_error = np.sqrt(dx * np.sum((u_numerical - u_exact)**2))
return u_numerical, u_exact, l2_error
def test_mms_poisson_1d_convergence():
"""
Finite differences on Poisson should converge at O(h^2).
Test that the convergence rate is between 1.8 and 2.2.
"""
mesh_sizes = [20, 40, 80, 160, 320]
errors = []
for n in mesh_sizes:
_, _, l2_error = manufactured_poisson_1d(n)
errors.append(l2_error)
# Compute convergence rates between consecutive mesh refinements
rates = []
for i in range(1, len(errors)):
rate = np.log(errors[i-1] / errors[i]) / np.log(2.0)
rates.append(rate)
avg_rate = np.mean(rates[-3:]) # Use the last 3 refinements (asymptotic behavior)
assert 1.8 < avg_rate < 2.2, (
f"Expected O(h^2) convergence, got rate={avg_rate:.3f}. "
f"Errors: {[f'{e:.2e}' for e in errors]}"
)MMS for 2D Heat Equation
def manufactured_heat_2d(nx: int, ny: int, dt: float, t_final: float):
"""
Solve u_t = ∇²u + f on [0,1]^2 with Dirichlet BC.
Manufactured solution: u(x, y, t) = sin(pi*x) * sin(pi*y) * exp(-2*pi^2*t)
This satisfies u_t = ∇²u exactly (no forcing needed for this choice).
"""
dx = 1.0 / (nx + 1)
dy = 1.0 / (ny + 1)
x = np.linspace(dx, 1.0 - dx, nx)
y = np.linspace(dy, 1.0 - dy, ny)
X, Y = np.meshgrid(x, y)
def u_exact(t):
return np.sin(np.pi * X) * np.sin(np.pi * Y) * np.exp(-2 * np.pi**2 * t)
# Initialize
u = u_exact(0.0)
n_steps = int(t_final / dt)
# Forward Euler (first-order in time, second-order in space)
for _ in range(n_steps):
laplacian = (
(np.roll(u, -1, axis=1) - 2*u + np.roll(u, 1, axis=1)) / dx**2 +
(np.roll(u, -1, axis=0) - 2*u + np.roll(u, 1, axis=0)) / dy**2
)
# Zero boundary (roll introduces periodic artifacts — fix boundary)
laplacian[:, 0] = laplacian[:, -1] = 0
laplacian[0, :] = laplacian[-1, :] = 0
u = u + dt * laplacian
return u, u_exact(t_final)
def test_heat_equation_mms_spatial_convergence():
"""O(h^2) spatial convergence for heat equation (fixed small dt)."""
dt = 1e-5
t_final = 0.01
mesh_refinements = [10, 20, 40, 80]
errors = []
for n in mesh_refinements:
u_num, u_ex = manufactured_heat_2d(n, n, dt, t_final)
l2_err = np.sqrt(np.mean((u_num - u_ex)**2))
errors.append(l2_err)
rates = [
np.log(errors[i-1] / errors[i]) / np.log(2.0)
for i in range(1, len(errors))
]
avg_rate = np.mean(rates[-2:])
assert avg_rate > 1.7, f"Spatial convergence rate too low: {avg_rate:.3f}"Convergence Order Testing
Every numerical method has a theoretical convergence order. Testing it is non-negotiable:
| Method | Expected order | Convergence test |
|---|---|---|
| Finite differences (2nd order) | O(h²) in space | Halve h, error should quarter |
| Runge-Kutta 4 | O(Δt⁴) in time | Halve Δt, error should drop 16× |
| Finite elements (linear) | O(h²) in L2 | Richardson extrapolation |
| Spectral methods | Exponential | Error vs wavenumber plot |
The convergence rate is computed by fitting a line to log(error) vs log(h):
def compute_convergence_rate(mesh_sizes: list[int], errors: list[float]) -> float:
"""
Fit a line to log(h) vs log(error) to get the convergence rate.
Returns the slope (negative means error decreases with refinement).
"""
log_h = np.log([1.0 / n for n in mesh_sizes])
log_e = np.log(errors)
# Linear regression
coeffs = np.polyfit(log_h, log_e, 1)
return coeffs[0] # slope = convergence orderThe key detail: use the last few refinements for the rate estimate. Coarse meshes are often in the pre-asymptotic regime where the rate is not yet the theoretical value.
Conservation Law Validation
Physical simulations must conserve mass, energy, and momentum. These conservation tests are independent of knowing the exact solution — they verify that the solver does not introduce or destroy physical quantities.
def test_mass_conservation_incompressible_flow():
"""
Incompressible Navier-Stokes: ∇·u = 0 must hold at every time step.
Total mass in a closed domain must be constant.
"""
# Synthetic velocity field: irrotational, divergence-free (e.g., potential flow)
nx, ny = 50, 50
dx = dy = 1.0 / nx
x = np.linspace(0, 1, nx)
y = np.linspace(0, 1, ny)
X, Y = np.meshgrid(x, y)
# Velocity field: u = (cos(pi*x)*sin(pi*y), -sin(pi*x)*cos(pi*y))
# This is divergence-free analytically
u = np.cos(np.pi * X) * np.sin(np.pi * Y)
v = -np.sin(np.pi * X) * np.cos(np.pi * Y)
# Compute divergence using central differences
du_dx = (np.roll(u, -1, axis=1) - np.roll(u, 1, axis=1)) / (2 * dx)
dv_dy = (np.roll(v, -1, axis=0) - np.roll(v, 1, axis=0)) / (2 * dy)
divergence = du_dx + dv_dy
# Interior divergence should be near machine epsilon
interior_div = divergence[2:-2, 2:-2]
max_div = np.max(np.abs(interior_div))
assert max_div < 1e-10, (
f"Velocity field is not divergence-free: max |∇·u| = {max_div:.2e}"
)
def test_energy_conservation_wave_equation():
"""
Wave equation u_tt = c^2 u_xx: total energy E = KE + PE must be conserved.
E(t) / E(0) should stay within 1% for a non-dissipative scheme.
"""
n = 200
dx = 1.0 / n
c = 1.0
dt = 0.4 * dx / c # CFL condition
x = np.linspace(0, 1, n + 1)
# Gaussian initial condition
u0 = np.exp(-100 * (x - 0.5)**2)
u_prev = u0.copy()
u_curr = u0.copy() # Zero initial velocity
def total_energy(u_prev, u_curr):
# Kinetic energy: (u_t)^2 ≈ ((u_curr - u_prev) / dt)^2
u_t = (u_curr - u_prev) / dt
KE = 0.5 * dx * np.sum(u_t**2)
# Potential energy: c^2 * (u_x)^2
u_x = (u_curr[1:] - u_curr[:-1]) / dx
PE = 0.5 * c**2 * dx * np.sum(u_x**2)
return KE + PE
E0 = total_energy(u_prev, u_curr)
n_steps = 100
for _ in range(n_steps):
# Leapfrog scheme
u_next = 2 * u_curr - u_prev + (c * dt / dx)**2 * (
np.roll(u_curr, -1) - 2 * u_curr + np.roll(u_curr, 1)
)
u_next[0] = u_next[-1] = 0 # Dirichlet BC
u_prev = u_curr
u_curr = u_next
E_final = total_energy(u_prev, u_curr)
energy_ratio = E_final / E0
assert 0.99 < energy_ratio < 1.01, (
f"Energy not conserved: E_final/E_0 = {energy_ratio:.6f}"
)Benchmark Problem Suites
The NASA Langley Research Center maintains a collection of turbulence model validation cases (turbmodels.larc.nasa.gov). For structural analysis, the NAFEMS benchmark suite provides reference solutions for FEA verification. Using these in your test suite:
import json
from pathlib import Path
class NAFEMSBenchmark:
"""
NAFEMS LE1: Elliptic membrane under uniform pressure.
Reference solution: sigma_yy at point D = 92.7 MPa.
"""
REFERENCE_SIGMA_YY = 92.7e6 # Pa
TOLERANCE = 0.02 # 2% tolerance on benchmark
def run_solver(self, mesh_size: float) -> dict:
"""Run the FEA solver and return stress at reference point D."""
# This would call your actual FEA solver
# Returning mock data for illustration
return {"sigma_yy_at_D": 92.7e6 * (1.0 + 0.01 * np.random.randn())}
def test_nafems_le1_elliptic_membrane():
"""NAFEMS LE1 benchmark: linear elastic elliptic membrane."""
benchmark = NAFEMSBenchmark()
result = benchmark.run_solver(mesh_size=0.1)
sigma_computed = result["sigma_yy_at_D"]
ref = NAFEMSBenchmark.REFERENCE_SIGMA_YY
rel_error = abs(sigma_computed - ref) / ref
assert rel_error < NAFEMSBenchmark.TOLERANCE, (
f"NAFEMS LE1 failed: computed={sigma_computed/1e6:.2f} MPa, "
f"reference={ref/1e6:.2f} MPa, error={rel_error:.2%}"
)Store benchmark results in a JSON file and compare against them in CI:
def test_regression_solver_output():
"""Regression test: compare against stored reference output."""
ref_path = Path("tests/reference_data/poisson_n100.npy")
_, u_exact, _ = manufactured_poisson_1d(n=100)
u_num, _, _ = manufactured_poisson_1d(n=100)
if not ref_path.exists():
# First run: save the reference
np.save(ref_path, u_num)
pytest.skip("Reference data created — run again to compare")
reference = np.load(ref_path)
np.testing.assert_allclose(
u_num, reference,
rtol=1e-12,
err_msg="Solver output changed from reference. Intentional change? Update reference."
)FEniCS Test Harness
FEniCS (now FEniCSx/dolfinx) provides a Python interface to finite element solvers. A minimal test harness:
# Requires: pip install fenics-dolfinx
import pytest
try:
import dolfinx
import ufl
from mpi4py import MPI
HAS_FENICS = True
except ImportError:
HAS_FENICS = False
@pytest.mark.skipif(not HAS_FENICS, reason="FEniCSx not installed")
def test_fenics_poisson_mms():
"""MMS verification for FEniCSx Poisson solver."""
from dolfinx import mesh, fem, default_scalar_type
from dolfinx.fem.petsc import LinearProblem
import ufl
comm = MPI.COMM_WORLD
domain = mesh.create_unit_square(comm, 32, 32)
V = fem.functionspace(domain, ("Lagrange", 1))
# Manufactured solution: u_exact = sin(pi*x)*sin(pi*y)
x = ufl.SpatialCoordinate(domain)
u_exact_expr = ufl.sin(ufl.pi * x[0]) * ufl.sin(ufl.pi * x[1])
f_expr = 2 * ufl.pi**2 * u_exact_expr
# Boundary condition: u = u_exact on boundary
u_exact_fn = fem.Function(V)
u_exact_fn.interpolate(
lambda x: np.sin(np.pi * x[0]) * np.sin(np.pi * x[1])
)
facets = mesh.locate_entities_boundary(
domain, domain.topology.dim - 1,
lambda x: np.ones(x.shape[1], dtype=bool)
)
dofs = fem.locate_dofs_topological(V, domain.topology.dim - 1, facets)
bc = fem.dirichletbc(u_exact_fn, dofs)
# Variational form
u, v = ufl.TrialFunction(V), ufl.TestFunction(V)
f = fem.Function(V)
f.interpolate(lambda x: 2 * np.pi**2 * np.sin(np.pi * x[0]) * np.sin(np.pi * x[1]))
a = ufl.dot(ufl.grad(u), ufl.grad(v)) * ufl.dx
L = f * v * ufl.dx
problem = LinearProblem(a, L, bcs=[bc])
uh = problem.solve()
# Compute L2 error
error_form = fem.form((uh - u_exact_expr)**2 * ufl.dx)
l2_error = np.sqrt(comm.allreduce(fem.assemble_scalar(error_form), op=MPI.SUM))
assert l2_error < 1e-3, f"FEniCSx Poisson L2 error too large: {l2_error:.2e}"Summary
The testing stack for physics simulation verification:
- MMS — the canonical technique for verifying any PDE solver; works for any equation, any geometry
- Convergence order tests — confirm the method achieves its theoretical rate under mesh refinement
- Conservation law tests — verify mass, energy, and momentum are not artificially created or destroyed
- Benchmark suites — NAFEMS for FEA, NASA turbulence cases for CFD provide community-validated reference solutions
- Regression tests — pin solver output to reference arrays; force explicit decisions when results change
The most important insight: in simulation testing, "correct" means mathematically consistent. A solver that conserves energy, achieves the right convergence order, and matches benchmark solutions is very likely correct — even without an exact analytical solution to compare against.