Doubling τ Left Pr at 1.0000 — The Two Constants Locked Inside Thermal LBM
With a single τ, Pr = 1 and γ = 1 + 2/D are not fluid properties — they are numbers the lattice picked. Freeing both requires a separate distribution function for energy.
A number I never supplied came out as 1.0000#
I put one sinusoidal shear wave and one sinusoidal temperature wave on a D2Q9 lattice and measured the kinematic viscosity and the thermal diffusivity from how fast each amplitude decayed. Then I repeated the measurement with the relaxation time doubled step by step: 0.6, 0.8, 1.2. All three runs returned a Prandtl number (the ratio of momentum diffusion to thermal diffusion) of 1.0001, 1.0000, 1.0001.
I never supplied that number as a material property. The lattice picked it.
This post points at the line of code where the lock sits, then re-measures on the same lattice what opens up once energy gets its own distribution function. And is not the only constant locked in. The specific heat ratio is locked too, and on D2Q9 its value is 2 — not the 1.4 of air. Even after the lock comes off, cannot be pushed indefinitely, so I measured where it breaks down as well.
Work the lock open and shut yourself below.
While it stays locked, no amount of moving separates the curves: the orange (temperature) curve hides completely under the blue (velocity) one. Release the lock, drag , and the two curves split apart. How far they split is exactly .
One τ gets used in two places#
Here is the lattice Boltzmann equation with a BGK collision term.
is the distribution function along lattice direction , is the discrete velocity in that direction, and is the relaxation time. Carry the Chapman–Enskog expansion (an expansion that treats the departure from equilibrium as a small parameter and separates the result order by order) to first order, and the viscous stress falls out of the first-order non-equilibrium part of . That gives the familiar result.
is the lattice speed of sound and is the correction left behind by discretization. Where that half step comes from is worked out separately in The Δt/2 the LBM Discretization Left Behind.
The trouble starts right after. Define internal energy as a second moment of that same , and the temperature equation drops out of the same expansion through the same first-order non-equilibrium term. The thermal diffusivity comes out with an identical shape.
The right-hand sides match character for character. Only one conclusion follows.
The reason fits in one line. Momentum flux and heat flux both come from the same non-equilibrium term of the same distribution function, and there is exactly one time constant sitting in front of that term. One constant means the ratio is not yours to choose.
That sits near 1 for gases is an experimental fact, and the Reynolds analogy that links friction to heat transfer follows from it. But there it is an approximation and a modeling choice. Here it is enforced. Feed the solver water or liquid metal and it still returns 1.
Give energy its own distribution function#
The fix is structurally simple. The problem came from having only one constant, so make a second one. Let carry mass and momentum only, and hand energy to a second distribution function . That is the double-distribution-function (DDF) method.
The moment conditions must satisfy take two lines.
is the total energy per unit mass and is the work done by pressure. The in the second line is the crux. The convective flux of energy is not alone but , pressure work included, so the equilibrium distribution for cannot simply copy the one for .
Relax with , run the same Chapman–Enskog expansion, and the thermal diffusivity now looks at .
The Reynolds number sets ; the Prandtl number sets . The two demands finally hold separate knobs.
Measuring the lock and its release on one lattice in Python#
Rather than leave it as an argument, I measured it. I set up on D2Q9 and on D2Q5 (a five-velocity lattice for temperature only), with a sine wave varying along as the initial condition. The shear wave amplitude decays as and the temperature wave as . A least-squares fit to the log amplitude returns and .
import math
NY, CS2 = 64, 1.0 / 3.0
K = 2.0 * math.pi / NY
EX9 = [0, 1, 0, -1, 0, 1, -1, -1, 1]
EY9 = [0, 0, 1, 0, -1, 1, 1, -1, -1]
W9 = [4/9, 1/9, 1/9, 1/9, 1/9, 1/36, 1/36, 1/36, 1/36]
EY5 = [0, 0, 1, 0, -1]
W5 = [1/3, 1/6, 1/6, 1/6, 1/6]
def feq_d2q9(rho, ux, uy):
u2 = ux * ux + uy * uy
return [w * rho * (1 + 3 * (ex * ux + ey * uy)
+ 4.5 * (ex * ux + ey * uy) ** 2 - 1.5 * u2)
for w, ex, ey in zip(W9, EX9, EY9)]
def geq_d2q5(temp):
return [w * temp for w in W5]
def fit_decay_rate(samples):
"""samples = [(step, amplitude)] -> least-squares slope of ln(amplitude)."""
n = len(samples)
xs = [s for s, _ in samples]
ys = [math.log(a) for _, a in samples]
mx, my = sum(xs) / n, sum(ys) / n
num = sum((x - mx) * (y - my) for x, y in zip(xs, ys))
den = sum((x - mx) ** 2 for x in xs)
return -num / den
def run_shear_wave(tau_f, steps=3000, amp=1e-3):
f = [[0.0] * NY for _ in range(9)]
for y in range(NY):
for i, v in enumerate(feq_d2q9(1.0, amp * math.sin(K * y), 0.0)):
f[i][y] = v
log = []
for step in range(steps + 1):
rho = [sum(f[i][y] for i in range(9)) for y in range(NY)]
ux = [sum(f[i][y] * EX9[i] for i in range(9)) / rho[y] for y in range(NY)]
if step % 200 == 0:
a = 2.0 / NY * sum(ux[y] * math.sin(K * y) for y in range(NY))
log.append((step, a))
post = [[0.0] * NY for _ in range(9)]
for y in range(NY):
eq = feq_d2q9(rho[y], ux[y], 0.0)
for i in range(9):
post[i][y] = f[i][y] - (f[i][y] - eq[i]) / tau_f
for i in range(9):
for y in range(NY):
f[i][(y + EY9[i]) % NY] = post[i][y]
return fit_decay_rate(log) / (K * K)
def run_thermal_wave(tau_g, amp=1e-3):
"""Pick the step count from tau_g so the amplitude always decays by e^-2.5."""
alpha_th = CS2 * (tau_g - 0.5)
steps = max(240, min(12000, int(2.5 / (alpha_th * K * K))))
every = max(1, steps // 12)
g = [[0.0] * NY for _ in range(5)]
for y in range(NY):
for i, v in enumerate(geq_d2q5(amp * math.sin(K * y))):
g[i][y] = v
log = []
for step in range(steps + 1):
temp = [sum(g[i][y] for i in range(5)) for y in range(NY)]
if step % every == 0:
a = 2.0 / NY * sum(temp[y] * math.sin(K * y) for y in range(NY))
log.append((step, a))
post = [[0.0] * NY for _ in range(5)]
for y in range(NY):
eq = geq_d2q5(temp[y])
for i in range(5):
post[i][y] = g[i][y] - (g[i][y] - eq[i]) / tau_g
for i in range(5):
for y in range(NY):
g[i][(y + EY5[i]) % NY] = post[i][y]
return fit_decay_rate(log) / (K * K)
def tau_for(nu, target_pr):
return 0.5 + nu / (target_pr * CS2)
TAU_F = 0.8
nu = run_shear_wave(TAU_F)
print(f"tau_f = {TAU_F} nu(theory) = {CS2*(TAU_F-0.5):.6f} nu(measured) = {nu:.6f}")
print()
print("[A] single distribution: one tau relaxes momentum AND energy")
print(f"{'tau':>6} {'nu':>10} {'alpha':>10} {'Pr':>8}")
for t in (0.6, 0.8, 1.2):
n_, a_ = run_shear_wave(t), run_thermal_wave(t)
print(f"{t:6.2f} {n_:10.6f} {a_:10.6f} {n_/a_:8.4f}")
print()
print("[B] double distribution: tau_g chosen for a target Pr (tau_f = 0.8)")
print(f"{'gas':>8} {'Pr(target)':>11} {'tau_g':>8} {'alpha':>10} {'Pr(meas)':>9} {'err%':>7}")
for name, pr in (("mercury", 0.025), ("air", 0.71), ("Pr=1", 1.0), ("water", 7.0)):
tg = tau_for(nu, pr)
a_ = run_thermal_wave(tg)
prm = nu / a_
print(f"{name:>8} {pr:11.3f} {tg:8.4f} {a_:10.6f} {prm:9.4f} {100*(prm-pr)/pr:7.2f}")
print()
print("[C] how far can tau_g be pushed? (theory: alpha = cs2*(tau_g-0.5))")
print(f"{'tau_g':>7} {'alpha(th)':>10} {'alpha(meas)':>12} {'err%':>7}")
for tg in (0.51, 0.55, 0.7, 1.0, 2.0, 4.0, 8.0, 12.5):
th = CS2 * (tg - 0.5)
ms = run_thermal_wave(tg)
print(f"{tg:7.2f} {th:10.5f} {ms:12.5f} {100*(ms-th)/th:7.2f}")tau_f = 0.8 nu(theory) = 0.100000 nu(measured) = 0.100057
[A] single distribution: one tau relaxes momentum AND energy
tau nu alpha Pr
0.60 0.033368 0.033363 1.0001
0.80 0.100057 0.100060 1.0000
1.20 0.233144 0.233124 1.0001
[B] double distribution: tau_g chosen for a target Pr (tau_f = 0.8)
gas Pr(target) tau_g alpha Pr(meas) err%
mercury 0.025 12.5069 2.047239 0.0489 95.50
air 0.710 0.9228 0.140963 0.7098 -0.03
Pr=1 1.000 0.8002 0.100117 0.9994 -0.06
water 7.000 0.5429 0.014308 6.9931 -0.10
[C] how far can tau_g be pushed? (theory: alpha = cs2*(tau_g-0.5))
tau_g alpha(th) alpha(meas) err%
0.51 0.00333 0.00334 0.16
0.55 0.01667 0.01668 0.10
0.70 0.06667 0.06672 0.08
1.00 0.16667 0.16667 -0.00
2.00 0.50000 0.49624 -0.75
4.00 1.16667 1.11274 -4.62
8.00 2.50000 1.94812 -22.08
12.50 4.00000 2.04752 -48.81Table [A] is the lock. Double from 0.6 to 1.2 and grows sevenfold, yet stays 1 out to the fourth decimal. Table [B] is the release. Air lands at 0.7098 and water at 6.9931, both within 0.1% of target. The top row is the exception: mercury is off by 95%. That row gets its own section later.
There is a second locked constant — the specific heat ratio#
Stop at and you miss the second lock. A particle on the lattice moves along translational directions and nothing else. No rotation, no vibration. The heat capacity at constant volume then counts translational degrees of freedom only, giving , and the gas constant comes back as . The specific heat ratio is decided for you.
is the number of internal degrees of freedom carried on top of the translational ones. Do nothing and .
| Lattice | Matching gas | |||
|---|---|---|---|---|
| D2Q9 | 0 | 2.000 | none | |
| D3Q19 | 0 | 1.667 | monatomic (Ar, He) | |
| D2Q9 | 3 | 1.400 | air | |
| D3Q19 | 2 | 1.400 | air | |
| D2Q9 | 4 | 1.333 | water vapor |
In two dimensions, an untouched lattice has . Since the speed of sound is , that is times the value for air — 19.5% too fast. Mach number, shock angles, and nozzle choking conditions all shift by the same factor. In compressible simulations this bites before does.
Start from D2Q9 with , watch how far the two acoustic pulses separate, then drag until the blue marker sits on the dashed line. The number where it lands is the count of internal degrees of freedom the energy distribution function has to carry. Matching air takes 2 in three dimensions and 3 in two.
How far τ_g can be pushed#
Table [C] measures how long stays trustworthy. Out to the error stays under 0.8%. At 4 it is 4.6%, at 8 it is 22%, and at 12.5 the gap opens to 49%. The measurement returns barely half the theoretical value.
The reason lies in where that formula came from. is a first-order result of the Chapman–Enskog expansion. For the expansion to hold, the relaxation time must be short compared with the time scale of the flow. As grows, the discarded second-order term — a hyperdiffusive term proportional to the fourth power of the wavenumber — grows to the size of the first-order one. The practical ceiling is around in lattice units.
That is why the mercury row in [B] broke. Getting at demands , eight times that ceiling. The prescription is not to raise further but to lower . Holding while reaching requires , that is . Now the opposite wall shows up: BGK turns unstable as approaches 0.5.
So the picture is this. High runs into a stability wall on the side, and low runs into an accuracy wall as grows. What DDF hands you is a window, not unlimited freedom.
Which ledger does viscous heating go on#
Splitting and creates a new problem. The energy equation contains the viscous dissipation term . But is a quantity that emerges from the first-order non-equilibrium part of , and it relaxes with .
is the strain rate tensor. Since relaxes with , the dissipation term returned by the expansion of carries in front of it. The moment the two relaxation times differ, the coefficients disagree. They match on their own only when . That is why coupled DDF schemes attach one more correction term to the equation.
Computing that correction locally at each lattice node requires closing in terms of moments. Grad's 13-moment approximation fills that slot.
Set and the familiar equilibrium distribution comes back untouched. Where that polynomial comes from is written up in Maxwell–Boltzmann Inside Nine Arrows. Grad's approximation carries the same expansion one order further, feeding the non-equilibrium stress back into the distribution function itself. This is also the fork between the coupled DDF papers of 2007 (the Li et al. model for compressible Navier–Stokes) and the low-Mach decoupled models. The former attaches the correction explicitly; the latter throws viscous heating away entirely.
How much physics one distribution function can carry#
A single can carry , , and one relaxation time — that is the budget. Put temperature on a second moment of that same and and stop being fluid properties and become lattice constants. Low-Mach Boussinesq simulations never expose the problem, since temperature there is a passive scalar anyway. Move to compressible thermal flow and both constants come due at once.
In a thermal LBM code you inherited, three lines are enough to find first. Does temperature come out of a moment of , or out of a separate array? Is hardcoded as a constant, or computed from ? And is there a correction term next to the collision operator of that uses ? If the third one is missing while , the viscous heating in that code is a quantity nobody ever computed.
Related
Share if you found it helpful.