Skip to content
cfd-lab:~/en/posts/2026-09-15-dynamic-smago…● online

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

NOTE #155DAY TUE 유체역학DATE 2026.09.15UPDATED 2026.10.19READ 8 min read#Dynamic-Smagorinsky#Germano-Identity#Turbulence#Fluid-Dynamics#Numerical-Analysis

49.9% of the Dynamic Coefficients Came Out Negative — the Germano Identity and the Averaging Operator

The averaging operator in a dynamic model is not a post-processing option; it is what makes the model work.

It blew up in the transition region, and the log showed a negative number#

The first run with the dynamic Smagorinsky model diverged at step 200. The log said the model coefficient was negative. I suspected the code, but the code was right. That negative number was not a bug: it was the value the model had read out of the data. This article traces where it comes from, and why a dynamic model does not stand up without an averaging operator. Everything is measured directly on a 24³ turbulent field.

The short version: 49.9% of the local coefficients were negative, and at 27.1% of the grid points the total viscosity went negative. One plane average over the same data drove that figure to 0.0%.

The filter-ratio slider runs from 1.2 to 4. Pushing it opens the gap between the blue grid filter and the orange test filter.

mean L = 0.0000  |  max |L| = 0.0000  |  energy kept by grid filter = 0.0 %

The green band is L(x)L(x). As α\alpha sticks toward 1 the amplitude dies to zero. No signal, no CC.

What it costs to fix one constant by hand#

The classic Smagorinsky model closes the subgrid-scale (SGS) stress with an eddy viscosity.

νT=(CsΔ)2∣Sˉ∣,∣Sˉ∣=2SˉijSˉij\nu_T = (C_s \Delta)^2 |\bar{S}|, \qquad |\bar{S}| = \sqrt{2 \bar{S}_{ij} \bar{S}_{ij}}

Here Δ\Delta is the grid filter width, Sˉij\bar{S}_{ij} the filtered strain-rate tensor, and CsC_s the constant.

The trouble is that CsC_s is not a constant. Decaying isotropic turbulence wants about 0.17; channel flow wants about 0.1. In a laminar region the eddy viscosity never reaches zero as long as ∣Sˉ∣≠0|\bar{S}| \neq 0. Approaching a wall, νT\nu_T should decay like y3y^3, and the model does not know that limit. The PMBFS2 manual patches the same spot with a Van Driest damping function. A damping function needs the wall distance, and in a complex geometry that distance is poorly defined.

Measure the same stress at two levels and a difference remains#

The way out proposed by Germano in 1991 is to stop supplying the constant from outside and read it off the resolved scales instead. A wider test filter (⋅)^\widehat{(\cdot)} is stacked on top of the grid filter. Writing the stress at each level,

τij=uiuj‾−uˉiuˉj,Tij=uiuj‾^−uˉ^iuˉ^j\tau_{ij} = \overline{u_i u_j} - \bar{u}_i \bar{u}_j, \qquad T_{ij} = \widehat{\overline{u_i u_j}} - \hat{\bar{u}}_i \hat{\bar{u}}_j

their difference cancels every unknown term and leaves a computable quantity behind.

Lij=Tij−τij^=uˉiuˉj^−uˉ^iuˉ^jL_{ij} = T_{ij} - \widehat{\tau_{ij}} = \widehat{\bar{u}_i \bar{u}_j} - \hat{\bar{u}}_i \hat{\bar{u}}_j

Only uˉ\bar{u} appears on the right-hand side, so LijL_{ij} can be measured without any model. That is the Germano identity, and it is the only footing the dynamic model stands on.

The α\alpha slider near the top already draws this identity. As α→1\alpha \to 1, LL dies to zero and the signal goes with it.

Five equations reduced to one scalar#

Even counting only the deviatoric components, LijL_{ij} gives five equations for a single unknown C=Cs2C = C_s^2. Assuming the same CC holds at both levels (scale invariance) and rearranging,

