Skip to content
cfd-lab:~/en/posts/2026-09-23-multicomponen…● online

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

NOTE #163DAY WED CFD기법DATE 2026.09.23UPDATED 2026.10.19READ 8 min read#Species-Transport#LBM#Chapman-Enskog#Diffusion#Combustion

Giving every species its own tau threw the total mass off by 16.8% — correction velocity in multicomponent LBM

The moment two species get different diffusivities, continuity breaks. One correction-velocity line turns 0.168 into 0.0005.

The day I added a third species and the density ledger stopped balancing#

Every species you bolt onto a combustion solver brings another set of distribution functions with it. Each one diffuses at its own rate, so each gets its own relaxation time τk\tau_k. So far, textbook. Run a few hundred steps and ∑kYk\sum_k Y_k walks away from 1. The mass fractions (the share of the total mass carried by each species) have to add up to 1 by definition.

This post traces that drift back to its source with a Chapman–Enskog expansion, then undoes it with a single line. A three-species mixture runs for 2000 steps and the worst defect drops from 0.168 to 0.0005. The conclusion up front: this is not a bug, it is the model. The instant you hand the species different τk\tau_k, continuity is built to break.

Three sets of distribution functions, split by job#

A compressible multiphase multicomponent LBM splits its distributions by purpose. fif_i carries the flow field, gig_i the energy, hk,ih_{k,i} the kk-th species. All three live on the same lattice velocities ci\mathbf{c}_i and solve the same discrete Boltzmann equation.

hk,i(x+ciδt,  t+δt)−hk,i(x,t)=−1τk[hk,i(x,t)−hk,ieq(x,t)]h_{k,i}(\mathbf{x}+\mathbf{c}_i\delta t,\;t+\delta t)-h_{k,i}(\mathbf{x},t) =-\frac{1}{\tau_k}\Big[h_{k,i}(\mathbf{x},t)-h_{k,i}^{\mathrm{eq}}(\mathbf{x},t)\Big]

hk,ih_{k,i} is the distribution of species kk along direction ii, τk\tau_k is that species' relaxation time, δt\delta t the time step.

The equilibrium needs only the species density ρk\rho_k and the flow velocity u\mathbf{u}. Unlike fif_i, it carries no second-order term, because the species equation only has to recover advection and diffusion.

hk,ieq=ρkwi(1+ci⋅ucs2)h_{k,i}^{\mathrm{eq}}=\rho_k w_i\left(1+\frac{\mathbf{c}_i\cdot\mathbf{u}}{c_s^2}\right)

wiw_i are the lattice weights and csc_s the lattice sound speed. The species density reads ρk=∑ihk,i\rho_k=\sum_i h_{k,i} and the mass fraction Yk=ρk/ρY_k=\rho_k/\rho.

Spread the DA,DB,DCD_A,D_B,D_C sliders and the three diffusivities split, so the mass sum in the lower panel walks off 1.

step 0 — current |sum Y_k - 1| = 0.00e+0, peak = 0.00e+0. Drag the three sliders to the same value and the lower panel flattens to round-off. Spread them apart and the error grows every step: each species relaxes with its own tau, so the three Fickian fluxes no longer cancel. Tick the checkbox to inject the correction velocity and the growth stops on the spot.

Line the three sliders up and the lower panel flattens to round-off. Leave them apart and the error piles up every step. The checkbox is the correction velocity. Turn it on and the sum folds back toward 1.

What Chapman–Enskog pulls out of hkh_k#

Taylor-expand the discrete equation in δt\delta t and split the distribution as hk,i=hk,ieq+ϵhk,i(1)+⋯h_{k,i}=h_{k,i}^{\mathrm{eq}}+\epsilon h_{k,i}^{(1)}+\cdots. Collecting the zeroth moment order by order gives a single-species advection–diffusion equation.

∂tρk+∇⋅(ρku)=∇⋅(Dk∇ρk),Dk=cs2(τk−12)δt\partial_t \rho_k+\nabla\cdot(\rho_k\mathbf{u})=\nabla\cdot\big(D_k\nabla\rho_k\big), \qquad D_k=c_s^2\Big(\tau_k-\tfrac{1}{2}\Big)\delta t

DkD_k is that species' diffusivity. The −1/2-1/2 comes from the discretization, not from physics. The same −1/2-1/2 shows up as viscosity in the flow field and as thermal diffusivity in the energy field. Tie those two ledgers together and the Prandtl number locks to a constant — that story is in the two constants buried in thermal LBM.

