Skip to content
cfd-lab:~/en/posts/2026-07-13-roe-approxima…● online

Translated from the author's Korean original. Read the original (한국어)

NOTE #101DAY MON CFD기법DATE 2026.07.13UPDATED 2026.10.19READ 7 min read#Riemann#Roe-Scheme#Approximate-Riemann-Solver#Entropy-Fix#HLLC

What You Give Up the Exact Riemann Solution For — the Roe Solver and the Entropy Fix

On Shu–Osher at δ=0.1, CFL 0.4 and t=1.8, density stays near 0.81 to 4.08. At δ=0 the Burgers expansion keeps a kink at x=0.

What You Give Up the Exact Riemann Solution For — the Roe Solver and the Entropy Fix#

The 1-D Euler Riemann problem has an exact solution. One nonlinear equation for the star pressure, one Newton loop, done. Production compressible codes still skip that solve at every face. The answer is in hand. It gets thrown out. Cost. Past an ideal gas, the exact solution may not exist at all.

Move the left and right density and velocity sliders. The sqrt(rho) weights and the three wave speeds u^−c^\hat{u}-\hat{c}, u^\hat{u}, u^+c^\hat{u}+\hat{c} recompute on the face.

Left state
Right state
√ρ weights: L 0.667 / R 0.333  |  û = 0.000   ĉ = 1.143
λ = [ -1.143, 0.000, 1.143 ] ← subsonic: fan straddles the interface

When u^−c^<0<u^+c^\hat{u}-\hat{c}<0<\hat{u}+\hat{c}, the wave fan straddles the face. Subsonic. Push both velocities hard to one side and the three waves share a sign. The state goes supersonic.

Why the Exact Solution Vanished From Practice#

An exact Riemann solver (Godunov) finds the root of a nonlinear equation at every face. With millions of cells, that root-finding loop runs millions of times per step. Worse, the exact solution is bound to the ideal-gas equation of state. Move to a real gas or a two-phase mixture and the exact solution no longer exists.

Approximate Riemann solvers sidestep this. They linearize the nonlinear Riemann problem locally. One set of algebraic formulas gives the flux with no iteration. The trade is a small loss of exactness.

Collapsing the Jacobian Into a Single Average#

Roe's idea is simple. Approximate the flux difference across a face by a single constant matrix A^\hat{A} acting on the two states qLq_L, qRq_R.

A^ (qR−qL)=f(qR)−f(qL)\hat{A}\,(q_R - q_L) = f(q_R) - f(q_L)

Here qq is the conserved-variable vector and ff is the physical flux. This condition is decisive. If the two states are connected by a single wave (a shock or a contact), then A^\hat{A} propagates that wave exactly, even at finite amplitude. So the solver is approximate, yet exact in front of a pure shock or contact.

The catch is how to choose A^\hat{A}. A careless average breaks the condition above.

Where the sqrt(rho) Average Comes From#

Roe's answer is an average weighted by the square root of density.

u^=ρL uL+ρR uRρL+ρR,H^=ρL HL+ρR HRρL+ρR\hat{u} = \frac{\sqrt{\rho_L}\,u_L + \sqrt{\rho_R}\,u_R}{\sqrt{\rho_L}+\sqrt{\rho_R}}, \qquad \hat{H} = \frac{\sqrt{\rho_L}\,H_L + \sqrt{\rho_R}\,H_R}{\sqrt{\rho_L}+\sqrt{\rho_R}}

Here uu is velocity, HH is total specific enthalpy, and the carets mark face-averaged values. The Roe sound speed follows as c^=(γ−1)(H^−u^2/2)\hat{c}=\sqrt{(\gamma-1)(\hat{H}-\hat{u}^2/2)}.

Why the square root of density? Write the state qq and flux ff in the components of the parameter vector W=ρ (H,u,1)TW=\sqrt{\rho}\,(H,u,1)^T and both become perfectly quadratic. Being quadratic, the derivatives of qq and ff with respect to WW are linear, and the path integral that builds A^\hat{A} evaluates exactly. What falls out is precisely this sqrt(rho)-weighted average.

The weight denominator is ρL+ρR\sqrt{\rho_L}+\sqrt{\rho_R}. If one density is four times the other, its square root is twice as large, so that side's velocity and enthalpy pull twice as hard on the average. An arithmetic mean does not satisfy A^(qR−qL)=f(qR)−f(qL)\hat{A}(q_R-q_L)=f(q_R)-f(q_L) on a finite jump. Without sqrt(rho) the wave speeds are already wrong in front of a pure shock. The sound speed is c^=(γ−1)(H^−u^2/2)\hat{c}=\sqrt{(\gamma-1)(\hat{H}-\hat{u}^2/2)}. At γ=1.4\gamma=1.4, γ−1=0.4\gamma-1=0.4. What remains after subtracting the kinetic energy u^2/2\hat{u}^2/2 from the total enthalpy is the square of the sound speed. The place where the three wave speeds change sign is the same three-wave split as the Euler eigensystem on an arbitrary normal.