Lijd=CMij,Mij=2[Δ2∣Sˉ∣Sˉij^−Δ^2∣Sˉ^∣Sˉ^ij]L_{ij}^{d} = C M_{ij}, \qquad M_{ij} = 2\left[\Delta^2 \widehat{|\bar{S}| \bar{S}_{ij}} - \hat{\Delta}^2 |\hat{\bar{S}}| \hat{\bar{S}}_{ij}\right]

where the superscript dd marks the deviatoric part. Lilly closed this overdetermined system with least squares in 1992. Differentiating the residual ∥Lijd−CMij∥2\|L^d_{ij} - C M_{ij}\|^2 with respect to CC and setting it to zero gives

C=⟨LijdMij⟩⟨MklMkl⟩C = \frac{\langle L_{ij}^{d} M_{ij} \rangle}{\langle M_{kl} M_{kl} \rangle}

The brackets ⟨⋅⟩\langle \cdot \rangle are the subject of this article. Where they are applied changes the character of the model. Drop them and divide point by point, and you create places where the denominator approaches zero.

Building a 24³ turbulent field in Python and measuring the coefficient#

A field with random phases carries no cascade. So the Navier-Stokes equations are integrated for 40 steps first to build the correlations, and the a priori test runs on the result. No external libraries: plain lists only.

import math, random
 
N, NU = 24, 0.02
NP, H = N * N * N, 2.0 * math.pi / N
GRID_W, TEST_W = 3, 5                       # box filter widths, in cells
DELTA = GRID_W * H                          # grid filter width
DELTA_T = math.sqrt((GRID_W * H) ** 2 + (TEST_W * H) ** 2)   # composed test level
 
_perm = {}
def shift_perm(axis, off):
    """periodic index map for a shift of `off` cells along `axis`"""
    if (axis, off) not in _perm:
        p = [0] * NP
        for i in range(N):
            for j in range(N):
                for k in range(N):
                    a, b, c = i, j, k
                    if axis == 0: a = (i + off) % N
                    elif axis == 1: b = (j + off) % N
                    else: c = (k + off) % N
                    p[(i * N + j) * N + k] = (a * N + b) * N + c
        _perm[(axis, off)] = p
    return _perm[(axis, off)]
 
def box_filter(f, w):
    r, out = w // 2, f
    for axis in (0, 1, 2):
        acc = [0.0] * NP
        for off in range(-r, r + 1):
            acc = [a + out[q] for a, q in zip(acc, shift_perm(axis, off))]
        out = [v / w for v in acc]
    return out
 
def ddx(f, axis):
    inv = 1.0 / (2.0 * H)
    return [(f[a] - f[b]) * inv for a, b in zip(shift_perm(axis, 1), shift_perm(axis, -1))]
 
def lap(f):
    out = [-6.0 * v for v in f]
    for axis in (0, 1, 2):
        for off in (1, -1):
            out = [o + f[q] for o, q in zip(out, shift_perm(axis, off))]
    return [v / (H * H) for v in out]
 
def divergence(u):
    d = ddx(u[0], 0)
    d = [a + b for a, b in zip(d, ddx(u[1], 1))]
    return [a + b for a, b in zip(d, ddx(u[2], 2))]
 
def synth_field(nmodes, kmax, seed):
    """divergence-free random Fourier field, E(k) ~ k^(-5/3)"""
    random.seed(seed)
    u, xs = [[0.0] * NP for _ in range(3)], [i * H for i in range(N)]
    for _ in range(nmodes):
        while True:
            kv = [random.randint(-kmax, kmax) for _ in range(3)]
            km = math.sqrt(kv[0]**2 + kv[1]**2 + kv[2]**2)
            if 1.0 <= km <= kmax: break
        amp = km ** (-5.0 / 6.0)
        while True:
            r = [random.gauss(0, 1) for _ in range(3)]
            e = [r[1]*kv[2]-r[2]*kv[1], r[2]*kv[0]-r[0]*kv[2], r[0]*kv[1]-r[1]*kv[0]]
            en = math.sqrt(e[0]**2 + e[1]**2 + e[2]**2)
            if en > 1e-9: break
        e, ph = [c / en for c in e], random.uniform(0, 2 * math.pi)
        ax = [kv[0]*x for x in xs]; by = [kv[1]*x for x in xs]; cz = [kv[2]*x for x in xs]
        for i in range(N):
            for j in range(N):
                base, o = ax[i] + by[j] + ph, (i*N+j)*N
                for k in range(N):
                    c = math.cos(base + cz[k])
                    u[0][o+k] += amp*e[0]*c; u[1][o+k] += amp*e[1]*c; u[2][o+k] += amp*e[2]*c
    rms = math.sqrt(sum(v*v for comp in u for v in comp) / NP)
    return [[v / rms for v in comp] for comp in u]
 
