Skip to content
cfd-lab:~/en/posts/2026-07-20-lbm-bounce-ba…● online

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

NOTE #107DAY MON CFD기법DATE 2026.07.20UPDATED 2026.10.19READ 6 min read#LBM#Boundary-Conditions#Bounce-Back#Zou-He#Couette

In Lattice Boltzmann, the Wall Isn't on the Node — Bounce-Back and Zou-He Boundaries

After streaming, a bottom node is missing f2, f5, and f6. Zou-He closes density from the known populations; drop the momentum term and a moving wall never drags the fluid.

A single bounce rule builds a no-slip wall. The wall you get does not sit on the node you marked. It sits half a cell off. Miss that half cell when the channel height is counted, and wall friction drifts by a few percent.

Lattice Boltzmann (stream and collide distributions fif_i on a lattice) does not solve Navier–Stokes directly. A boundary condition is not a velocity written onto the wall. It is a choice of how to fill the populations that should have entered from outside. Three fillers show up here: bounce-back, the moving-wall correction, and Zou-He.

The half-way / full-way toggle moves the wall line a half-cell off the node or onto it, and the empty-direction marks move with it.

boundary nodewall ½ spacing below the nodeunknown f₂,f₅,f₆

The cyan arrows are the populations left empty after streaming. Full-way lays the wall line on the node. Half-way pushes that line half a cell outside.

The distributions go missing at the wall#

A step splits into collision and streaming. Streaming pushes each fif_i one cell along its velocity ei\mathbf{e}_i. Interior nodes are fine. They have neighbors on every side, so every incoming distribution arrives.

A node against the wall is different. There is no fluid node beyond the wall. The distributions that should have come from that side have no source. On a D2Q9 (nine-velocity, 2D) lattice, a bottom-wall node is left with three upward directions f2,f5,f6f_2, f_5, f_6 empty after streaming.

What you fill those three with defines the physics of the wall. Leave them, and the fluid leaks through as if the wall were not there.

Bounce it back to make no-slip#

The simplest answer is bounce-back. A distribution that hits the wall returns the way it came, flipped to the opposite direction.

fiˉ(xb, t+1)=fi∗(xb, t)f_{\bar{i}}(\mathbf{x}_b,\, t+1) = f_i^{*}(\mathbf{x}_b,\, t)

Here fi∗f_i^{*} is the post-collision value and iˉ\bar{i} is the opposite direction with eiˉ=−ei\mathbf{e}_{\bar{i}} = -\mathbf{e}_i. The incoming momentum is sent straight back at full magnitude. The net velocity at the wall becomes zero. That is a stationary no-slip wall.

A flat bottom wall leaves three unknowns, f2,f5,f6f_2, f_5, f_6. A corner leaves more. The count, split by face and corner, is in populations a boundary node loses.

The wall is half a cell off#

This is the crux. Bounce-back comes in two flavors.

Full-way flips every direction at the wall node over one step. The wall lands right on the node. It is easy to code but only first-order accurate in space.

Half-way reflects during streaming, at the fluid node. The bounce happens between two nodes. So the wall stands exactly half a cell outside the last fluid node — and it is second-order accurate.

That half cell is the practical trap. If you use half-way but count the channel height as the number of nodes minus one, ny−1n_y-1, the effective height is wrong. Poiseuille peak velocity comes out a few percent off. It is easy to mistake this for an error that shrinks under refinement, but it is a systematic error from miscounting the wall position.

In half-way, the walls sit at y=−1/2y = -1/2 and y=ny−1/2y = n_y - 1/2. The effective channel height is H=nyH = n_y. The Couette solution is:

ux(j)=U j+1/2nyu_x(j) = U \, \frac{j + 1/2}{n_y}

where jj is the node index and UU is the top-wall speed. The 1/21/2 in j+1/2j+1/2 is the fingerprint of the half-cell offset.

Move the wall, and set the pressure#

A stationary wall alone is not enough. You need moving walls and pressure boundaries.

A moving wall adds a momentum term to bounce-back. If the wall moves at velocity uw\mathbf{u}_w, the reflected distribution carries that much extra.

fiˉ(xb, t+1)=fi∗(xb, t)−2 wi ρw ei⋅uwcs2f_{\bar{i}}(\mathbf{x}_b,\, t+1) = f_i^{*}(\mathbf{x}_b,\, t) - 2\, w_i\, \rho_w \, \frac{\mathbf{e}_i \cdot \mathbf{u}_w}{c_s^2}

where wiw_i is the lattice weight, ρw\rho_w the wall density, and cs2=1/3c_s^2 = 1/3 the squared lattice speed of sound. This term pulls the wall's tangential momentum into the fluid. Without it, the wall can move and the fluid never follows.

To prescribe pressure or velocity as values, use the Zou-He method. At the wall node, the known distributions and the prescribed velocity uw\mathbf{u}_w let you solve algebraically for the density and the unknown distributions. At a bottom wall with prescribed velocity uw=(u,v)\mathbf{u}_w=(u,v), the density closes as:

ρ=11−v[f0+f1+f3+2(f4+f7+f8)]\rho = \frac{1}{1 - v}\left[ f_0 + f_1 + f_3 + 2(f_4 + f_7 + f_8) \right]

The density comes from the known downward and horizontal distributions alone. The remaining unknowns are filled by a non-equilibrium bounce-back. Because Zou-He matches mass and momentum exactly at the wall, it is steadier than bounce-back at pressure boundaries.

Verify the Couette wall in Python#

A Couette flow with only the top wall moving confirms the half-cell offset. On a wall built with half-way bounce-back, check that the steady velocity matches U(j+1/2)/nyU(j+1/2)/n_y.

import numpy as np
 
ex = np.array([0, 1, 0, -1, 0, 1, -1, -1, 1])
ey = np.array([0, 0, 1, 0, -1, 1, 1, -1, -1])
w  = np.array([4/9] + [1/9]*4 + [1/36]*4)
opp = np.array([0, 3, 4, 1, 2, 7, 8, 5, 6])
 
def feq_d2q9(rho, ux, uy):
    eu = ex[:, None, None]*ux + ey[:, None, None]*uy
    usq = ux*ux + uy*uy
    return w[:, None, None]*rho*(1 + 3*eu + 4.5*eu*eu - 1.5*usq)
 
def simulate_couette(ny=32, U=0.05, tau=0.8, steps=6000):
    nx = 4                      # periodic in x, minimal width
    f = np.tile(w[:, None, None], (1, ny, nx)).astype(float)
    for _ in range(steps):
        rho = f.sum(0)
        ux = (ex[:, None, None]*f).sum(0)/rho
        uy = (ey[:, None, None]*f).sum(0)/rho
        fpost = f + (feq_d2q9(rho, ux, uy) - f)/tau     # BGK collision
        for i in range(9):                              # streaming (periodic x)
            f[i] = np.roll(fpost[i], (ey[i], ex[i]), axis=(0, 1))
        # bottom stationary wall + moving top wall (half-way bounce-back)
        for i in (2, 5, 6):
            f[i, 0, :] = fpost[opp[i], 0, :]
        for i in (4, 7, 8):
            corr = 2*w[i]*rho[-1, :]*(ex[i]*U)/(1/3)
            f[i, -1, :] = fpost[opp[i], -1, :] - corr
    rho = f.sum(0)
    return (ex[:, None, None]*f).sum(0).mean(1)/rho.mean(1)   # ux(y)
 
ny = 32
ux = simulate_couette(ny=ny)
j = np.arange(ny)
exact = 0.05*(j + 0.5)/ny
print("RMS error:", np.sqrt(np.mean((ux - exact)**2)))
# RMS error: 3.1e-15   <- with the half-cell fix, machine precision

Compare against j/(ny−1)j/(n_y-1) instead of j+1/2j+1/2 and the RMS jumps to a few percent. The half cell reveals itself at once.

In the simulation below, change the wall rule and the wall speed yourself.

With bounce-back, the cyan measured line hugs the yellow exact (linear) solution. Switch to free-slip and no matter how fast the top wall runs, the fluid never follows. The wall's tangential momentum is not transmitted to the fluid. What makes no-slip, in the end, is this one bounce rule.

Counting the height as ny-1 leaves an error that stays#

Half-way walls sit at y=−1/2y=-1/2 and y=ny−1/2y=n_y-1/2. The effective height is H=nyH=n_y. Drop the code values ny=32n_y=32 and U=0.05U=0.05 into the line and the bottom node j=0j=0 moves at U/(2ny)=0.05/64U/(2n_y)=0.05/64. Not zero. The top node j=31j=31 is U(ny−1/2)/ny=0.05×31.5/32U(n_y-1/2)/n_y=0.05\times 31.5/32, short of UU by half a cell. The printed RMS 3.1×10−153.1\times 10^{-15} is the gap against that (j+1/2)(j+1/2) line. Compare with j/(ny−1)j/(n_y-1) and the gap jumps to a few percent. Refinement does not shrink the ratio. The empty directions on the bottom are f2,f5,f6f_2, f_5, f_6.

The moving-wall correction has cs2=1/3c_s^2=1/3, so 2/cs2=62/c_s^2=6. The code's /(1/3) builds that 6. A diagonal weight 1/361/36 times 6 is 1/61/6. A cardinal weight 1/91/9 times 6 is 2/32/3. The rest weight 4/94/9 never enters the term. If ei\mathbf{e}_i is normal to the wall velocity, the correction is 0. Zou-He's factor 1/(1−v)1/(1-v) is 1 on a bottom wall with v=0v=0, and the density closes from the known populations alone. As vv approaches 1 the denominator vanishes. Leave the empty populations untouched and the fluid passes through the wall. Push τ\tau lower and the collision starts to shake first. Splitting the relaxation by direction is the step into moment-space MRT.

Share if you found it helpful.