Split Into Waves, Then Add Them Back#

Assembling the flux takes three steps. First decompose the jump Δq=qR−qL\Delta q = q_R - q_L into a sum of the three eigenvectors K^k\hat{K}_k; the size of each component is the wave strength αk\alpha_k. Then advect each wave upwind according to the sign of its eigenvalue λ^k\hat{\lambda}_k.

Fi+1/2=12(fL+fR)−12∑k∣λ^k∣ αk K^kF_{i+1/2} = \tfrac{1}{2}\bigl(f_L + f_R\bigr) - \tfrac{1}{2}\sum_{k}|\hat{\lambda}_k|\,\alpha_k\,\hat{K}_k

The first term is a centered average; the second is upwind dissipation weighted by the eigenvalue magnitudes. That structure ports straight to code. The test is the Shu–Osher problem: a Mach 3 shock races into a sinusoidal density field and leaves high-frequency structure behind it. It is exactly where Roe's low numerical dissipation earns its keep.

import numpy as np
 
gamma = 1.4
 
def phys_flux(U):                       # conserved U=(rho, rho*u, E) -> physical flux
    rho = U[0]; u = U[1] / rho; E = U[2]
    p = (gamma - 1) * (E - 0.5 * rho * u * u)
    return np.array([rho * u, rho * u * u + p, u * (E + p)])
 
def roe_flux(UL, UR, delta):            # Roe approximate Riemann flux (+ Harten entropy fix)
    rhoL, rhoR = UL[0], UR[0]
    uL, uR = UL[1] / rhoL, UR[1] / rhoR
    pL = (gamma - 1) * (UL[2] - 0.5 * rhoL * uL * uL)
    pR = (gamma - 1) * (UR[2] - 0.5 * rhoR * uR * uR)
    HL = (UL[2] + pL) / rhoL; HR = (UR[2] + pR) / rhoR
    sL, sR = np.sqrt(rhoL), np.sqrt(rhoR)      # sqrt(rho) weights
    u = (sL * uL + sR * uR) / (sL + sR)        # Roe-averaged velocity
    H = (sL * HL + sR * HR) / (sL + sR)        # Roe-averaged enthalpy
    c = np.sqrt((gamma - 1) * (H - 0.5 * u * u))   # Roe-averaged sound speed
    rr = sL * sR
    drho, dp, du = rhoR - rhoL, pR - pL, uR - uL
    alpha = np.array([(dp - rr * c * du) / (2 * c * c),   # wave strengths alpha_k
                      drho - dp / (c * c),
                      (dp + rr * c * du) / (2 * c * c)])
    lam = np.array([u - c, u, u + c])          # eigenvalues (wave speeds)
    K = np.array([[1, u - c, H - u * c],       # right eigenvectors
                  [1, u,     0.5 * u * u],
                  [1, u + c, H + u * c]])
    al = np.abs(lam)
    small = al < delta                         # entropy fix: floor on |lambda|
    al[small] = (lam[small] ** 2 + delta ** 2) / (2 * delta)
    diss = (al * alpha) @ K
    return 0.5 * (phys_flux(UL) + phys_flux(UR)) - 0.5 * diss
 
def run_shu_osher(N=400, tmax=1.8, cfl=0.4, delta=0.1):
    x = np.linspace(0, 10, N); dx = x[1] - x[0]
    rho = np.where(x < 1, 3.857143, 1 + 0.2 * np.sin(5 * x))   # shock + sinusoidal density
    u   = np.where(x < 1, 2.629369, 0.0)
    p   = np.where(x < 1, 10.33333, 1.0)
    U = np.array([rho, rho * u, p / (gamma - 1) + 0.5 * rho * u * u])
    t = 0.0
    while t < tmax:
        r = U[0]; v = U[1] / r; pp = (gamma - 1) * (U[2] - 0.5 * r * v * v)
        dt = cfl * dx / np.max(np.abs(v) + np.sqrt(gamma * pp / r))
        dt = min(dt, tmax - t)
        F = np.zeros((3, N + 1))
        for i in range(1, N):
            F[:, i] = roe_flux(U[:, i - 1], U[:, i], delta)
        F[:, 0] = phys_flux(U[:, 0]); F[:, N] = phys_flux(U[:, N - 1])
        U[:, 1:N - 1] -= dt / dx * (F[:, 2:N] - F[:, 1:N - 1])
        t += dt
    return x, U[0]
 
x, rho = run_shu_osher()
print(f"t=1.8  min rho={rho.min():.3f}  max rho={rho.max():.3f}")   # -> min~0.81  max~4.08

Roughly forty lines make a complete compressible solver. No Newton iteration, no exact Riemann solve. delta is the entropy-fix parameter covered next.