def project(u, phi, sweeps):
    """remove the divergence; the Laplacian matches div(grad) of the 2h stencils"""
    rhs, hh = divergence(u), (2.0 * H) ** 2
    for _ in range(sweeps):
        acc = [0.0] * NP
        for axis in (0, 1, 2):
            for off in (2, -2):
                acc = [a + phi[q] for a, q in zip(acc, shift_perm(axis, off))]
        phi = [(a - hh * r) / 6.0 for a, r in zip(acc, rhs)]
    for d in range(3):
        u[d] = [v - s for v, s in zip(u[d], ddx(phi, d))]
    return u, phi
 
def rhs_ns(u):
    out = []
    for d in range(3):
        adv = [0.0] * NP
        for ax in range(3):
            g = ddx(u[d], ax)
            adv = [a + v * gg for a, v, gg in zip(adv, u[ax], g)]
        out.append([-a + NU * l for a, l in zip(adv, lap(u[d]))])
    return out
 
def advance(u, dt, nsteps, sweeps):
    """RK2 + pressure projection: random phases turn into a real cascade"""
    phi = [0.0] * NP
    for _ in range(nsteps):
        k1 = rhs_ns(u)
        mid = [[v + 0.5*dt*r for v, r in zip(u[d], k1[d])] for d in range(3)]
        k2 = rhs_ns(mid)
        u, phi = project([[v + dt*r for v, r in zip(u[d], k2[d])] for d in range(3)], phi, sweeps)
    return u
 
def strain_tensor(u):
    g = [[ddx(u[d], ax) for ax in range(3)] for d in range(3)]
    S = [[None]*3 for _ in range(3)]
    for a in range(3):
        for b in range(a, 3):
            S[a][b] = [0.5*(x+y) for x, y in zip(g[a][b], g[b][a])]
            S[b][a] = S[a][b]
    mag = [0.0]*NP
    for a in range(3):
        for b in range(3):
            mag = [m + 2.0*s*s for m, s in zip(mag, S[a][b])]
    return S, [math.sqrt(m) for m in mag]
 
def leonard_stress(ub, w):
    """L_ij = test(u_i u_j) - test(u_i) test(u_j), deviatoric part only"""
    ut = [box_filter(c, w) for c in ub]
    L = [[None]*3 for _ in range(3)]
    for a in range(3):
        for b in range(a, 3):
            prod = box_filter([x*y for x, y in zip(ub[a], ub[b])], w)
            L[a][b] = [p - x*y for p, x, y in zip(prod, ut[a], ut[b])]
            L[b][a] = L[a][b]
    tr = [0.0]*NP
    for a in range(3):
        tr = [t + v for t, v in zip(tr, L[a][a])]
    for a in range(3):
        L[a][a] = [v - t/3.0 for v, t in zip(L[a][a], tr)]
    return L, ut
 
def m_tensor(S, mag, ut, w, dg, dt_):
    """M_ij = 2[ D^2 test(|S|S_ij) - Dhat^2 |S_test| S_test_ij ]"""
    St, magt = strain_tensor(ut)
    M = [[None]*3 for _ in range(3)]
    for a in range(3):
        for b in range(a, 3):
            t1 = box_filter([m*s for m, s in zip(mag, S[a][b])], w)
            M[a][b] = [2.0*(dg*dg*x - dt_*dt_*mt*st) for x, mt, st in zip(t1, magt, St[a][b])]
            M[b][a] = M[a][b]
    return M
 
