Translated from the author's Korean original. Read the original (한국어)
Mass Is Conserved Exactly, Yet the Density Is Negative — The Positivity-Preserving Flux Limiter
Conservation keeps the sum exact and still lets one cell leave the admissible set. That cell is what turns the sound speed into NaN.
A conservative scheme does not lose a single grain of mass. Whatever leaves through a face is exactly what the neighbor receives, so the sum over the whole domain is constant to machine precision. And that same scheme produces a density of −0.003. The total is right, but an individual cell is negative.
Raise |dF| scale and the two bars beside cell 2 drop from green into yellow. A larger face jump cuts the high-order weight.
Hit "full budget per face" and both values jump back near 1, while the cell 2 bar punches through the green floor. Each face looks legal. The cell spends its budget twice.
It Did Not Diverge — It Walked Out of Its Domain#
When a log ends in NaN, the first suspect is the time step. Halve the CFL. It still dies. Refine the grid. It dies faster.
At that point it is probably not a stability problem. Look at the line that computes the speed of sound.
is the ratio of specific heats, the pressure, the density. The moment either or turns negative, becomes NaN. The next step's time interval is NaN, and every flux after that is NaN as well. The real accident was already over one step before the line where NaN got printed.
The NaN usually starts here. For a solution of the Euler equations to stay alive, the conserved variables must lie inside the admissible set.
Conservation is the property that the total is preserved, not the property that each cell stays inside . Those are entirely different demands.
High-Order Reconstruction Makes No Promise About the Sign#
The classic way out of is near vacuum. Strong expansion waves, high-altitude reentry flow, the inside of a cavitation bubble, and the region behind a blast wave. In places like these and fall to the level.
Lay a MUSCL or WENO reconstruction on top of that, and even with a positive cell average the face value can come out negative. The slope gets multiplied by half a cell width and added on. Reconstruction preserves only the cell average; it promises nothing about the sign.
Pressure is worse. is a nonlinear function of the conserved variables. Even with and each positive, once the kinetic energy exceeds , goes negative. In the double rarefaction run later, the density sits at a perfectly healthy 0.37 while the pressure drops to −0.163 first.
The initial data make that order less surprising. With , , and , the specific internal energy is . The kinetic energy is . Internal energy is only an eighth of the kinetic energy. A reconstruction that overshoots momentum by a little pushes past , and goes negative first. Density never does that division, so it can still read 0.37.
Why Lax–Friedrichs Holds Up at CFL 0.5#
So what can you trust? The first-order Lax–Friedrichs (LF) flux.
is the largest characteristic speed. Expand the update with this flux and it rearranges as follows.
Here . The three coefficients sum to exactly 1, and if none of them is negative. That is a convex combination.
is a convex set, so if all three terms entering the combination are inside , the result is inside too. The open question is whether lands back in , and Perthame–Shu showed that this condition holds for . That is the origin of the sentence everyone quotes: "LF is positivity-preserving at CFL 0.5."
So there are two fluxes in hand. The accurate high-order flux that does not protect the sign, and the inaccurate that does. A TVD limiter that clips the slope at a jump kills oscillations. This one keeps a cell from leaving . Different job.
Finding θ on the Segment Joining the Two Fluxes#
The idea of Hu, Adams and Shu (2013) is simple. Blend the two, and set the blend ratio separately for every face.
is the pure high-order scheme, retreats to LF. Whatever you use it is still flux form, so conservation is maintained automatically. Meaning the limiter neither creates nor destroys mass. This is the decisive difference from dumping artificial viscosity in locally.
Write and the update splits like this.
The first term is guaranteed to be inside as long as the CFL condition holds. The rest is a segment reaching from that safe point toward the high-order solution. All we have to do is stop the segment right before it leaves .
The floor is not 0 but a very small positive .
and are the density and pressure of the initial state. Aim at exactly 0 and one rounding drops you back below it.
Spend the Budget in Halves — One Face Touches Two Cells#
Density is a conserved variable itself, so is a linear function of . That makes the condition solvable algebraically.
Call the slack that cell holds its budget: . At face , if that face drains the density of the left cell . Otherwise it drains the right one.
There is a trap here. An interior cell is drained from both faces at once. Compute as if each face could spend the whole budget of the cell it drains, and two faces that are each legal on their own together spend twice the budget. So each face is allowed only half.
Once exceeds half the budget, drops below 1. An interior cell is drained from both faces at once. Skip the factor of one half and two faces that each look legal can spend . The dial at the top of the note already showed that on the cell 2 bar.
Pressure Comes After Density, and by Bisection#
Once density is settled, look at pressure. The order matters. Computing requires dividing by , so has to be secured first.
is a nonlinear function of , so it does not fall out of a linear equation. Instead it has one good property. is a concave function on . Since is linear in , is concave too. The superlevel set of a concave function is an interval, and (the LF state) already satisfies the condition, so that interval contains 0.
In other words there is only one root. Bisection works safely. Twenty to forty iterations narrow it down to the double-precision limit.
One thing to watch. Shrinking because of cell changes the computation for the neighbors . A single sweep leaves a violation behind in rare cases. You have to repeat the sweep until no violating cell is left. Since converges to the LF state, the iteration is guaranteed to terminate.
comes straight out of the initial data. With and , . Forty bisections cut the interval in half that many times. The outer sweep stops at 20. Cutting on one cell can push a neighbor's pressure back under , so both face values get scaled until the violation is gone. At the update is exactly and the loop stops.
The Double Rarefaction, Brought Back to Life in Python#
The toy problem is a double rarefaction. About the middle of the tube, the two sides move apart from each other at . A near-vacuum hole opens in the center, and that hole kills the high-order scheme.
import numpy as np
GAMMA = 1.4
def to_primitive(U):
rho = U[0]
u = U[1] / rho
p = (GAMMA - 1.0) * (U[2] - 0.5 * rho * u * u)
return rho, u, p
def euler_flux(U):
rho, u, p = to_primitive(U)
return np.array([rho * u, rho * u * u + p, (U[2] + p) * u])
def lf_face_flux(UL, UR, alpha):
return 0.5 * (euler_flux(UL) + euler_flux(UR) - alpha * (UR - UL))
def density_theta(rho_lf, dF_rho, lam, eps_rho):
"""Set theta per face. Each face may spend at most half the budget of the cell it drains."""
n = rho_lf.size
budget = np.maximum(rho_lf - eps_rho, 0.0)
theta = np.ones(dF_rho.size)
for f in range(1, n): # interior faces only
d = dF_rho[f]
if d > 0.0: # drains the left cell
cap = 0.5 * budget[f - 1] / (lam * d)
elif d < 0.0: # drains the right cell
cap = 0.5 * budget[f] / (lam * (-d))
else:
cap = 1.0
theta[f] = min(1.0, cap)
return theta
def pressure_at(U_lf, dFl, dFr, tl, tr, lam):
U = U_lf - lam * (tr * dFr - tl * dFl)
return to_primitive(U)[2]
def pressure_theta(U_lf, dF, theta, lam, eps_p):
"""p(theta) is concave, so the safe set is a single interval. Bisection locates its edge.
Shrinking one cell's theta affects its neighbors, so repeat until no violation remains."""
n = U_lf.shape[1]
for _ in range(20):
dirty = False
for i in range(n):
tl, tr = theta[i], theta[i + 1]
if pressure_at(U_lf[:, i], dF[:, i], dF[:, i + 1], tl, tr, lam) >= eps_p:
continue
dirty = True
lo, hi = 0.0, 1.0
for _ in range(40):
mid = 0.5 * (lo + hi)
ok = pressure_at(U_lf[:, i], dF[:, i], dF[:, i + 1],
tl * mid, tr * mid, lam) >= eps_p
lo, hi = (mid, hi) if ok else (lo, mid)
theta[i] *= lo
theta[i + 1] *= lo
if not dirty:
return theta
return thetaThe time-marching loop builds two fluxes each step, finds , blends them, and updates.
def minmod(a, b):
return np.where(a * b <= 0.0, 0.0, np.where(np.abs(a) < np.abs(b), a, b))
def face_states(U):
"""MUSCL-minmod reconstruction -> left/right state at each face"""
d = minmod(U[:, 1:-1] - U[:, :-2], U[:, 2:] - U[:, 1:-1])
s = np.zeros_like(U)
s[:, 1:-1] = d
return U[:, :-1] + 0.5 * s[:, :-1], U[:, 1:] - 0.5 * s[:, 1:]
def march_double_rarefaction(n=200, cfl=0.45, u0=4.0, t_end=0.15, limiter=True):
dx = 1.0 / n
x = (np.arange(n) + 0.5) * dx
rho = np.ones(n)
u = np.where(x < 0.5, -u0, u0)
p = np.full(n, 0.4)
U = np.vstack([rho, rho * u, p / (GAMMA - 1.0) + 0.5 * rho * u * u])
eps = min(1e-13, rho.min(), p.min())
t, step, clipped, total = 0.0, 0, 0, 0
while t < t_end:
r, v, pr = to_primitive(U)
if r.min() <= 0.0 or pr.min() <= 0.0: # left the admissible set
return dict(crashed=True, step=step,
rho_min=r.min(), p_min=pr.min())
a = np.sqrt(GAMMA * pr / r)
alpha = float(np.max(np.abs(v) + a))
dt = min(cfl * dx / alpha, t_end - t)
lam = dt / dx
Ug = np.hstack([U[:, :1], U, U[:, -1:]]) # zero-gradient ghost
UL, UR = face_states(Ug)
Flow = np.zeros((3, n + 1))
Fhigh = np.zeros((3, n + 1))
for f in range(n + 1):
Flow[:, f] = lf_face_flux(Ug[:, f], Ug[:, f + 1], alpha)
Fhigh[:, f] = lf_face_flux(UL[:, f], UR[:, f], alpha)
dF = Fhigh - Flow
U_lf = U - lam * (Flow[:, 1:] - Flow[:, :-1]) # safe reference point
if limiter:
th = density_theta(U_lf[0], dF[0], lam, eps)
th = pressure_theta(U_lf, dF, th, lam, eps)
clipped += int(np.sum(th[1:n] < 1.0 - 1e-12))
total += n - 1
else:
th = np.ones(n + 1)
F = Flow + th * dF # blended flux
U = U - lam * (F[:, 1:] - F[:, :-1])
t += dt
step += 1
r, _, pr = to_primitive(U)
return dict(crashed=False, step=step, rho_min=float(r.min()),
p_min=float(pr.min()), clipped=100.0 * clipped / max(total, 1))
for lim in (False, True):
o = march_double_rarefaction(limiter=lim)
tag = "limiter ON " if lim else "limiter OFF"
if o["crashed"]:
print(f"{tag}: crashed at step {o['step']} "
f"rho_min={o['rho_min']:.4f} p_min={o['p_min']:+.4f}")
else:
print(f"{tag}: reached t=0.15 in {o['step']} steps "
f"rho_min={o['rho_min']:.3e} p_min={o['p_min']:.3e} "
f"theta<1 on {o['clipped']:.3f}% of faces")At , 200 cells and CFL 0.45, the output is this.
limiter OFF: crashed at step 2 rho_min=0.3698 p_min=-0.1630
limiter ON : reached t=0.15 in 300 steps rho_min=9.560e-04 p_min=5.140e-04 theta<1 on 0.027% of facesWith limiter OFF, raising pull-apart u0 shows whether the density curve stays alive while pressure alone crosses the red line.
Above u0 of 3.5 the run matches the output block: step 2, density minimum 0.3698, pressure minimum −0.1630. With limiter ON only a few bars leave green, and the march fills 300 steps out to .
Without the limiter it is over at the second step. The density at that moment, 0.3698, does not look dangerous. Pressure went negative first. Code that only watches density misses this accident.
What Percentage of Faces Ever Get θ Below 1#
The measured value is 0.027%. Interior faces per step are . Over 300 steps that is faces, and 0.027% of them is about 16. Raising to 8 still leaves the fraction at 0.093%.
Divide the two surviving minima, . Same order as the initial . The hole is not empty. With , stays below 1 and is not NaN. The time-step killer does not start there.
The limiter is effectively asleep. It wakes only at the few cells and the few steps where a vacuum opens, and pulls the flux at those faces slightly toward LF. On the remaining 99.97% of faces the original high-order scheme runs exactly as it was.
CFL 0.45 reads as . The three update weights are and on each neighbor. All positive, and they sum to 1. On the boundary the self weight is 0.5 and each neighbor is 0.25. The code's 0.45 sits inside that bound, so the LF reference stays in . Cutting means something only there.
Compare that with raising artificial viscosity globally to paper over the problem. That approach shaves accuracy in the smooth regions along with everything else. This one is bit-for-bit identical to the original scheme wherever is maintained. That is why the limiter does not drop the order in a grid-convergence test measuring the convergence rate.
Three Things to Check Before Putting It in Your Code#
Start with the CFL bound. The whole method stands on the premise that " is safe." LF positivity preservation holds only for . A code that normally runs at CFL 0.8 has already lost the reference point, and dropping to 0 will not bring it back. If the limiter seems dead, start there.
SSP-RK makes each stage a convex combination of forward Euler steps. Every stage has to sit inside or the final state will not. A check only at the end arrives after the inside of the went negative. Leave at 0 and the last rounding of the bisection hands back . The floor is a small positive number taken from the initial minimum.
Dropping θ to 0 is already too late#
If the reference is already outside , the blend only connects a dead state to the high-order update. A chosen after has passed 1/2 is not a safety net. The decision, before the limiter goes in, is whether the CFL stays inside that bound the way 0.45 does.
Fewer than 0.1% of faces ever get touched, and whatever is, the update is still a flux difference. The mass sum stays put. Once the sound speed is undefined, a wave decomposition on an arbitrary face normal has nothing left to split. The sign has to be fixed before that decomposition has a place to stand.
References X.Y. Hu, N.A. Adams, C.-W. Shu, "Positivity-preserving method for high-order conservative schemes solving compressible Euler equations", Journal of Computational Physics 242 (2013) 169–180.
Related
Share if you found it helpful.