Up to here it looks clean. Turn one τk\tau_k and the diffusivity lands wherever you want it. Whether it really does is measured below.

What is left over when the τk\tau_k differ#

The trouble appears not in one species equation but in their sum. Add the equation over kk and use ∑kρk=ρ\sum_k\rho_k=\rho.

∂tρ+∇⋅(ρu)=∇⋅(∑kDk∇ρk)\partial_t \rho+\nabla\cdot(\rho\mathbf{u})=\nabla\cdot\left(\sum_k D_k\nabla\rho_k\right)

The left side is continuity. The right side has to vanish. In general it does not.

∑kDk∇ρk=ρ∑kDk∇Yk≠0\sum_k D_k\nabla\rho_k=\rho\sum_k D_k\nabla Y_k \neq 0

Only when every DkD_k is the same does ∑kDk∇Yk=D∇∑kYk=D∇(1)=0\sum_k D_k\nabla Y_k=D\nabla\sum_k Y_k=D\nabla(1)=0. Giving each species its own τk\tau_k is exactly the act of breaking that cancellation. Written as fluxes it is even plainer: the Fickian fluxes Jk=−ρDk∇Yk\mathbf{J}_k=-\rho D_k\nabla Y_k do not sum to zero.

Fick's law is a binary-mixture approximation, and with three or more species the Stefan–Maxwell relations are the correct closure. LBM inherits the defect wholesale when it transcribes that approximation into one independent τk\tau_k per species.

Two thousand steps of a ternary mixture, and the ledger#

I put three sets of species populations on a D1Q3 lattice. The mixture sits still: ρ=1\rho=1, u=0\mathbf{u}=0. Nothing happens except diffusion, so ∑kρk\sum_k\rho_k must stay at 1 forever. First a decaying sine mode checks whether DkD_k really equals cs2(τk−1/2)c_s^2(\tau_k-1/2), then the defect is measured across several DkD_k combinations.

import numpy as np
 
N, CS2 = 256, 1.0/3.0
W = np.array([2.0/3.0, 1.0/6.0, 1.0/6.0])   # D1Q3 weights: c = 0, +1, -1
C = np.array([0, 1, -1])
 
def feq_species(rho_k, u):
    return rho_k[None, :] * W[:, None] * (1.0 + C[:, None] * u[None, :] / CS2)
 
def correction_velocity(rho_k, D):
    dY = np.array([(np.roll(r, -1) - np.roll(r, 1)) * 0.5 for r in rho_k])
    return np.sum(D[:, None] * dY, axis=0)           # V_c = sum_k D_k grad Y_k
 
def lbm_species_step(h, D, use_correction):
    rho_k = h.sum(axis=1)                            # (K, N)
    u = correction_velocity(rho_k, D) if use_correction else np.zeros(N)
    tau = D / CS2 + 0.5
    for k in range(len(D)):
        h[k] += (feq_species(rho_k[k], u) - h[k]) / tau[k]
        for i in range(3):
            h[k, i] = np.roll(h[k, i], C[i])
    return h
 
def diffusion_leak_run(D, steps, use_correction):
    x = np.arange(N)
    r1 = 0.30 + 0.20 * np.sin(2 * np.pi * x / N)
    r2 = 0.45 + 0.15 * np.cos(4 * np.pi * x / N)
    r3 = 1.0 - r1 - r2                               # the sum is exactly 1 at t = 0
    h = np.array([feq_species(r, np.zeros(N)) for r in [r1, r2, r3]])
    worst = 0.0
    for _ in range(steps):
        h = lbm_species_step(h, D, use_correction)
        worst = max(worst, np.abs(h.sum(axis=(0, 1)) - 1.0).max())
    return worst
 
def measure_D_eff(tau, steps=400):
    x = np.arange(N)
    amp0 = 0.01
    rho = 0.5 + amp0 * np.sin(2 * np.pi * x / N)     # inject one sine mode, measure its decay
    h = feq_species(rho, np.zeros(N))
    for _ in range(steps):
        r = h.sum(axis=0)
        h += (feq_species(r, np.zeros(N)) - h) / tau
        for i in range(3):
            h[i] = np.roll(h[i], C[i])
    amp = np.abs(np.fft.rfft(h.sum(axis=0))[1]) * 2.0 / N
    k2 = (2 * np.pi / N) ** 2
    return -np.log(amp / amp0) / (k2 * steps)
 