def contract(A, B):
    out = [0.0]*NP
    for a in range(3):
        for b in range(3):
            out = [o + x*y for o, x, y in zip(out, A[a][b], B[a][b])]
    return out
 
def plane_average(v):
    acc = [0.0]*N
    for i in range(N):
        for j in range(N):
            o = (i*N+j)*N
            for k in range(N):
                acc[k] += v[o+k]
    return [a/(N*N) for a in acc]
 
def pct(v, q):
    s = sorted(v)
    return s[min(len(s)-1, int(q*len(s)))]
 
u = advance(synth_field(40, 8, 20260915), 0.05, 40, 40)
urms = math.sqrt(sum(v*v for c in u for v in c) / NP)
dv = divergence(u)
sg = [0.0]*NP
for d in range(3):
    for a in range(3):
        sg = [x + y*y for x, y in zip(sg, ddx(u[d], a))]
print("grid %d^3  nu %.3f  t_end %.2f  u_rms %.4f" % (N, NU, 0.05*40, urms))
print("rms|div u| / rms|grad u|      : %.3f"
      % (math.sqrt(sum(v*v for v in dv)/NP) / math.sqrt(sum(sg)/NP)))
 
ub = [box_filter(c, GRID_W) for c in u]
S, mag = strain_tensor(ub)
L, ut = leonard_stress(ub, TEST_W)
 
print("--- Germano-Lilly coefficient  C = Cs^2 ---")
ref = None
for tag, dt_ in (("composed  a=%.3f" % (DELTA_T/DELTA), DELTA_T),
                 ("textbook  a=2.000", 2.0*DELTA),
                 ("test only a=%.3f" % (TEST_W/GRID_W), TEST_W*H)):
    M = m_tensor(S, mag, ut, TEST_W, DELTA, dt_)
    c = sum(contract(L, M)) / sum(contract(M, M))
    if ref is None:
        ref, Mref = c, M
        print("  %s : C = %.6f   Cs = %.4f" % (tag, c, math.sqrt(c)))
    else:
        print("  %s : C = %.6f   Cs = %.4f   (%+.1f%%)" % (tag, c, math.sqrt(c), 100*(c/ref-1)))
 
LM, MM = contract(L, Mref), contract(Mref, Mref)
Cloc = [a/b for a, b in zip(LM, MM)]
nuT = [c*DELTA*DELTA*m for c, m in zip(Cloc, mag)]
mm_mean = sum(MM) / NP
print("--- pointwise C (no averaging) ---")
print("  C < 0 fraction              : %.1f %%" % (100.0*sum(1 for c in Cloc if c < 0)/NP))
print("  C  p01 / p50 / p99          : %+.4f / %+.4f / %+.4f" % (pct(Cloc,0.01), pct(Cloc,0.5), pct(Cloc,0.99)))
print("  M:M < 1e-3 * <M:M>          : %.2f %%" % (100.0*sum(1 for m in MM if m < 1e-3*mm_mean)/NP))
print("  nu_T(global C) / nu         : %.2f" % (ref*DELTA*DELTA*(sum(mag)/NP)/NU))
print("  nu + nu_T < 0               : %.1f %%" % (100.0*sum(1 for v in nuT if NU+v < 0)/NP))
print("  worst nu_T / nu             : %.1f" % (min(nuT)/NU))
 
Cpl = [a/b for a, b in zip(plane_average(LM), plane_average(MM))]
nuTp = [Cpl[n % N]*DELTA*DELTA*mag[n] for n in range(NP)]
print("--- C averaged over i-j planes ---")
print("  C range over %d planes       : %+.5f .. %+.5f" % (N, min(Cpl), max(Cpl)))
print("  negative planes             : %d / %d" % (sum(1 for c in Cpl if c < 0), N))
print("  nu + nu_T < 0               : %.1f %%" % (100.0*sum(1 for v in nuTp if NU+v < 0)/NP))

