Skip to content
cfd-lab:~/ko/posts/2026-08-05-positivity-pr…online
NOTE #124DAY WED CFD기법DATE 2026.08.05READ 7 min read#Positivity-Preserving#Riemann#Compressible#TVD#Flux-Limiter

질량은 정확히 보존됐는데 밀도가 음수다 — 양수 보존 flux 리미터

고차 flux와 Lax–Friedrichs flux 사이를 θ로 잇는 양수 보존 기법

보존형 스킴은 질량을 한 톨도 잃지 않는다. 면을 통해 나간 양이 곧 이웃이 받은 양이니, 도메인 전체 합은 기계 정밀도까지 일정하다. 그런데 그 스킴이 밀도 −0.003을 만든다. 총합은 맞는데 개별 셀이 음수다. 오늘은 왜 보존성이 양수성을 보장하지 않는지, 그리고 고차 정확도를 거의 그대로 유지한 채 부호만 지켜내는 flux 리미터를 어떻게 짜는지 다룬다. 실제로 돌려서 몇 %의 면이 실제로 손을 타는지까지 세어 본다.

발산이 아니라 정의역 밖으로 나간 것이다#

로그가 NaN으로 끝났을 때 가장 먼저 의심하는 것은 시간 스텝이다. CFL을 절반으로 줄인다. 그래도 죽는다. 격자를 세분화한다. 더 빨리 죽는다.

이때는 안정성 문제가 아닐 가능성이 높다. 음속을 구하는 줄을 보자.

c=γpρc = \sqrt{\frac{\gamma p}{\rho}}

γ\gamma는 비열비, pp는 압력, ρ\rho는 밀도다. ppρ\rho 중 하나가 음수가 되는 순간 cc는 NaN이 된다. 다음 스텝의 시간 간격도 NaN, 그다음 flux도 전부 NaN. 실제 사고는 NaN이 찍힌 줄보다 한 스텝 앞에서 이미 끝나 있다.

핵심은 이것이다. Euler 방정식의 해가 살아 있으려면 보존변수 U=(ρ, ρu, E)\mathbf{U} = (\rho,\ \rho u,\ E)허용 집합(admissible set) 안에 있어야 한다.

G={U:ρ>0,  p=(γ1)(E(ρu)22ρ)>0}G = \left\{ \mathbf{U} : \rho > 0,\ \ p = (\gamma-1)\left(E - \frac{(\rho u)^2}{2\rho}\right) > 0 \right\}

보존성은 "총합이 유지된다"는 성질이지 "각 셀이 GG 안에 남는다"는 성질이 아니다. 둘은 완전히 다른 요구다.

고차 재구성은 부호를 지켜 주지 않는다#

GG를 벗어나는 대표적인 상황은 진공 근처다. 강한 팽창파, 고고도 재진입 유동, 캐비테이션 기포 내부, 그리고 폭발파 뒤쪽. 이런 곳에서 ρ\rhopp10410^{-4} 수준까지 떨어진다.

여기에 MUSCL이나 WENO 재구성을 얹으면 셀 평균 ρˉi\bar\rho_i가 양수여도 면 값 ρi+1/2L\rho_{i+1/2}^{L}은 음수가 될 수 있다. 기울기가 셀 폭의 절반을 곱해 더해지기 때문이다. 재구성은 셀 평균만 보존할 뿐, 부호에는 아무 약속을 하지 않는다.

압력은 더 나쁘다. pp는 보존변수의 비선형 함수다. ρ\rhoEE가 각각 양수여도 운동에너지 (ρu)2/2ρ(\rho u)^2/2\rhoEE를 넘으면 pp는 음수가 된다. 실제로 뒤에서 돌려 볼 이중 희박파에서는 밀도가 0.37로 멀쩡한 상태에서 압력이 먼저 −0.163으로 떨어진다.

Lax–Friedrichs가 CFL 0.5에서 버티는 이유#

그럼 무엇을 믿을 수 있는가. 1차 Lax–Friedrichs(LF) flux다.

