Independent · ongoing · flagship

Building a solver is one thing; knowing whether it is right is the other.

CFDLab is a Python companion to the Lomax–Pulliam–Zingg curriculum: analytical probes, three structurally different quasi-1-D Euler solvers, and baselines pinned by MD5 under one local gate, so the gate fails when a number moves. Two of the three are measured on this page. ARC1D is the central-difference solver with JST artificial dissipation; upwind1d is the characteristic-decomposition solver, Roe with MUSCL reconstruction.

Register
Verification, not validation
Evidence here
Committed final_state.npz, two solvers
Case
Sod shock tube, t = 6.1 ms
Every wave in this problem is a straight line out of one point, because the solution depends only on x/t. The dashed cut is the single instant everything after it examines.

Two solvers, the same shock tube, the same grid spacing, the same final time. One adds a scalar dissipation term to a central scheme; the other decomposes the flux into characteristic waves and upwinds each one separately. Both are stable. Both conserve mass, momentum and energy. Both produce a plausible picture.

They are not equally right, and the place where they differ most is not the shock; it is the contact discontinuity, the one wave that carries no pressure jump and therefore gives a pressure-driven dissipation term nothing to lock onto. What follows is one measurement of that difference, taken from the arrays the repository commits.

The case

p left
100 000 Pa
p right
10 000 Pa
ρ left
1.000 kg/m³
ρ right
0.125 kg/m³
diaphragm
x = 5.0 m
final time
6.1 ms
γ
1.4
Δx
0.025 m

If none of those words are familiar yet, chapter 03 builds them one at a time, in the order they depend on each other, against this same experiment. Nothing below assumes you read it.

01

The experiment, from a burst diaphragm to a counted width

The measurement

A tube closed at both ends, a diaphragm at the midpoint, and a pressure ratio of ten across it. Six milliseconds after the diaphragm bursts, three waves are running and one of them is the subject.

Narration

01 The problem

Ten metres of tube, closed at both ends, divided by a diaphragm at the five-metre mark. At t = 0 the diaphragm is removed, and a pressure ratio of ten has to resolve itself into whatever waves the Euler equations permit.

02 What the physics does

Six milliseconds later the closed-form solution has three distinct waves in it, and only one of them is a shock. Running left, an expansion fan: a smooth, continuous acceleration spread over about two metres by 6.1 ms. Running right at 554 m/s, a shock: a genuine discontinuity, compressive, self-steepening. Between them, moving at the flow speed itself, the contact discontinuity.

The contact is the odd one out, and this is the whole point: switch the plotted quantity to pressure and the contact vanishes. Density jumps across it by a factor of 1.6; pressure does not change at all.

03 What each scheme does with it

ARC1D adds a scalar artificial-dissipation term to a central difference and runs 150 implicit steps at Courant 1; the shock comes out six cells wide between the 10 and 90 % points of its jump. upwind1d splits the flux difference into the three characteristic fields and upwinds each separately, with MUSCL-minmod reconstruction and adaptive SSP-RK2 at CFL 0.8. On this scale the two are almost indistinguishable, which is exactly the problem: a plausible picture is not evidence.

04 Where they differ

One metre of tube around the contact, and the difference that was invisible at full scale is now the only thing in the frame. Inside a fixed window, the repository counts the cells whose density sits outside ±2 % of both bounding plateaus. Twenty-six cells for JST, sixteen for Roe + MUSCL, counted from the rarefaction tail to the exact shock position, so the shock's upstream half is in the count; the gap is concentrated at the contact. A factor of 1.6, and neither scheme holds the contact within one cell.

02

Why the contact is the hard one

The argument

The chart above shows a difference. The reason is a property of the equations, not of either implementation, which is why it was predictable before the counting was done.

The mechanism, and why it was predictable

Open where you need it

A shock is compressive. The characteristic speeds behind it exceed those ahead of it, so the profile steepens on its own and a scheme's dissipation is continually fought by the physics. Whatever smearing the numerics add, the nonlinearity pushes back. The result is that a shock stays narrow: between the 10 and 90 % points of its jump, Roe holds it in three cells here and JST in six.

A contact is linearly degenerate. Its characteristic field is the one whose speed is the flow velocity itself, and the wave speed does not change across the wave. Nothing steepens. There is no mechanism at all to oppose numerical diffusion, so whatever a scheme smears on step one it keeps, and adds to, every step after.