출력은 이렇게 나온다.

grid 24^3  nu 0.020  t_end 2.00  u_rms 0.5140
rms|div u| / rms|grad u|      : 0.008
--- Germano-Lilly coefficient  C = Cs^2 ---
  composed  a=1.944 : C = 0.008296   Cs = 0.0911
  textbook  a=2.000 : C = 0.008035   Cs = 0.0896   (-3.1%)
  test only a=1.667 : C = 0.001029   Cs = 0.0321   (-87.6%)
--- pointwise C (no averaging) ---
  C < 0 fraction              : 49.9 %
  C  p01 / p50 / p99          : -0.5499 / +0.0001 / +0.3246
  M:M < 1e-3 * <M:M>          : 0.07 %
  nu_T(global C) / nu         : 0.25
  nu + nu_T < 0               : 27.1 %
  worst nu_T / nu             : -78.6
--- C averaged over i-j planes ---
  C range over 24 planes       : -0.00021 .. +0.01692
  negative planes             : 1 / 24
  nu + nu_T < 0               : 0.0 %
C < 0 : 0.0 %  |  ν + νT < 0 : 0.0 %  |  C range 0.0000 … 0.0000  |  global C = 0.00000

Grow the window from 1×1 and the blue negative points vanish first, while ν+νT<0\nu + \nu_T < 0 drops to zero. Clipping cuts from below and skips the average.

The global least-squares value is Cs=0.0911C_s = 0.0911, inside the 0.09 to 0.12 band usually reported for a priori tests with a box filter.

Half of them being negative is not an error#

49.9% of the local coefficients are negative. The median is +0.0001+0.0001, effectively zero, while the 1st percentile sits at −0.55-0.55 and the 99th at +0.32+0.32. The spread reaches 40 to 66 times the mean value of 0.0083 on either side.

Those negative values mean something physical. Energy does not travel only from large scales to small ones. Locally it runs the other way, a process called backscatter, observed at 30 to 50% of the grid points in real turbulence. A dynamic model is honest enough to read that direction too.

The price of that honesty is the problem. C<0C < 0 means νT<0\nu_T < 0, and the sign of the diffusion term flips. In the run above, 27.1% of the grid points sat at ν+νT<0\nu + \nu_T < 0. The worst point reaches νT/ν=−78.6\nu_T / \nu = -78.6, pushing backwards with 78 times the molecular viscosity. Divergence is the guaranteed outcome.

The map right after the output already dragged that window. Blue is negative CC, red is positive. Averaging and clipping leave different marks there.

The global fit is C=0.008296C = 0.008296, so Cs=0.0911C_s = 0.0911. At α=2\alpha = 2, C=0.008035C = 0.008035, down 3.13.1%. At α=1.667\alpha = 1.667, C=0.001029C = 0.001029 and Cs=0.0321C_s = 0.0321, down 87.687.6%. The composed width is Δ^=5.83h\hat{\Delta} = 5.83h, so α=1.944\alpha = 1.944. MijM_{ij} grows with α2−1\alpha^2 - 1. A small move from 2 to 1.944 only costs 3.13.1% of CC. Drop to 1.667 and numerator and denominator shrink together, taking 87.687.6% with them.

Pointwise, the 1st percentile of CC is −0.5499-0.5499, the median is +0.0001+0.0001, and the 99th is +0.3246+0.3246. Set next to the mean 0.00830.0083, the tails are tens of times larger. The negative fraction, 49.949.9%, sits inside the 3030–5050% band where backscatter shows up. ν+νT<0\nu + \nu_T < 0 covers 27.127.1% of the points, and the worst is νT/ν=−78.6\nu_T / \nu = -78.6. The global eddy viscosity on the same field is νT/ν=0.25\nu_T / \nu = 0.25. The mean is a quarter of the molecular viscosity. One point pushes backward at 78 times that.