F^i+1/2LF=12[F(Ui)+F(Ui+1)α(Ui+1Ui)]\hat{\mathbf{F}}^{LF}_{i+1/2} = \frac{1}{2}\left[\mathbf{F}(\mathbf{U}_i) + \mathbf{F}(\mathbf{U}_{i+1}) - \alpha\,(\mathbf{U}_{i+1} - \mathbf{U}_i)\right]

α=maxj(uj+cj)\alpha = \max_j(|u_j| + c_j)는 최대 특성속도다. 이 flux로 업데이트를 전개하면 다음처럼 정리된다.

Uin+1=(1λα)Ui+λα2(Ui+1Fi+1α)+λα2(Ui1+Fi1α)\mathbf{U}_i^{n+1} = (1 - \lambda\alpha)\,\mathbf{U}_i + \frac{\lambda\alpha}{2}\left(\mathbf{U}_{i+1} - \frac{\mathbf{F}_{i+1}}{\alpha}\right) + \frac{\lambda\alpha}{2}\left(\mathbf{U}_{i-1} + \frac{\mathbf{F}_{i-1}}{\alpha}\right)

λ=Δt/Δx\lambda = \Delta t/\Delta x다. 세 계수의 합은 정확히 1이고, λα1\lambda\alpha \le 1이면 모두 음수가 아니다. 즉 볼록 결합이다.

GG는 볼록 집합이므로, 결합에 들어가는 세 항이 모두 GG 안이면 결과도 GG 안이다. 문제는 U±F/α\mathbf{U} \pm \mathbf{F}/\alpha가 다시 GG에 들어오느냐인데, Perthame–Shu가 이 조건이 λα1/2\lambda\alpha \le 1/2에서 성립함을 보였다. 흔히 쓰는 "LF는 CFL 0.5에서 양수 보존"이라는 문장의 출처가 여기다.

정리하면 우리 손에는 두 개의 flux가 있다. 정확하지만 부호를 안 지키는 고차 flux F^H\hat{\mathbf{F}}^{H}, 그리고 부정확하지만 부호를 지키는 F^LF\hat{\mathbf{F}}^{LF}.

두 flux를 잇는 선분 위에서 θ 찾기#

Hu, Adams, Shu(2013)의 아이디어는 단순하다. 둘을 섞되, 섞는 비율을 면마다 따로 정한다.

F^i+1/2=F^i+1/2LF+θi+1/2(F^i+1/2HF^i+1/2LF),θi+1/2[0,1]\hat{\mathbf{F}}_{i+1/2} = \hat{\mathbf{F}}^{LF}_{i+1/2} + \theta_{i+1/2}\left(\hat{\mathbf{F}}^{H}_{i+1/2} - \hat{\mathbf{F}}^{LF}_{i+1/2}\right), \qquad \theta_{i+1/2} \in [0, 1]

θ=1\theta = 1이면 순수 고차 스킴, θ=0\theta = 0이면 LF로 후퇴한다. 어떤 θ\theta를 써도 flux 형식이므로 보존성은 자동으로 유지된다. 리미터가 질량을 만들거나 없애지 않는다는 뜻이다. 이 점이 인공 점성을 국소적으로 들이붓는 방식과 결정적으로 다르다.

ΔFi+1/2F^HF^LF\Delta\mathbf{F}_{i+1/2} \equiv \hat{\mathbf{F}}^{H} - \hat{\mathbf{F}}^{LF}로 쓰면 업데이트는 이렇게 갈라진다.

Uin+1=UiLF안전한 기준점λ(θi+1/2ΔFi+1/2θi1/2ΔFi1/2)\mathbf{U}_i^{n+1} = \underbrace{\mathbf{U}_i^{LF}}_{\text{안전한 기준점}} - \lambda\left(\theta_{i+1/2}\Delta\mathbf{F}_{i+1/2} - \theta_{i-1/2}\Delta\mathbf{F}_{i-1/2}\right)

첫 항은 CFL 조건만 지키면 GG 안에 있음이 보장된다. 나머지는 그 안전한 점에서 고차 해 쪽으로 뻗는 선분이다. 우리가 할 일은 선분이 GG를 벗어나기 직전에서 멈추는 것뿐이다.