λ₁ = u − a genuinely non-linear → rarefaction or shock λ₂ = u linearly degenerate → contact λ₃ = u + a genuinely non-linear → rarefaction or shock ∇λ₂ · r₂ = 0 ← this is the whole reason the contact never re-steepens

Scalar artificial dissipation makes the problem worse in a specific way. JST's switch is built from a normalised second difference of pressure, because pressure is what detects a shock. Across a contact the pressure is constant, so the sensor reads zero, the higher-order term is not switched off, and the background fourth-difference dissipation smooths the density jump unopposed.

Roe's flux-difference splitting decomposes the jump between neighbouring cells into the three characteristic fields and applies upwind dissipation to each one on its own. The contact is one of those fields. It gets dissipation proportional to |λ₂| = |u| rather than to the largest wave speed in the cell, which is roughly a factor of (|u| + a)/|u| less. That is the mechanism the measurement above is detecting.

03

Every term above, in the order it depends on the one before it

Reference · open what you need

The argument is finished. This is the apparatus underneath it: the dependency chain the experiment rests on, each term only meaningful once the one above it is. Open a term where it is unfamiliar.

Each entry answers the same five questions: what it is, why it exists at all, why it matters here, what physically or numerically changes because of it, and where to see it in this result. Three symbols are used throughout and are worth having before you start: u is the flow speed, a is the local speed of sound, and r2 is the direction in which the flow state changes across the middle wave. A superscript T is a transpose.

    04

    What was measured

    One metric · one provenance

    The counts

    JST
    26cells in transition, tail to shock
    Roe + MUSCL
    16cells in transition, tail to shock
    Ratio
    1.6×

    Counted by the CFDLab repository's own metric: spread() in analysis/numerical_dissipation/jst_vs_upwind_shocktube.py at commit d53325ff, against each solver's committed final state, at the one tolerance that script pins.

    spread(), from the repository

    def spread(x, rho, rho3, rho2, x_tail, x_shock, tol=0.02): mask = (x > x_tail) & (x < x_shock) in3 = np.abs(rho - rho3) < tol * rho3 in2 = np.abs(rho - rho2) < tol * rho2 return int(np.sum(mask & ~(in3 | in2)))

    A cell counts when it is inside the window and outside the tolerance band of both plateaus. The tolerance is a default argument in the source, not a knob.

    05

    What the repository does with a number like this

    Why the project exists

    A count of twenty-six against sixteen is worth very little on its own. What makes it evidence is that the inputs are pinned: the two final states above are committed to the repository, the metric is a function in the repository, and every committed artifact, those two final states among them, has its MD5 recorded in a per-project harness that the repository's own gate re-checks against the committed bytes.

    If a refactor of the limiter changes the Roe final state, the gate fails as soon as the regenerated file is committed, and the count of sixteen moves with it. Not because the new count is wrong (it may well be an improvement) but because a file that was measured and recorded has changed without anyone saying so. Someone then has to look at it and either fix the regression or re-pin the hash deliberately, in the same commit as the reason. That is the whole discipline.

    Over a hundred such hashes are pinned across the project as of July 2026, covering the analytical probes, the three solver families and the shared numerical library. One is shown here in full.

    • The data is committed. Both final_state.npz arrays are in the repository, and the count above is recomputed from them rather than read off an exported figure.
    • The metric is code. spread() lives beside the solvers and is imported by the check, so the definition cannot drift away from the number it produced.
    • The artifacts are hashed. Each committed figure, table and final state has its own MD5 pinned in a per-project harness, so a change anywhere in the set is a failure at one named file.
    • The gate is local. One script in the repository runs every harness and refuses an uncommitted tree; there is no remote CI behind it. The cost of the discipline is paid continuously and is therefore small.

    The rest of what the repository renders

    These are the other results the same codebase produces, each rendered by the script that owns it, and each one is a different chapter of the two books the project works through.

    Two stacked panels titled dispersion and dissipation: the real and imaginary parts of the modified wavenumber against the exact wavenumber, with several finite-difference schemes departing from the exact diagonal at different rates.
    2024 Modified wavenumber. The single most useful diagram in the first book: it says exactly which wavelengths a finite-difference scheme gets right and how it fails on the rest, and every stability and accuracy argument later in the book reads off it.
    A Sweby diagram: limiter value against the slope ratio r, with the second-order TVD region shaded and the minmod, van Leer, van Albada, superbee, monotonised central and MC limiter curves drawn through it.
    2024 The Sweby diagram, with six limiters drawn inside the second-order TVD region. Choosing a limiter is choosing a curve in this box, and the two boundaries are minmod at the bottom and superbee at the top.
    Two panels: the first three basis functions of a diffusion problem, and their decay against time showing the highest mode collapsing first.
    2024 Modal decay in the diffusion equation. The high modes go first and they go fast, which is the whole reason an implicit method pays for itself here and the whole reason multigrid works.
    Density and Mach number against position for a shock tube at 6.1 milliseconds, ARC1D against the exact solution, with the rarefaction, contact and shock all resolved.
    2024 The implicit central scheme on the shock tube, against the analytic answer. This is the run chapter 01 takes apart.
    The same shock tube at the same instant computed by an upwind Roe scheme with MUSCL reconstruction, density and Mach against position, overlaid on the exact solution.
    2024 The same instant, computed by Roe with MUSCL reconstruction. Against the ARC1D panel: both get the shape, and the difference is in how many cells the contact takes.
    Two zoomed panels of density against position, one at the contact and one at the shock, for three values of the fourth-difference dissipation coefficient; the three curves lie on top of each other at this scale, and small oscillations sit on the plateau ahead of each wave.
    2024 The contact and shock, zoomed, at three settings of the fourth-difference coefficient κ⁽⁴⁾, in the secondary BDF2 time-stepping case, not the implicit-Euler run the 26-cell count comes from. The three curves are indistinguishable at this scale; the ringing ahead of the contact and ahead of the shock is a BDF2 feature, more visible there than under implicit Euler.
    Two panels: a near-field Mach contour around an aerofoil at Mach 0.75 showing a supersonic pocket terminated by a shock on the upper surface, and the surface pressure coefficient with the shock as a sharp recovery.
    2024 Two dimensions, transonic, and the oldest method on this page: Beam-Warming approximate factorization with the Pulliam-Chaussee diagonal form on a C-grid, the implicit approach Beam and Warming published in 1976 and 1978, with the diagonal form Pulliam and Chaussee gave it in 1981 to cut the work and storage per step. This rebuilds the numerical method, not the historical code. The supersonic pocket on the upper surface ends in a shock, and the pressure coefficient beside it shows the same event as a discontinuity. Marked in the repository as qualitative, not a validation against reference data.
    Two panels: a density field showing an oblique shock striking a wall and reflecting, in flat colour regions, and beside it a comparison of the numerical shock angles against the analytic ones.
    2024 Oblique shock reflection, with the computed angles against the exact oblique-shock relations. The flat colour regions are the point: between shocks the state is genuinely constant, so any wobble in them is the scheme talking.
    Pressure and Mach number against position through a transonic inlet, comparing a stagnation-based closure against a Riemann-invariant closure at the boundary, with the exact solution overlaid.
    2024 Two ways to close the inlet boundary, on the same transonic duct. The interior scheme is identical in both; everything that differs is the boundary condition, which is the part of a solver that is easiest to get quietly wrong.

    What this does not establish

    06

    Where the timestep stops working, and what buys a bigger one

    Drive it · linear model problem

    Chapter 01 runs 150 implicit steps at Courant 1 and never says what happens at Courant 2. This is that question with a control on it: one pulse, a periodic box, and a stability limit that moves when you pay for it.

    Cn 4.0 β 0.0

    Set the two numbers, then run. The pulse has to go four times round the box and still be a pulse.

    What this is. Linear scalar convection on a periodic box, second-order central in space, marched by the five-stage scheme FLOMG ships, with implicit residual smoothing. The curve is exact for that. It is not the Euler solver: no nonlinearity, no shock, no characteristic boundaries, no fourth-difference dissipation. The squares are what the nonlinear solver measured, and they are a bracket rather than a number.

    Narration

    01 What the control marches

    Linear scalar convection on a periodic box, 200 cells, second-order central in space, and the Jameson five-stage Runge-Kutta the solver ships, with stage coefficients (1/4, 1/6, 3/8, 1/2, 1). The task is to carry the pulse four times round the box and get it back still a pulse, in as few steps as possible. A bigger Courant number is fewer steps, so the whole game is how big a one you can get away with.

    02 Where the cliff is, exactly

    Implicit residual smoothing replaces each stage's residual R by the R̄ that solves R̄ − βδ²R̄ = R. Per mode that divides the amplification argument by 1 + 4βsin²(θ/2), so the largest argument the march ever sees is Cn/√(1+4β), and the five-stage polynomial is stable along the imaginary axis out to |z| = 4. The cliff is therefore closed form:

    Cnmax(β) = 4√(1 + 4β)

    A square root, not a straight line. Three times the timestep costs β = 2, and the slider runs out before it. Push it and the diminishing return is the thing you feel first.

    03 Why the squares are not the curve

    The curve is a model problem and the squares are the solver. They are different objects. The squares are the measured integer bracket on the real nonlinear march at J = 105 over a 400-step window, filled on the last stable value and hollow on the first unstable one, and the same bracket came back on the subsonic and the transonic exercise. Where the curve falls inside a bracket the two are consistent, which is all that can be said. At β = 0 the curve sits exactly on the first unstable integer, and past the last measured β there are no squares at all.

    04 Why a doomed run looks healthy

    Over the cliff the worst mode multiplies by max|σ| every step, starting from whatever seeds it. Here that is a stated broadband perturbation of 1e−9, standing in for the round-off a real march starts with. Two per cent over the cliff is a growth of about 1.35 a step, which needs roughly seventy steps to climb from that seed to anything a plot can show, and until then the pulse is clean. The nonlinear solver's own runs say the same thing: at β = 0.2 an unstable run goes non-finite in eight steps, and at β = 1.0 the same verdict takes 101 steps of the 400-step window on the subsonic exercise and 91 on the transonic one. Nothing in the upper panel knows. The dashed trace, the hatching and the map do.

    07

    How many sweeps it takes to get the error off the grid

    Drive it · linear model problem

    A second control, on the other half of the cost of a steady solution. Not how big a step you may take, but how many sweeps it takes to get the error off the grid, and why the answer is not to sweep harder.

    ω 1.00

    The error on the grid is one low mode and one high mode. Get the residual to one part in a million before the allowance runs out.

    What this is. The linear 1-D Poisson model problem, −u″ = f on the unit interval with Dirichlet ends and second-order central differences. It is the output of the relaxation chapter and the input of the multigrid chapter. It is not a statement about how the nonlinear Euler solver converges, and the work-unit numbers here are this problem's, not that solver's.

    Narration

    01 The grid is the error

    Take f = 0, so the exact answer is zero and whatever is left on the grid is the error. It starts as two modes: the lowest one the grid can hold, and a high one drawn fresh out of the band ξ ∈ [π/2, π] at every restart. Drawn rather than fixed, because a single fixed high mode has a weight that annihilates it exactly, and that weight is not the answer. The band is what the smoothing factor is defined over, so the band is what you have to beat.

    02 One eigenvalue per mode

    Weighted Jacobi has a single number per mode: μk(ω) = 1 − ω(1 − cosξ), with ξ = κΔx. The smoothing factor is the worst of those over the high band, and it is smallest at ω = 2/3, where it is 1/3. Plain Jacobi, ω = 1, is the worst setting on the slider: at ξ = π it has |μ| = 1 and does not touch the Nyquist mode at all. The control opens there. Press smooth and watch how little happens.

    03 What the coarse grid is for

    One smooth is one sweep of the fine grid and costs one work unit. One coarsen is a two-grid cycle: two sweeps, the residual, full-weighting restriction to the half grid, an exact solve there, linear prolongation back, the correction, two more sweeps. The repository prices that at about 4.25 work units, and that number is the whole comparison, because a method that is four times the work per step has to be four times better to be worth anything. The readout says what each mode would cost the smoother on its own. At ω = 2/3 the high mode is gone in at most thirteen sweeps whatever the restart drew, and no weight on this slider takes the lowest mode under nine thousand. That gap is the entire reason multigrid exists.

    04 What it does not say

    This is one linear model problem with a known spectrum, which is why every number on it can be computed rather than measured. FLOMG's multigrid tables are the measured article and they are a different reading, including two failures worth as much as the successes: a five-level hierarchy at J = 105 that is invalid because the grid nesting breaks, and a V-cycle on the transonic case that never reaches 1e−12 in 300 cycles.