print("tau    D_theory=cs^2(tau-1/2)   D_measured   error[%]")
for tau in (0.55, 0.80, 1.20, 2.00):
    dth, dm = CS2 * (tau - 0.5), measure_D_eff(tau)
    print(f"{tau:.2f}   {dth:.6f}                {dm:.6f}     {100*(dm-dth)/dth:+.3f}")
 
print()
print("D1,D2,D3            correction   max|sum(rho_k)-1|")
for D in (np.array([0.05, 0.05, 0.05]),
          np.array([0.05, 0.10, 0.02]),
          np.array([0.01, 0.10, 0.30]),
          np.array([0.005, 0.10, 0.50])):
    for corr in (False, True):
        print(f"{D[0]:.3f},{D[1]:.3f},{D[2]:.3f}     {str(corr):5s}        "
              f"{diffusion_leak_run(D, 2000, corr):.3e}")
tau    D_theory=cs^2(tau-1/2)   D_measured   error[%]
0.55   0.016667                0.016874     +1.243
0.80   0.100000                0.100137     +0.137
1.20   0.233333                0.233122     -0.091
2.00   0.500000                0.498135     -0.373
 
D1,D2,D3            correction   max|sum(rho_k)-1|
0.050,0.050,0.050     False        1.865e-13
0.050,0.050,0.050     True         2.091e-13
0.050,0.100,0.020     False        5.050e-02
0.050,0.100,0.020     True         5.201e-05
0.010,0.100,0.300     False        1.155e-01
0.010,0.100,0.300     True         1.354e-04
0.005,0.100,0.500     False        1.683e-01
0.005,0.100,0.500     True         5.215e-04

Drag a DkD_k slider and the red resultant arrow changes length.

sum_k J_k = 0.00e+0, V_c = 0.0000. With three different D_k the red resultant almost never lands on zero, whatever the gradients — that residue is exactly the mass the solver invents each step. Switch the correction on and the green segments rebalance the three arrows so the resultant collapses to zero for every slider setting.

Whatever you do to the DkD_k and the gradients, the red resultant arrow almost never lands on zero. Tick the checkbox and green segments appear, folding the resultant down to exactly zero.

Read the top table first. Dk=cs2(τk−1/2)D_k=c_s^2(\tau_k-1/2) holds to about 0.1%. The 1.24% outlier at τ=0.55\tau=0.55 is the lattice error that grows as τ\tau approaches 1/21/2. The relation itself can be trusted.

The bottom table is the point. Set all three diffusivities to 0.05 and the defect is 1.9×10−131.9\times10^{-13}, double-precision round-off. Spread them by a factor of 2.5 and it is 5×10−25\times10^{-2}. Spread them by 100 and it reaches 0.168. With ρ=1\rho=1 that is 16.8% of the total mass.

Put cs2=1/3c_s^2=1/3 into Dk=cs2(τk−1/2)D_k=c_s^2(\tau_k-1/2) and the theory column falls out. At τ=0.80\tau=0.80, (0.30)/3=0.100000(0.30)/3=0.100000. The run measures 0.100137, error +0.137%+0.137\%. At τ=2.00\tau=2.00, (1.50)/3=0.500000(1.50)/3=0.500000, measured 0.498135, error −0.373%-0.373\%. The sign flips. At τ=0.55\tau=0.55, (0.05)/3=0.016667(0.05)/3=0.016667 against a measured 0.016874, +1.243%+1.243\%. At τ=1.20\tau=1.20 theory is 0.233333, measured 0.233122, −0.091%-0.091\%. Farther from 1/21/2, the percent error shrinks.

The defect ratios divide straight out of the lower table. For D=(0.050, 0.100, 0.020)D=(0.050,\,0.100,\,0.020), 5.050×10−25.050\times10^{-2} over 5.201×10−55.201\times10^{-5} is 971. For D=(0.005, 0.100, 0.500)D=(0.005,\,0.100,\,0.500), 1.683×10−11.683\times10^{-1} over 5.215×10−45.215\times10^{-4} is 323. A diffusivity spread of 100 buys a smaller correction factor than a spread of 2.5. When all three values sit at 0.05 the defect is 1.865×10−131.865\times10^{-13}, and with the correction on it is 2.091×10−132.091\times10^{-13}. The cancellation already holds, so the correction velocity has nothing to do. Over 2000 steps at ρ=1\rho=1, 0.168 is 16.8% of the mass, and 5.215×10−45.215\times10^{-4} is what is left in that slot.

The one line that turned 0.168 into 0.0005#

The fix has been known for a long time. Add the same correction velocity Vc\mathbf{V}_c to every species so that the fluxes are forced to sum to zero.