바닥값은 0이 아니라 아주 작은 양수 ε\varepsilon으로 잡는다.

ε=min(1013, minjρj0, minjpj0)\varepsilon = \min\left(10^{-13},\ \min_j \rho_j^0,\ \min_j p_j^0\right)

ρj0\rho_j^0, pj0p_j^0는 초기 상태의 밀도·압력이다. 정확히 0을 목표로 하면 반올림 한 번에 다시 음수가 된다.

예산을 반씩 나눠 쓴다 — 면 하나가 셀 둘을 건드린다#

밀도는 보존변수 자체라서 ρin+1\rho_i^{n+1}θ\theta에 대해 1차 함수다. 그래서 조건을 대수적으로 풀 수 있다.

ii가 가진 여유분을 예산으로 부르자. bi=ρiLFε0b_i = \rho_i^{LF} - \varepsilon \ge 0이다. 면 i+1/2i+1/2에서 ΔFρ>0\Delta F^\rho > 0이면 이 면은 왼쪽 셀 ii의 밀도를 깎는다. 반대면 오른쪽 셀을 깎는다.

여기서 함정이 하나 있다. 내부 셀은 좌우 두 면에서 동시에 깎인다. 각 면이 자기가 깎는 셀의 예산 전부를 쓸 수 있다고 계산하면, 면 둘이 각각은 합법이면서 합쳐서 예산의 두 배를 써 버린다. 그래서 각 면에 절반씩만 허용한다.

θi+1/2=min(1, bvictim/2λΔFi+1/2ρ)\theta_{i+1/2} = \min\left(1,\ \frac{b_{\text{victim}} / 2}{\lambda\,|\Delta F^\rho_{i+1/2}|}\right)

아래 도식에서 직접 확인해 보자.

raise |dF| until the two faces around cell 2 both turn yellow — that is the limiter working. now press “full budget per face”: both thetas jump back up, each one perfectly legal on its own, and the bottom bar for cell 2 drops straight through the green floor.

|dF| scale을 올리면 셀 2 양옆의 두 θ\theta가 노란색으로 내려간다. 여기서 "full budget per face"를 누르면 두 θ\theta가 다시 1 근처로 튀어 오르는데, 아래쪽 셀 2 막대는 초록 바닥선을 뚫고 내려간다. 면 단위로만 보면 아무 잘못이 없는데 셀 단위로는 위반이다.

압력은 밀도 다음에, 그리고 이분법으로#

밀도가 정리되면 압력을 본다. 순서가 중요하다. pp를 계산하려면 ρ\rho로 나눠야 하므로, ρ>0\rho > 0이 먼저 확보되어야 한다.

ppU\mathbf{U}의 비선형 함수라 1차식으로 풀리지 않는다. 대신 좋은 성질이 하나 있다. p(U)p(\mathbf{U})GG 위에서 오목 함수(concave)다. U(θ)\mathbf{U}(\theta)θ\theta에 대해 선형이므로 p(θ)p(\theta)도 오목하다. 오목 함수의 상위 준위 집합 {θ:p(θ)ε}\{\theta : p(\theta) \ge \varepsilon\}은 구간이고, θ=0\theta = 0(LF 상태)에서 이미 조건을 만족하므로 그 구간은 0을 포함한다.

즉 근이 하나뿐이다. 이분법이 안전하게 작동한다. 20~40회면 배정밀도 한계까지 좁혀진다.

한 가지 주의할 점. 셀 ii 때문에 θi±1/2\theta_{i\pm1/2}를 줄이면 이웃 셀 i±1i\pm1의 계산이 달라진다. 한 번 훑고 끝내면 드문 경우에 위반이 남는다. 위반 셀이 사라질 때까지 스윕을 반복해야 한다. θ0\theta \to 0이면 LF 상태로 수렴하므로 반복은 반드시 끝난다.

Python으로 되살린 이중 희박파#