The Shu–Osher initial field is ρ=3.857143\rho=3.857143, u=2.629369u=2.629369, p=10.33333p=10.33333 for x<1x<1, and ρ=1+0.2sin⁡(5x)\rho=1+0.2\sin(5x), u=0u=0, p=1p=1 on the right. N=400N=400 on [0,10][0,10], CFL 0.4, t=1.8t=1.8, δ=0.1\delta=0.1. The printout is a density minimum near 0.81 and a maximum near 4.08. The left density 3.857 rises a little toward 4.08, and a trough of the sine falls toward 0.8. That range, with no Newton loop, is what this run holds.

Roe Breaks the Entropy Condition#

The Roe solver treats every wave as a jump. Even a rarefaction is approximated as a train of small shocks. Usually that is harmless. But when a rarefaction contains a sonic point, things change. At that point an eigenvalue λ^k\hat{\lambda}_k changes sign, passing through 00.

When ∣λ^k∣→0|\hat{\lambda}_k|\to 0, the upwind dissipation for that wave disappears. The scheme locks in a stationary expansion shock instead of opening a smooth fan. It satisfies the Rankine–Hugoniot condition but violates the entropy condition — an unphysical solution.

The cure is Harten's entropy fix. It puts a floor under the eigenvalue magnitude near 00.

∣λ^k∣  →  λ^k2+δ22 δ,∣λ^k∣<δ|\hat{\lambda}_k| \;\to\; \frac{\hat{\lambda}_k^{2} + \delta^{2}}{2\,\delta}, \qquad |\hat{\lambda}_k| < \delta

At ∣λ^k∣=0|\hat{\lambda}_k|=0 the fix formula equals δ/2\delta/2, because (0+δ2)/(2δ)=δ/2(0+\delta^2)/(2\delta)=\delta/2. If δ\delta is 0 the floor is 0 and the upwind dissipation vanishes. The code's delta=0.1 lays 0.05 on that floor. Five to ten percent of (∣u^∣+c^)(|\hat{u}|+\hat{c}) is the width before a contact smears too far.

Raise the δ\delta slider from 0 on the Burgers expansion ut+(u2/2)x=0u_t+(u^2/2)_x=0 with uL=−0.5u_L=-0.5 and uR=1.0u_R=1.0. Watch whether the blue kink at x=0x=0 moves. The sonic point u=0u=0 sits in the middle of the interval.

δ = 0 freezes a stationary expansion shock at the sonic point (the blue kink at x = 0). Raise δ and the numerical curve relaxes onto the amber exact rarefaction.

At δ=0\delta=0 a blue kink — the stationary expansion shock — sits at x=0x=0. Raise δ\delta and the numerical curve slides down onto the amber exact rarefaction fan. Five to ten percent of the local (∣u^∣+c^)(|\hat{u}|+\hat{c}) is usually safe. Too large, and contacts smear out.

Cheaper: HLL and HLLC#

If Roe feels heavy, keep fewer waves. HLL (Harten–Lax–van Leer) keeps only the two acoustic waves. It drops the middle contact wave.

FHLL=SRfL−SLfR+SLSR (qR−qL)SR−SLF^{\text{HLL}} = \frac{S_R f_L - S_L f_R + S_L S_R\,(q_R - q_L)}{S_R - S_L}

Here SLS_L, SRS_R are estimates of the outermost left and right wave speeds. HLL is robust and preserves positive density well. But it cannot keep contacts sharp — there is no middle wave.

The C in HLLC stands for the central Contact wave. It restores the wave HLL discarded: three waves, four constant-state regions. The three solvers sit on a spectrum of wave count.

SolverWavesContactRobustnessCost
HLL2smearedhighlowest
HLLC3sharphighmedium
Roefull (5 in 3-D)sharpneeds fixhigh

The production default is usually HLLC. It keeps contacts alive while making density and pressure positivity easy to enforce. Roe has excellent resolution, but the entropy fix is mandatory, and you must watch for the carbuncle phenomenon on strong grid-aligned shocks.

Leave δ at zero and the expansion shock stays on the face#

The sqrt(rho) weight comes from the parameterization W=ρ (H,u,1)TW=\sqrt{\rho}\,(H,u,1)^T that makes qq and ff quadratic. Without that path integral the jump condition on A^\hat{A} breaks at finite amplitude. Leave ∣λ^k∣→0|\hat{\lambda}_k|\to 0 untouched at a sonic point and Rankine–Hugoniot still holds while entropy does not. Roe without Harten's floor locks the expansion shock as a stationary solution.

How many waves to keep is the next decision. HLL keeps two acoustic waves and smears the contact. HLLC restores the contact and four constant states. Roe goes to five waves in 3-D, but the entropy fix is mandatory, and a strong shock aligned with the grid still needs a carbuncle check. At low Mach the artificial-diffusion scales split on their own. The Hope–Collins note that splits low-Mach Roe into three scalings is that split.

Share if you found it helpful.