Jk=−ρDk∇Yk+ρYkVc,Vc=∑kDk∇Yk\mathbf{J}_k=-\rho D_k\nabla Y_k+\rho Y_k\mathbf{V}_c, \qquad \mathbf{V}_c=\sum_k D_k\nabla Y_k

With that Vc\mathbf{V}_c, ∑kJk=−ρ∑kDk∇Yk+ρVc∑kYk=0\sum_k\mathbf{J}_k=-\rho\sum_k D_k\nabla Y_k+\rho\mathbf{V}_c\sum_k Y_k=0. It uses the constraint ∑kYk=1\sum_k Y_k=1 directly.

In LBM this never becomes a source term. Swap the velocity inside the equilibrium from u\mathbf{u} to u+Vc\mathbf{u}+\mathbf{V}_c and you are done — the correction_velocity line and the use_correction branch above are the entire change. The extra flux that shows up once the velocity rises lives in the leftover term of a convection–diffusion LBM.

The table agrees. A defect of 5.05×10−25.05\times10^{-2} becomes 5.20×10−55.20\times10^{-5}, and 1.68×10−11.68\times10^{-1} becomes 5.22×10−45.22\times10^{-4}: factors of 971 and 323.

Why the correction still does not reach zero#

One thing catches the eye. With equal diffusivities the defect is 1.9×10−131.9\times10^{-13}, but with the correction on it stalls at around 5×10−45\times10^{-4}. That is not machine precision.

The reason is that two different discrete operators are in play. correction_velocity builds ∇Yk\nabla Y_k from a central difference. The diffusive flux LBM actually produces is the first moment of the non-equilibrium part hk,i(1)h_{k,i}^{(1)} — a different discrete gradient, defined by streaming. The two disagree at O(δx2)O(\delta x^2), and that disagreement survives instead of cancelling.

To kill it, take the correction velocity from the same non-equilibrium moment, something like Vc=1ρ∑k(1−12τk)∑icihk,i(1)\mathbf{V}_c=\frac{1}{\rho}\sum_k\big(1-\frac{1}{2\tau_k}\big)\sum_i\mathbf{c}_i h_{k,i}^{(1)}. The central difference also lags by one step, which leaves a residue for the same reason. The broader point — that a discrete constraint transcribed straight from the continuum is not satisfied exactly — is the same kind of gap as the cubic moment defect of the LBM equilibrium.

In practice, though, 5×10−45\times10^{-4} and 1.7×10−11.7\times10^{-1} are very different animals. The first does not accumulate and shrinks under grid refinement. The second grows with time.

Where this term surfaces in a combustion code#

Lewis numbers in methane–air combustion scatter from 0.3 to 1.8. H and H2_2 are extremely fast, large hydrocarbons are slow. In diffusivity that is a spread of more than twenty. The third row of the table above sits right in that range.

The defect does not stop at a density error. Reaction rates depend on YkY_k exponentially. If the sum exceeds 1 the rate is overestimated, that heat pushes the density back, and the run diverges. Many codes renormalize by dividing YkY_k by the sum every step, but that does not remove the defect — it redistributes it arbitrarily among species. Which species eats the loss depends on the node index.

The most common production remedy is to nominate the heaviest species as a bath species and compute YN2=1−∑k≠N2YkY_{N_2}=1-\sum_{k\neq N_2}Y_k directly. The sum is then exactly 1, but the whole defect piles onto N2_2. The correction velocity splits that burden in proportion to YkY_k.

Dump the defect on N2_2 and the ledger balances on the wrong species#

Hand each species its own τk\tau_k and three paths open. Leave the defect where it is, divide by the sum and pile it on one species, or split it with a correction velocity in proportion to YkY_k. Leaving it is not a path. Piling it on one species only holds when that bath species is heavy, dilute combustion with YN2≈0.7Y_{N_2}\approx 0.7. The default is the correction velocity. The cost is one set of gradients per step and one swapped velocity in the equilibrium.

The range of τ\tau locks at the same time. The table shows a diffusivity error of +1.243%+1.243\% at τ=0.55\tau=0.55 and −0.373%-0.373\% at τ=2.00\tau=2.00. The sign flips. That floor is the same place as the half-step that misreads viscosity by a factor of 51 at τ=0.51\tau=0.51. Along the species axis the floor gets laid down once more. When a log prints ∑kYk=1.0003\sum_k Y_k=1.0003, line the DkD_k up first. If they differ, the cause is already on that line.

Share if you found it helpful.