토이 문제는 이중 희박파다. 관 가운데를 기준으로 좌우가 u0\mp u_0로 서로 멀어진다. 가운데에 진공에 가까운 구멍이 열리고, 그 구멍이 고차 스킴을 죽인다.

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

시간 전진 루프는 매 스텝 두 개의 flux를 만들고, θ\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                                     # 섞인 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")

u0=4u_0 = 4, 격자 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

리미터 없이는 두 번째 스텝에서 끝난다. 주목할 것은 그 순간의 밀도가 0.3698이라는 점이다. 밀도는 전혀 위험해 보이지 않는데 압력이 먼저 음수로 떨어졌다. 밀도만 감시하는 코드는 이 사고를 놓친다.

아래 시뮬레이션에서 직접 조작해 보자.

push u0 past about 3.5 with the limiter off — the pressure curve dips through the red line within two steps while the density is still near 0.37. turn the limiter on and watch the bottom panel: only a handful of green bars ever drop, and the run finishes.

limiter OFF를 누르고 pull-apart u0를 3.5 이상으로 올리면, 위쪽 밀도 곡선이 아직 멀쩡한 상태에서 가운데 압력 곡선이 빨간 선을 뚫는다. limiter ON으로 되돌리면 맨 아래 θ\theta 막대 중 극소수만 초록에서 내려오고 계산은 끝까지 간다.

θ가 1보다 작아진 면은 전체의 몇 %인가#

측정값은 0.027%다. 200셀 × 300스텝 = 약 6만 개의 면 중 열여섯 개 정도만 손을 탔다. u0u_0를 8까지 올려도 0.093%에 그친다.

이 숫자가 이 기법의 핵심이다. 리미터는 사실상 잠들어 있다. 진공이 열리는 몇 개의 셀, 몇 개의 스텝에서만 깨어나서 그 면의 flux를 LF 쪽으로 살짝 당긴다. 나머지 99.97%의 면에서는 원래 고차 스킴이 그대로 돌아간다.

인공 점성을 전역으로 올려서 문제를 덮는 방식과 비교하면 차이가 분명하다. 그쪽은 매끄러운 영역의 정확도까지 함께 깎는다. 이쪽은 θ=1\theta = 1이 유지되는 한 원래 스킴과 비트 단위로 동일하다. 수렴 차수를 재는 격자 수렴 시험에서도 리미터가 차수를 떨어뜨리지 않는 이유가 이것이다.

코드에 넣기 전 확인할 세 가지#

첫째, CFL 상한을 실제로 지키고 있는가. 이 기법 전체가 "ULF\mathbf{U}^{LF}는 안전하다"는 전제 위에 서 있다. LF의 양수 보존은 λα1/2\lambda\alpha \le 1/2에서만 성립한다. 평소 CFL 0.8로 돌리던 코드라면 기준점부터 이미 무너져 있어서 θ\theta를 0으로 내려도 살아나지 않는다. 리미터가 안 듣는다면 이것부터 의심하자.

둘째, Runge–Kutta 각 스테이지마다 적용하고 있는가. SSP-RK는 각 스테이지가 전진 Euler의 볼록 결합이다. 스테이지 하나하나가 GG 안에 있어야 최종 결과도 GG 안에 들어온다. 마지막에 한 번만 검사하면 중간 스테이지에서 이미 \sqrt{} 안이 음수가 된 뒤다.

셋째, ε\varepsilon을 0으로 두지 않았는가. 정확히 0을 목표로 이분법을 돌리면 마지막 반올림에서 1017-10^{-17}이 나온다. 초기 최소값 기준의 작은 양수를 바닥으로 깔아야 한다.

다시 진공을 만났을 때 꺼낼 것#

보존성과 양수성은 다른 성질이다. 전자는 flux 형식이 공짜로 주고, 후자는 따로 강제해야 한다.

flux를 섞는 방식은 이 강제를 보존성을 깨지 않고 해낸다. θ\theta가 무엇이든 여전히 flux 차분이기 때문이다.

그리고 실제로 개입하는 면은 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.

도움이 됐다면 공유해주세요.