本文译自作者的韩语原文。 阅读原文(한국어)
质量一粒不少,密度却是负的 — 正性保持通量限制器
被动过的面只占0.027%。没有这几张面,压力会在第2步变成负数。
守恒型格式不会丢掉一粒质量。从某个面流出去的量,正好是邻居收到的量,所以全域求和到机器精度都是常数。可就是这样的格式,算出了密度 −0.003。总量对得上,单个单元却是负的。
把 |dF| scale 调大,单元 2 两侧的 会变黄下落。面削掉的量越大,高阶比重被压得越低。
按下 "full budget per face",两个 又跳回 1 附近,只有单元 2 的柱子穿透绿色底线。按面看都合规。单元把左右预算叠在一起,密度就成了负的。
不是发散,是跑出了定义域
日志以 NaN 收尾时,第一个被怀疑的总是时间步长。把 CFL 减半。照样死。加密网格。死得更快。
这种时候多半不是稳定性问题。看一眼算声速的那一行。
是比热比, 是压力, 是密度。 或 中任何一个变成负数的瞬间, 就是 NaN。下一步的时间间隔也是 NaN,再下一步的通量全是 NaN。真正的事故,在打出 NaN 的那一行之前一步就已经结束了。
NaN 通常从这里开始。Euler 方程的解要活着,守恒变量 就必须待在容许集(admissible set)里。
守恒性是"总量保持不变"这条性质,不是"每个单元都留在 里"那条。两者是完全不同的要求。
高阶重构不负责守住符号
跑出 的典型场景是真空附近。强膨胀波、高空再入流动、空化气泡内部,还有爆炸波后方。这些地方 和 会掉到 量级。
在这上面叠一层 MUSCL 或 WENO 重构,即便单元平均 是正的,面值 也可能为负。因为斜率要乘上半个单元宽再加上去。重构只保住单元平均,对符号不作任何承诺。
压力更糟。 是守恒变量的非线性函数。哪怕 和 各自为正,只要动能 超过 , 就是负的。后面要跑的双稀疏波里,密度还稳稳停在 0.37 的时候,压力已经先掉到 −0.163。
把初始条件拆开,这个顺序就不奇怪了。、、、 时,比内能是 。动能是 。内能只有动能的八分之一。重构把动量估得稍大一点, 就会超过 ,于是 先变成负数。密度那一项不做这个除法,所以还能停在 0.37。
Lax–Friedrichs 能在 CFL 0.5 撑住的理由#
那么什么是可信的。一阶 Lax–Friedrichs(LF)通量。
是最大特征速度。用这个通量把更新式展开,可以整理成下面的形式。
。三个系数之和正好是 1,而且只要 就都非负。也就是说,这是一个凸组合。
是凸集,所以只要参与组合的三项都在 内,结果也在 内。问题在于 是否重新落回 ,而 Perthame–Shu 证明了这个条件在 时成立。常说的"LF 在 CFL 0.5 下正性保持",出处就在这里。
手上的通量就是两个。精确但不守符号的高阶通量 ,以及不精确但守符号的 。在间断处截掉斜率的 TVD 限制器压的是振荡。这里要挡的是单元走出 。两件事不一样。
在连接两个通量的线段上找 θ
Hu、Adams、Shu(2013)的想法很简单。把两者混合,而混合比例逐面单独决定。
是纯高阶格式, 则退回 LF。不管取什么 ,形式仍是通量差分,所以守恒性自动成立。也就是说限制器既不造出质量,也不吃掉质量。这一点和局部猛灌人工粘性的做法有决定性的区别。
记 ,更新式就分成这样两块。
第一项只要守住 CFL 条件,就保证在 内。其余部分是从那个安全点朝高阶解伸出去的一条线段。要做的只是让线段在跨出 之前停下来。
底线不取 0,而取一个很小的正数 。
、 是初始状态的密度和压力。若把目标定在恰好为 0,一次舍入就又是负数。
预算对半分 — 一个面动两个单元
密度本身就是守恒变量,因此 关于 是一次函数。条件可以代数地解出来。
把单元 手上的余量叫作预算。。在面 上若 ,这个面削的是左侧单元 的密度。反之则削右侧单元。
这里有一个陷阱。内部单元会被左右两个面同时削。如果按每个面都能用光被削单元的全部预算来算,那么两个面各自合法,合起来却花掉了两倍预算。所以每个面只许用一半。
分母里的 一旦超过预算的一半, 就降到 1 以下。内部单元同时被左右两个面削。不先留出这一半,两个面各自合法,合起来仍可能花掉 。这个差别,文首拨盘上单元 2 的柱子已经显示过了。
压力排在密度之后,用二分法
密度理顺之后再看压力。顺序很重要。算 需要除以 ,所以必须先确保 。
是 的非线性函数,解不出一次式。不过有一条好性质。 在 上是凹函数(concave)。 关于 线性,因此 也是凹的。凹函数的上水平集 是一个区间,而 (LF 状态)已经满足条件,所以这个区间包含 0。
也就是说根只有一个。二分法可以安全工作。20 到 40 次就能收缩到双精度极限。
有一点要注意。因为单元 而调小 ,会改变邻居单元 的计算结果。扫一遍就收工,少数情况下会留下违规。必须反复扫描直到违规单元消失。由于 时收敛到 LF 状态,迭代一定会终止。
也直接来自初值。、,所以 。二分法 40 次是把区间 对半切那么多次。外层扫描最多 20 次。一个单元的 变小,邻居的压力可能再次掉到 以下,所以违规消失之前两侧面值要一起削。 时更新就是 本身,循环会停。
用 Python 复活的双稀疏波#
玩具问题取双稀疏波。以管子中央为界,左右各以 相互远离。中间张开一个接近真空的洞,正是这个洞杀死了高阶格式。
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):
"""逐面确定 theta。每个面最多只用掉被它削减单元预算的一半。"""
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): # 只处理内部面
d = dF_rho[f]
if d > 0.0: # 削减左侧单元
cap = 0.5 * budget[f - 1] / (lam * d)
elif d < 0.0: # 削减右侧单元
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) 是凹的,安全区间只有一个。用二分法找边界。
调小一个单元的 theta 会影响邻居,因此反复迭代直到不再有违规。"""
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 theta时间推进循环每步构造两个通量,求出 ,混合后更新。
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 重构 -> 每个面的左/右状态"""
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: # 跑出容许集
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]) # 安全的基准点
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 # 混合后的通量
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")取 、200 个网格、CFL 0.45,输出如下。
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 faces在 limiter OFF 下把 pull-apart u0 调高,能看到密度曲线还活着的时候,压力是不是单独穿过红线。
u0 超过 3.5 时,结果和输出块一致:第 2 步,密度最小值 0.3698,压力最小值 −0.1630。切到 limiter ON,只有少数 柱离开绿色,并一路填满到 的 300 步。
不带限制器,第二步就结束了。那一刻的密度 0.3698 看上去并不危险。先变成负数的是压力。只监视密度的代码会漏掉这场事故。
θ 小于 1 的面占全部的百分之几#
实测值是 0.027%。内部面每步是 个。300 步就是 个。其中 0.027% 大约 16 个。把 提到 8,比例仍停在 0.093%。
把活下来的两个最小值相除,。和初始的 是同一个量级。真空洞并没有被抽空。代入 , 小于 1,但不是 NaN。杀死时间步长的事故不是从这里开始的。
限制器基本上处于睡眠状态。只在真空张开的那几个单元、那几步里醒来,把那些面的通量朝 LF 方向轻轻拉一下。其余 99.97% 的面上,原来的高阶格式照常运行。
CFL 0.45 读成 。更新的三个系数是 ,两侧邻居各为 。三个都是正的,和为 1。在 的边界上,自身权重是 0.5,邻居各 0.25。代码里的 0.45 在这条边界内侧,所以 LF 基准点 留在 内。只在这里,把 压小才有意义。
和把人工粘性全局调高来掩盖问题的做法相比,差别很清楚。那一边连光滑区域的精度也一起削掉。这一边只要 保持不变,就和原格式逐比特相同。网格收敛试验中限制器不会拉低收敛阶,原因也在这里。
接进代码前要确认的三件事
先看 CFL 上限。整套方法都架在" 是安全的"这个前提上。LF 的正性保持只在 时成立。平时按 CFL 0.8 跑的代码,基准点本身就已经塌了,把 压到 0 也救不回来。限制器不起作用时,从这里查。
SSP-RK 的每个子步都是前向 Euler 的凸组合。每一个子步都在 内,最终结果才会落在 内。只在最后检查一次,那时 里早在中间子步就已经是负的了。把 留在 0,二分法最后一次舍入会给出 。底线得按初始最小值铺一个小正数。
把 θ 降到 0 已经晚了#
基准点 若已经在 外,混合只是把一个死状态和高阶更新连起来。 越过 1/2 之后的 不是安全装置。限制器接进去之前要定的,是 CFL 能不能像 0.45 那样留在这条边界内侧。
实际被干预的面不到 0.1%,而且无论 取何值,更新仍是通量差分。质量总和不动。声速一旦没有定义,任意面法向的特征结构还没上场,计算就已经结束。符号不先固定,波动分解没有落脚的地方。
参考文献 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.
相关文章
如果对您有帮助,请分享。