After the plane average, CC runs from −0.00021-0.00021 to +0.01692+0.01692. One of 24 planes is negative, and ν+νT<0\nu + \nu_T < 0 is 0.00.0%. Points where MijMijM_{ij}M_{ij} is under a thousandth of its mean are 0.070.07%. The divergence ratio is 0.0080.008, so the negatives are not leftover projection error. On a field with urms=0.5140u_{rms} = 0.5140, ν=0.020\nu = 0.020, and t=2.00t = 2.00, the brackets are what erase 27.127.1% down to 00%. Putting that average on the circumference and the axis of a pipe is a different question from the threshold where a pipe stream breaks up. A negative coefficient and a flow leaving laminar are not the same line in the log.

Where the brackets go#

The ⟨⋅⟩\langle \cdot \rangle left behind by Germano and Lilly is not there to make the formula look tidy. The last block of output is the evidence. Average the numerator and the denominator separately over ii–jj planes, then divide: only 1 of 24 planes is negative, and that value is −0.0002-0.0002. Not a single grid point has ν+νT<0\nu + \nu_T < 0. Same data, same formula, and the unstable fraction falls from 27.1% to zero.

The order matters. Do not compute CC first and average afterwards, because CC blows up wherever the denominator is near zero. In this run, 0.07% of the points had MijMijM_{ij}M_{ij} below one-thousandth of its mean. Average ⟨L:M⟩\langle L{:}M \rangle and ⟨M:M⟩\langle M{:}M \rangle separately, then divide.

The flow decides the averaging direction: planes parallel to the wall in a channel, the circumferential and axial directions in a pipe. With no homogeneous direction at all, the Lagrangian dynamic model averages along pathlines instead. How a constant gets entangled with the mesh width is already the ratio α=Δ^/Δ\alpha = \hat{\Delta}/\Delta.

Get α\alpha wrong and 88% of the coefficient disappears#

MijM_{ij} contains Δ^2\hat{\Delta}^2, and the number chosen for it moves CC wholesale. The code above stacks a 5-cell box filter on top of a 3-cell one. The effective width of the test level is not 5 cells. Applying two box filters adds their second moments, so

Δ^=Δ2+Δtest2=(3h)2+(5h)2=5.83h\hat{\Delta} = \sqrt{\Delta^2 + \Delta_{\text{test}}^2} = \sqrt{(3h)^2 + (5h)^2} = 5.83h

and α=Δ^/Δ=1.944\alpha = \hat{\Delta}/\Delta = 1.944. Following the textbook habit of α=2\alpha = 2 moves CC by only 3.1%. But dropping the test filter width 5h5h straight into Δ^\hat{\Delta} gives α=1.667\alpha = 1.667 and shrinks CC by 87.6%. In terms of CsC_s, 0.0911 falls to 0.0321.

The reason sits in the structure of MijM_{ij}. It is built as a difference of two terms, so it grows in proportion to α2−1\alpha^2 - 1. As α\alpha approaches 1, numerator and denominator go to zero together and their ratio distorts sharply. That is the same effect as LL collapsing when α\alpha is pushed toward 1.2 in the first simulation.

Why PMBFS2 left the dynamic model switched off#

The manual behind this article implements the dynamic model and then states that the actual simulations used the algebraic Smagorinsky model. It gives two reasons: the best SGS model for real-fluid flows is still unknown, and the dynamic model carries a stricter grid requirement because of its double filters.

The second reason is the practical one. The dynamic procedure holds only if Δ\Delta and Δ^\hat{\Delta} both lie inside the inertial subrange, and Δ^\hat{\Delta} is twice Δ\Delta. That means a mesh twice as fine to keep the same assumption alive, which in three dimensions is eight times the cell count. The constant comes for free, and the mesh pays for it.

So the practical split runs like this. With at least one homogeneous direction, and transition or relaxation physics that matters, the dynamic model earns its cost. With a complex geometry and a tight mesh budget, an algebraic model plus a damping function is the realistic choice. Feeding a wall distance into a damping function leaves the same spot open in recirculation behind a backward-facing step.

When a negative number shows up in the log now, the code is no longer the first suspect. The brackets are.

Share if you found it helpful.