Lattice Boltzmann flow on the CPU: model, solver, engine (M12) (#36)

Model: Newtonian fluid materials; fluid domains (box, spacing, conditions on
the box faces, obstacle bodies with conditions on their named faces, initial
velocity, body acceleration); fluid steps with caps. Deck writers refuse
fluid steps; a fluid-only model needs no mesh in model.check; one step
selector (Model::select_step) for every solver.

New L2 crate modelcraft-lbm: D3Q19 two-relaxation-time collision with Guo
forcing; halfway bounce-back walls (moving walls after Ladd), slip faces,
periodic pairs; Zou–He velocity and pressure faces with the D3Q19
transverse correction; obstacles voxelised from the face-tagged boundary
surface with exact predicates and symbolic perturbation; momentum-exchange
forces per box face and per B-rep face; unit conversion, automatic time step
and Mach/tau limits; bounded runs with frames and force histories.

Engine: model.fluid_domain.create/edit/delete, model.fluid_domain.boundary,
solve.fluid (obstacles from the open design or imported surfaces, lowerable
caps), solve.probe for fluid fields and wall force histories. Fluid Domain,
Inlet & Outlet and Lattice Boltzmann toolbar entries are ready.

Verification (analytic): plane Poiseuille exact with Λ = 3/16 and second
order (2.000) with Λ = 1/4; Couette exact with the wall shear; Taylor–Green
decay second order (2.01, 2.00); staircase pipe; Stokes drag of a sphere
array within 1.7% of Hasimoto at R = 6; Zou–He channel; mass conserved to
round-off; bit-for-bit reproducibility. Benchmark: Schäfer–Turek 2D-1.


Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-13 20:03:12 -04:00
1 parent bd55abea7f
commit a17752a49f
55 files changed
+3800 -64

No files matched your search

+141
View File
@@ -0,0 +1,141 @@
//! The D3Q19 velocity set (Qian, d'Humières & Lallemand 1992; Krüger et al. 2017, ch. 3).
//!
//! Directions come in opposite pairs: 1–2, 3–4, …, 17–18, so the opposite of an odd `q` is
//! `q + 1` and of an even `q` (not 0) is `q − 1`.
/// The number of velocities.
pub const Q: usize = 19;
/// The lattice velocities `c_q` in lattice units.
pub const C: [[i32; 3]; Q] = [
[0, 0, 0],
[1, 0, 0],
[-1, 0, 0],
[0, 1, 0],
[0, -1, 0],
[0, 0, 1],
[0, 0, -1],
[1, 1, 0],
[-1, -1, 0],
[1, -1, 0],
[-1, 1, 0],
[1, 0, 1],
[-1, 0, -1],
[1, 0, -1],
[-1, 0, 1],
[0, 1, 1],
[0, -1, -1],
[0, 1, -1],
[0, -1, 1],
];
/// The weights `w_q`: 1/3 at rest, 1/18 along the axes, 1/36 along the face diagonals.
pub const W: [f64; Q] = [
1.0 / 3.0,
1.0 / 18.0,
1.0 / 18.0,
1.0 / 18.0,
1.0 / 18.0,
1.0 / 18.0,
1.0 / 18.0,
1.0 / 36.0,
1.0 / 36.0,
1.0 / 36.0,
1.0 / 36.0,
1.0 / 36.0,
1.0 / 36.0,
1.0 / 36.0,
1.0 / 36.0,
1.0 / 36.0,
1.0 / 36.0,
1.0 / 36.0,
1.0 / 36.0,
];
/// The squared speed of sound in lattice units, `c_s² = 1/3`.
pub const CS2: f64 = 1.0 / 3.0;
/// The direction opposite `q`.
#[inline(always)]
pub const fn opposite(q: usize) -> usize {
if q == 0 {
0
} else if q % 2 == 1 {
q + 1
} else {
q - 1
}
}
/// `c_q` as floating-point numbers.
#[inline(always)]
pub fn c(q: usize) -> [f64; 3] {
let v = C.get(q).copied().unwrap_or([0; 3]);
[f64::from(v[0]), f64::from(v[1]), f64::from(v[2])]
}
/// The second-order equilibrium `w_q ρ (1 + 3 c·u + 9/2 (c·u)² − 3/2 u²)`.
#[inline(always)]
pub fn equilibrium(q: usize, rho: f64, u: [f64; 3]) -> f64 {
let c = c(q);
let cu = c[0] * u[0] + c[1] * u[1] + c[2] * u[2];
let uu = u[0] * u[0] + u[1] * u[1] + u[2] * u[2];
W.get(q).copied().unwrap_or(0.0) * rho * (1.0 + 3.0 * cu + 4.5 * cu * cu - 1.5 * uu)
}
/// Ladd's moving-wall term for a population bounced back into direction `q` off a wall moving
/// with `u` (lattice units, reference density 1): `2 w_q c_q·u / c_s²` (Ladd 1994).
#[inline(always)]
pub fn moving_wall(q: usize, u: [f64; 3]) -> f64 {
let c = c(q);
2.0 * W.get(q).copied().unwrap_or(0.0) * (c[0] * u[0] + c[1] * u[1] + c[2] * u[2]) / CS2
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn moments_of_the_velocity_set() {
// Σ w = 1, Σ w c = 0, Σ w c_a c_b = c_s² δ_ab, and opposite directions pair up.
let mut m0 = 0.0;
let mut m1 = [0.0; 3];
let mut m2 = [[0.0; 3]; 3];
for q in 0..Q {
let c = c(q);
m0 += W[q];
for a in 0..3 {
m1[a] += W[q] * c[a];
for b in 0..3 {
m2[a][b] += W[q] * c[a] * c[b];
}
}
let o = opposite(q);
assert_eq!(opposite(o), q);
assert_eq!([C[o][0], C[o][1], C[o][2]], [-C[q][0], -C[q][1], -C[q][2]]);
assert_eq!(W[o], W[q]);
}
assert!((m0 - 1.0f64).abs() < 1e-15);
for a in 0..3 {
assert!(m1[a].abs() < 1e-15);
for b in 0..3 {
let want = if a == b { CS2 } else { 0.0 };
assert!((m2[a][b] - want).abs() < 1e-15);
}
}
// The equilibrium carries the density and momentum it is built from.
let (rho, u) = (1.03, [0.02, -0.01, 0.005]);
let (mut r, mut j) = (0.0, [0.0; 3]);
for q in 0..Q {
let f = equilibrium(q, rho, u);
r += f;
for a in 0..3 {
j[a] += f * c(q)[a];
}
}
assert!((r - rho).abs() < 1e-15);
for a in 0..3 {
assert!((j[a] - rho * u[a]).abs() < 1e-15);
}
}
}