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

+67
View File
@@ -0,0 +1,67 @@
//! Benchmark suite: comparisons with published numerical reference values (not analytic; the
//! analytic suite in `verification.rs` is the gate). Each case names its source. The cases are
//! long, so they are ignored by default:
//! `cargo test --release -p modelcraft-lbm --test benchmark -- --ignored --nocapture`.
mod common;
use common::*;
use modelcraft_lbm::{Limits, solve_fluid};
use modelcraft_model::{FaceCondition, Procedure, Profile};
/// Benchmark "2D-1" (steady flow around a cylinder at Re = 20): M. Schäfer, S. Turek, *Benchmark
/// computations of laminar flow around a cylinder*, in: Flow Simulation with High-Performance
/// Computers II, NNFM 52:547–566, Vieweg 1996, DOI 10.1007/978-3-322-89849-4_39.
/// Channel 2.2 × 0.41, cylinder of diameter 0.1 centred at (0.2, 0.2), parabolic inlet with peak
/// 0.3 (mean 0.2), ν = 10⁻³, ρ = 1. Reference intervals: drag coefficient 5.57–5.59, lift
/// coefficient 0.0104–0.0110, pressure difference between the cylinder's front and back
/// 0.1172–0.1176. Run quasi-2D: one periodic cell across z.
/// Returns (c_D, c_L, Δp).
fn schafer_turek_2d1(dx: f64, time_step: Option<f64>) -> (f64, f64, f64) {
let (h, l, d, um, nu) = (0.41, 2.2, 0.1, 0.3, 1e-3);
let mut dom = domain([0.0; 3], [l, h, dx], dx);
dom.faces.x_min = FaceCondition::Velocity { velocity: [um, 0.0, 0.0], profile: Profile::Parabolic };
dom.faces.x_max = FaceCondition::Pressure { pressure: 0.0 };
dom.faces.z_min = FaceCondition::Periodic {};
dom.faces.z_max = FaceCondition::Periodic {};
dom.obstacles = vec!["Cylinder".into()];
let cyl = obstacle("Cylinder", cylinder([0.2, 0.2], d / 2.0, -0.1, 0.1, 1024), &["side", "bottom", "top"]);
let procedure = Procedure::Fluid { domain: "Flow".into(), end_time: Some(10.0), steps: None, time_step, frames: 1, magic: None };
let m = model(1.0, nu, dom, procedure);
let r = solve_fluid(&m, None, std::slice::from_ref(&cyl), &Limits::default()).unwrap();
let side = r.forces.iter().find(|f| f.name == "side").unwrap();
// The mean over the last tenth of the run, per unit depth.
let n = side.force.len();
let tail = &side.force[n - n / 10..];
let mean = |a: usize| tail.iter().map(|v| v[a]).sum::<f64>() / tail.len() as f64 / dx;
let mean_u = 2.0 / 3.0 * um;
let coefficient = |f: f64| 2.0 * f / (mean_u * mean_u * d);
// Pressure in the fluid cells just in front of and behind the cylinder on its axis
// (the average of the two cell rows about y = 0.2).
let p = |x: f64| {
let at = |y: f64| f64::from(r.probe(1, [x, y, 0.5 * dx]).unwrap().2);
0.5 * (at(0.2 - 0.5 * dx) + at(0.2 + 0.5 * dx))
};
let dp = p(0.15 - 0.5 * dx) - p(0.25 + 0.5 * dx);
println!("Schäfer–Turek 2D-1, Δx = {dx}: {:?}; warnings {:?}", r.stats, r.warnings);
(coefficient(mean(0)), coefficient(mean(1)), dp)
}
#[test]
#[ignore]
fn schafer_turek_2d1_cylinder_drag_and_lift() {
// Lattice velocity 0.025 (Mach 0.043): the inlet's pressure, about 0.3, changes the density
// by Δp / (ρ c_s²) in lattice units, so the time step shrinks with the spacing squared.
for dx in [0.01, 0.005] {
let dt = 0.025 * dx / 0.3;
let (cd, cl, dp) = schafer_turek_2d1(dx, Some(dt * (dx / 0.01)));
println!("Schäfer–Turek 2D-1, D/Δx = {}: c_D = {cd:.4} (5.57–5.59), c_L = {cl:.4} (0.0104–0.0110), Δp = {dp:.4} (0.1172–0.1176)", 0.1 / dx);
if dx <= 0.005 {
// A staircase cylinder of 20 cells (measured: c_D 5.773, +3.4%; c_L 0.0107, inside
// the interval). Δp is read in the cells half a spacing off the staircase, where the
// pressure changes fast, so it is reported, not asserted (measured 0.108).
assert!((cd / 5.58 - 1.0).abs() < 0.05, "{cd}");
assert!((cl / 0.0107 - 1.0).abs() < 0.2, "{cl}");
}
}
}
+219
View File
@@ -0,0 +1,219 @@
//! Shared helpers for the lattice Boltzmann tests: models, analytic shapes generated in code, and
//! running a flow to a time.
#![allow(dead_code)]
use std::collections::BTreeMap;
use modelcraft_lbm::{Flow, Limits, Obstacle, Setup};
use modelcraft_mesh::surface::{BoundarySurface, SurfaceOptions};
use modelcraft_model::{BoxFaces, Fluid, FluidDomain, Material, Model, Procedure, Step};
/// A domain over [min, max] with walls on every face.
pub fn domain(min: [f64; 3], max: [f64; 3], spacing: f64) -> FluidDomain {
FluidDomain {
material: "Fluid".into(),
min,
max,
spacing,
faces: BoxFaces::default(),
obstacles: vec![],
surfaces: vec![],
initial_velocity: [0.0; 3],
acceleration: [0.0; 3],
tolerance: None,
}
}
/// A fluid step `Run` over `end_time` with a given time step.
pub fn step(end_time: f64, time_step: f64, magic: Option<f64>) -> Procedure {
Procedure::Fluid { domain: "Flow".into(), end_time: Some(end_time), steps: None, time_step: Some(time_step), frames: 2, magic }
}
/// A model with fluid `Fluid` (density ρ, kinematic viscosity ν), domain `Flow` and step `Run`.
pub fn model(density: f64, nu: f64, domain: FluidDomain, procedure: Procedure) -> Model {
let mut m = Model::default();
m.add_material("Fluid", Material { density: Some(density), fluid: Some(Fluid::Newtonian { viscosity: nu * density }), ..Material::default() })
.unwrap();
m.add_fluid_domain("Flow", domain).unwrap();
m.add_step(Step::new("Run", procedure), None).unwrap();
m
}
/// The setup and the flow at its initial state.
pub fn prepare(m: &Model, obstacles: &[Obstacle]) -> (Setup, Flow) {
let setup = Setup::new(m, None, obstacles, &Limits::default()).unwrap();
let flow = Flow::new(&setup, obstacles).unwrap();
(setup, flow)
}
/// Run every step of the setup.
pub fn run_all(setup: &Setup, flow: &mut Flow) {
for _ in 0..setup.steps {
flow.step().unwrap();
}
}
/// The convergence order of errors at resolutions growing by `ratio` each.
pub fn orders(errors: &[f64], ratio: f64) -> Vec<f64> {
errors.windows(2).map(|w| (w[0] / w[1]).ln() / ratio.ln()).collect()
}
/// A closed surface from triangles, each turned to face `outward(centroid)`.
pub fn surface(
positions: Vec<[f64; 3]>,
mut triangles: Vec<[u32; 3]>,
tags: Vec<u32>,
outward: impl Fn([f64; 3], u32) -> [f64; 3],
) -> BoundarySurface {
for (t, tag) in triangles.iter_mut().zip(&tags) {
let [a, b, c] = t.map(|v| positions[v as usize]);
let (u, v) = ([b[0] - a[0], b[1] - a[1], b[2] - a[2]], [c[0] - a[0], c[1] - a[1], c[2] - a[2]]);
let n = [u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]];
let centroid = [0, 1, 2].map(|k| (a[k] + b[k] + c[k]) / 3.0);
let o = outward(centroid, *tag);
if n[0] * o[0] + n[1] * o[1] + n[2] * o[2] < 0.0 {
t.swap(1, 2);
}
}
BoundarySurface::new(&positions, &triangles, &tags, &SurfaceOptions::default()).unwrap()
}
/// A sphere of radius `r` about `centre`: an icosahedron subdivided `levels` times, one face.
pub fn sphere(centre: [f64; 3], r: f64, levels: u32) -> BoundarySurface {
let t = (1.0 + 5f64.sqrt()) / 2.0;
let mut unit: Vec<[f64; 3]> = vec![
[-1.0, t, 0.0],
[1.0, t, 0.0],
[-1.0, -t, 0.0],
[1.0, -t, 0.0],
[0.0, -1.0, t],
[0.0, 1.0, t],
[0.0, -1.0, -t],
[0.0, 1.0, -t],
[t, 0.0, -1.0],
[t, 0.0, 1.0],
[-t, 0.0, -1.0],
[-t, 0.0, 1.0],
];
let normalise = |p: [f64; 3]| {
let l = (p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt();
p.map(|c| c / l)
};
unit = unit.into_iter().map(normalise).collect();
let mut tris: Vec<[u32; 3]> = vec![
[0, 11, 5],
[0, 5, 1],
[0, 1, 7],
[0, 7, 10],
[0, 10, 11],
[1, 5, 9],
[5, 11, 4],
[11, 10, 2],
[10, 7, 6],
[7, 1, 8],
[3, 9, 4],
[3, 4, 2],
[3, 2, 6],
[3, 6, 8],
[3, 8, 9],
[4, 9, 5],
[2, 4, 11],
[6, 2, 10],
[8, 6, 7],
[9, 8, 1],
];
for _ in 0..levels {
let mut mid: BTreeMap<(u32, u32), u32> = BTreeMap::new();
let mut next = Vec::with_capacity(tris.len() * 4);
for [a, b, c] in tris {
let mut m = |p: u32, q: u32| {
*mid.entry((p.min(q), p.max(q))).or_insert_with(|| {
let (x, y) = (unit[p as usize], unit[q as usize]);
unit.push(normalise([(x[0] + y[0]) / 2.0, (x[1] + y[1]) / 2.0, (x[2] + y[2]) / 2.0]));
(unit.len() - 1) as u32
})
};
let (ab, bc, ca) = (m(a, b), m(b, c), m(c, a));
next.extend([[a, ab, ca], [b, bc, ab], [c, ca, bc], [ab, bc, ca]]);
}
tris = next;
}
let positions: Vec<[f64; 3]> = unit.iter().map(|p| [centre[0] + r * p[0], centre[1] + r * p[1], centre[2] + r * p[2]]).collect();
let tags = vec![0; tris.len()];
surface(positions, tris, tags, |p, _| [p[0] - centre[0], p[1] - centre[1], p[2] - centre[2]])
}
/// A tube along x from `x0` to `x1` about the axis through (`y`, `z`): inner radius `r`, outer
/// radius `outer`, `m` segments. Faces: 0 inner wall, 1 outer wall, 2 and 3 the ends.
pub fn tube(x0: f64, x1: f64, axis: [f64; 2], r: f64, outer: f64, m: usize) -> BoundarySurface {
let mut positions = Vec::new();
for x in [x0, x1] {
for radius in [r, outer] {
for k in 0..m {
let phi = 2.0 * std::f64::consts::PI * k as f64 / m as f64;
positions.push([x, axis[0] + radius * phi.cos(), axis[1] + radius * phi.sin()]);
}
}
}
// Ring index: end e (0, 1), radius kind k (0 inner, 1 outer), segment s.
let id = |e: usize, k: usize, s: usize| ((e * 2 + k) * m + s % m) as u32;
let mut tris = Vec::new();
let mut tags = Vec::new();
for s in 0..m {
for k in 0..2 {
tris.push([id(0, k, s), id(1, k, s), id(1, k, s + 1)]);
tris.push([id(0, k, s), id(1, k, s + 1), id(0, k, s + 1)]);
tags.extend([k as u32; 2]);
}
for e in 0..2 {
tris.push([id(e, 0, s), id(e, 1, s), id(e, 1, s + 1)]);
tris.push([id(e, 0, s), id(e, 1, s + 1), id(e, 0, s + 1)]);
tags.extend([2 + e as u32; 2]);
}
}
surface(positions, tris, tags, move |p, tag| {
let radial = [0.0, p[1] - axis[0], p[2] - axis[1]];
match tag {
0 => radial.map(|c| -c),
1 => radial,
2 => [-1.0, 0.0, 0.0],
_ => [1.0, 0.0, 0.0],
}
})
}
/// A circular cylinder along z from `z0` to `z1` about (`x`, `y`), radius `r`, `m` segments.
/// Faces: 0 the side, 1 the bottom, 2 the top.
pub fn cylinder(centre: [f64; 2], r: f64, z0: f64, z1: f64, m: usize) -> BoundarySurface {
let mut positions = Vec::new();
for z in [z0, z1] {
for k in 0..m {
let phi = 2.0 * std::f64::consts::PI * k as f64 / m as f64;
positions.push([centre[0] + r * phi.cos(), centre[1] + r * phi.sin(), z]);
}
}
positions.push([centre[0], centre[1], z0]);
positions.push([centre[0], centre[1], z1]);
let id = |e: usize, s: usize| (e * m + s % m) as u32;
let (bottom, top) = ((2 * m) as u32, (2 * m + 1) as u32);
let mut tris = Vec::new();
let mut tags = Vec::new();
for s in 0..m {
tris.push([id(0, s), id(0, s + 1), id(1, s + 1)]);
tris.push([id(0, s), id(1, s + 1), id(1, s)]);
tags.extend([0, 0]);
tris.push([bottom, id(0, s + 1), id(0, s)]);
tags.push(1);
tris.push([top, id(1, s), id(1, s + 1)]);
tags.push(2);
}
surface(positions, tris, tags, move |p, tag| match tag {
0 => [p[0] - centre[0], p[1] - centre[1], 0.0],
1 => [0.0, 0.0, -1.0],
_ => [0.0, 0.0, 1.0],
})
}
pub fn obstacle(body: &str, surface: BoundarySurface, faces: &[&str]) -> Obstacle {
Obstacle { body: body.into(), surface, faces: faces.iter().map(|s| s.to_string()).collect() }
}
+44
View File
@@ -0,0 +1,44 @@
//! Throughput of the solver (ignored; run with
//! `cargo test --release -p modelcraft-lbm --test throughput -- --ignored --nocapture`).
mod common;
use common::*;
use modelcraft_model::{BoxFace, FaceCondition};
#[test]
#[ignore]
fn cell_updates_per_second() {
for (name, l, sphere_radius) in [("periodic box", 64usize, 0.0), ("periodic box", 60, 0.0), ("box with a sphere", 64, 16.0)] {
let side = l as f64;
let mut d = domain([0.0; 3], [side; 3], 1.0);
for f in BoxFace::ALL {
*d.faces.get_mut(f) = FaceCondition::Periodic {};
}
d.acceleration = [1e-6, 0.0, 0.0];
let mut obstacles = Vec::new();
if sphere_radius > 0.0 {
d.obstacles = vec!["Ball".into()];
obstacles.push(obstacle("Ball", sphere([side / 2.0; 3], sphere_radius, 4), &["skin"]));
}
let steps = 100;
let m = model(
1.0,
1.0 / 6.0,
d,
modelcraft_model::Procedure::Fluid {
domain: "Flow".into(),
end_time: None,
steps: Some(steps),
time_step: Some(1.0),
frames: 1,
magic: None,
},
);
let (setup, mut flow) = prepare(&m, &obstacles);
let start = std::time::Instant::now();
run_all(&setup, &mut flow);
let s = start.elapsed().as_secs_f64();
println!("{name}, {l}³: {:.1} M cell updates/s", (flow.fluid_cells() as f64 * steps as f64) / s / 1e6);
}
}
+383
View File
@@ -0,0 +1,383 @@
//! Verification of the lattice Boltzmann solver against analytic solutions: plane Poiseuille
//! (exact with Λ = 3/16, second order otherwise), Couette (exact, with the wall shear force),
//! Taylor–Green vortex decay (second order), Poiseuille pipe flow on a staircase wall, Stokes drag
//! of a periodic array of spheres (Hasimoto 1959), inlet/outlet channel flow, mass conservation
//! to round-off, and the stability limits.
//!
//! Each test prints its errors and measured orders (`cargo test -p modelcraft-lbm -- --nocapture`).
mod common;
use common::*;
use modelcraft_lbm::{Error, Limits, Setup, solve_fluid};
use modelcraft_model::{FaceCondition, Profile};
const PERIODIC: FaceCondition = FaceCondition::Periodic {};
fn sci(v: &[f64]) -> String {
v.iter().map(|x| format!("{x:.3e}")).collect::<Vec<_>>().join(", ")
}
/// Plane Poiseuille flow between walls at y = 0 and y = 1, driven by a body force; periodic in x
/// and z (one cell each). τ = 1; returns the largest error of the velocity over its peak.
fn plane_poiseuille(n: usize, magic: f64) -> f64 {
let (h, nu, umax) = (1.0, 1.0 / 6.0, 0.1);
let dx = h / n as f64;
let a = 8.0 * nu * umax / (h * h);
let mut d = domain([0.0; 3], [dx, h, dx], dx);
d.faces.x_min = PERIODIC;
d.faces.x_max = PERIODIC;
d.faces.z_min = PERIODIC;
d.faces.z_max = PERIODIC;
d.acceleration = [a, 0.0, 0.0];
let m = model(1.0, nu, d, step(4.0 * h * h / nu, dx * dx, Some(magic)));
let (setup, mut flow) = prepare(&m, &[]);
assert!((setup.tau_plus - 1.0).abs() < 1e-12);
run_all(&setup, &mut flow);
let mut err: f64 = 0.0;
for j in 0..n {
let y = (j as f64 + 0.5) * dx;
let exact = a / (2.0 * nu) * y * (h - y);
let (_, u) = flow.moments(j).unwrap();
err = err.max((u[0] * setup.velocity_scale() - exact).abs() / umax);
assert!(u[1].abs() < 1e-14 && u[2].abs() < 1e-14);
}
err
}
#[test]
fn plane_poiseuille_is_exact_with_the_magic_parameter_and_second_order_otherwise() {
// Λ = 3/16: halfway bounce-back is exact for the parabola (Ginzburg et al. 2008).
for n in [4, 8, 16] {
let e = plane_poiseuille(n, 3.0 / 16.0);
println!("Poiseuille channel, Λ = 3/16, {n} cells: max error {e:.2e}");
assert!(e < 1e-10, "{n}: {e}");
}
// Λ = 1/4 (BGK at τ = 1): the wall moves off the halfway point by O(Δx²).
let errors: Vec<f64> = [8, 16, 32, 64].iter().map(|&n| plane_poiseuille(n, 0.25)).collect();
let p = orders(&errors, 2.0);
println!("Poiseuille channel, Λ = 1/4, 8 → 64 cells: errors {}, orders {p:.3?}", sci(&errors));
assert!(errors[0] > 1e-6, "the error is real at Λ = 1/4");
for o in p {
assert!((o - 2.0).abs() < 0.05, "second order: {o}");
}
}
#[test]
fn couette_flow_is_linear_and_the_wall_shear_is_mu_u_over_h() {
let (h, nu, rho, u_wall, n) = (1.0, 1.0 / 6.0, 2.0, 0.1, 16);
let dx = h / n as f64;
let mut d = domain([0.0; 3], [dx, h, dx], dx);
d.faces.x_min = PERIODIC;
d.faces.x_max = PERIODIC;
d.faces.z_min = PERIODIC;
d.faces.z_max = PERIODIC;
d.faces.y_max = FaceCondition::MovingWall { velocity: [u_wall, 0.0, 0.0] };
let m = model(rho, nu, d, step(5.0 * h * h / nu, dx * dx, None));
let (setup, mut flow) = prepare(&m, &[]);
run_all(&setup, &mut flow);
let mut err: f64 = 0.0;
for j in 0..n {
let y = (j as f64 + 0.5) * dx;
let (_, u) = flow.moments(j).unwrap();
err = err.max((u[0] * setup.velocity_scale() - u_wall * y / h).abs() / u_wall);
}
// The shear stress μ U / H on each wall, over its area Δx · Δx.
let tau = rho * nu * u_wall / h * dx * dx;
let g = |name: &str| flow.groups().iter().position(|g| g.name == name).unwrap();
let fs = setup.force_scale();
let bottom = flow.forces()[g("y_min")][0] * fs;
let top = flow.forces()[g("y_max")][0] * fs;
println!("Couette, {n} cells: max error {err:.2e}; wall shear {bottom:.6e} and {top:.6e}, expected ±{tau:.6e}");
assert!(err < 1e-10, "{err}");
assert!((bottom - tau).abs() < 1e-6 * tau, "{bottom} vs {tau}");
assert!((top + tau).abs() < 1e-6 * tau, "{top} vs {tau}");
}
/// The decaying Taylor–Green vortex on [0, 2π]², periodic: returns the relative error of the
/// velocity amplitude after its half-life, and the relative change of the total mass.
fn taylor_green(n: usize) -> (f64, f64) {
let (nu, u0, l) = (0.1, 0.1, 2.0 * std::f64::consts::PI);
let dx = l / n as f64;
let mut d = domain([0.0; 3], [l, l, dx], dx);
for f in modelcraft_model::BoxFace::ALL {
*d.faces.get_mut(f) = PERIODIC;
}
// τ = 0.8: Δt = 0.3 Δx² / (3 ν).
let dt = 0.3 * dx * dx / (3.0 * nu);
let half_life = std::f64::consts::LN_2 / (2.0 * nu);
let m = model(1.0, nu, d, step(half_life, dt, None));
let (setup, mut flow) = prepare(&m, &[]);
let grid = *flow.grid();
let mode = |c: usize| {
let [x, y, _] = grid.centre(c);
[-x.cos() * y.sin(), x.sin() * y.cos()]
};
let to_lattice = 1.0 / setup.velocity_scale();
flow.set_equilibrium(|c| {
let [x, y, _] = grid.centre(c);
let p = -u0 * u0 / 4.0 * ((2.0 * x).cos() + (2.0 * y).cos());
let m = mode(c);
(setup.lattice_density(p), [u0 * m[0] * to_lattice, u0 * m[1] * to_lattice, 0.0])
});
let mass0 = flow.mass();
run_all(&setup, &mut flow);
let (mut num, mut den) = (0.0, 0.0);
for c in 0..grid.len() {
let (_, u) = flow.moments(c).unwrap();
let m = mode(c);
num += u[0] * m[0] + u[1] * m[1];
den += m[0] * m[0] + m[1] * m[1];
}
let amplitude = num / den * setup.velocity_scale();
let t = setup.steps as f64 * setup.time_step;
let exact = u0 * (-2.0 * nu * t).exp();
((amplitude / exact - 1.0).abs(), (flow.mass() - mass0).abs() / mass0)
}
#[test]
fn taylor_green_vortex_decays_at_the_viscous_rate_to_second_order_and_conserves_mass() {
let runs: Vec<(f64, f64)> = [16, 32, 64].iter().map(|&n| taylor_green(n)).collect();
let errors: Vec<f64> = runs.iter().map(|r| r.0).collect();
let p = orders(&errors, 2.0);
println!(
"Taylor–Green, 16 → 64 cells: amplitude errors {}, orders {p:.3?}; mass drift {}",
sci(&errors),
sci(&runs.iter().map(|r| r.1).collect::<Vec<_>>())
);
assert!(errors[2] < 2e-3, "{errors:?}");
for o in p {
assert!(o > 1.8, "second order: {o}");
}
for (_, drift) in runs {
assert!(drift < 1e-13, "mass is conserved to round-off: {drift}");
}
}
/// Poiseuille flow in a pipe of radius 1 along x (a tube obstacle, staircase wall), driven by a
/// body force; `k` cells per radius. Returns the relative L2 error of the velocity over the pipe's
/// cross-section and the relative error of the flow rate.
fn pipe(k: usize) -> (f64, f64) {
let (r, nu, umax) = (1.0, 1.0 / 6.0, 0.1);
let dx = r / k as f64;
let half = r + 2.0 * dx;
let mut d = domain([0.0, -half, -half], [dx, half, half], dx);
d.faces.x_min = PERIODIC;
d.faces.x_max = PERIODIC;
let a = 4.0 * nu * umax / (r * r);
d.acceleration = [a, 0.0, 0.0];
d.obstacles = vec!["Tube".into()];
let tube = obstacle("Tube", tube(-1.0, dx + 1.0, [0.0, 0.0], r, 3.0 * half, 512), &["inner", "outer", "start", "end"]);
let m = model(1.0, nu, d, step(5.0 * r * r / nu, dx * dx, None));
let (setup, mut flow) = prepare(&m, std::slice::from_ref(&tube));
run_all(&setup, &mut flow);
let grid = *flow.grid();
let (mut e2, mut u2, mut q) = (0.0, 0.0, 0.0);
for c in 0..grid.len() {
let [_, y, z] = grid.centre(c);
let rr = y * y + z * z;
let exact = if rr < r * r { a / (4.0 * nu) * (r * r - rr) } else { 0.0 };
let u = flow.moments(c).map_or(0.0, |(_, u)| u[0] * setup.velocity_scale());
e2 += (u - exact) * (u - exact);
u2 += exact * exact;
q += u * dx * dx;
}
let q_exact = std::f64::consts::PI * a * r.powi(4) / (8.0 * nu);
// Momentum balance: the wall takes the whole driving force.
let wall: f64 = flow.groups().iter().zip(flow.forces()).filter(|(g, _)| g.body.is_some()).map(|(_, f)| f[0]).sum::<f64>() * setup.force_scale();
let drive = a * flow.fluid_cells() as f64 * dx.powi(3);
assert!((wall - drive).abs() < 1e-6 * drive, "{wall} vs {drive}");
((e2 / u2).sqrt(), (q / q_exact - 1.0).abs())
}
#[test]
fn pipe_poiseuille_on_a_staircase_wall_converges() {
let runs: Vec<(f64, f64)> = [4, 8, 16].iter().map(|&k| pipe(k)).collect();
let l2: Vec<f64> = runs.iter().map(|r| r.0).collect();
let flow: Vec<f64> = runs.iter().map(|r| r.1).collect();
println!(
"Poiseuille pipe, 4 → 16 cells per radius: L2 errors {} (orders {:.2?}), flow-rate errors {} (orders {:.2?})",
sci(&l2),
orders(&l2, 2.0),
sci(&flow),
orders(&flow, 2.0)
);
assert!(l2[2] < l2[0] && l2[2] < 0.05, "{l2:?}");
}
/// The Stokes drag coefficient K = F / (6 π μ R U) of a simple cubic array of spheres at volume
/// fraction c (H. Hasimoto, JFM 5(2):317–328, 1959, with the higher terms of A. S. Sangani,
/// A. Acrivos, Int. J. Multiphase Flow 8(4):343–360, 1982), U the mean velocity over the cell.
fn hasimoto(c: f64) -> f64 {
1.0 / (1.0 - 1.7601 * c.cbrt() + c - 1.5593 * c * c + 3.9799 * c.powf(8.0 / 3.0) - 3.0734 * c.powf(10.0 / 3.0))
}
/// A sphere of radius `r` cells at the centre of a periodic box of `l` cells, the fluid driven by
/// a small body force (Re ≪ 1); returns the measured K over Hasimoto's, and the relative mass
/// drift.
fn sphere_drag(r: f64, l: usize, steps: u64) -> (f64, f64) {
// τ = 3: with Λ = 3/16 the steady solution does not depend on τ, and a high viscosity
// reaches it sooner (the mean flow relaxes over ρ V / (6 π μ R K) steps).
let (nu, a) = (5.0 / 6.0, 1e-6);
let side = l as f64;
let mut d = domain([0.0; 3], [side; 3], 1.0);
for f in modelcraft_model::BoxFace::ALL {
*d.faces.get_mut(f) = PERIODIC;
}
d.acceleration = [a, 0.0, 0.0];
d.obstacles = vec!["Ball".into()];
let ball = obstacle("Ball", sphere([side / 2.0; 3], r, 4), &["skin"]);
let m = model(
1.0,
nu,
d,
modelcraft_model::Procedure::Fluid {
domain: "Flow".into(),
end_time: None,
steps: Some(steps),
time_step: Some(1.0),
frames: 1,
magic: None,
},
);
let (setup, mut flow) = prepare(&m, std::slice::from_ref(&ball));
let mass0 = flow.mass();
run_all(&setup, &mut flow);
let cells = (l * l * l) as f64;
let u: f64 = (0..l * l * l).filter_map(|c| flow.moments(c)).map(|(_, u)| u[0]).sum::<f64>() / cells;
let skin = flow.groups().iter().position(|g| g.name == "skin").unwrap();
let force = flow.forces()[skin][0];
let fluid = flow.fluid_cells() as f64;
// Momentum balance at steady state.
assert!((force - a * fluid).abs() < 1e-3 * a * fluid, "{force} vs {}", a * fluid);
// The array's drag per sphere is the driving force over the whole cell (the body force acts
// on the fluid only, so the measured force is that times the fluid fraction).
let k = force * cells / fluid / (6.0 * std::f64::consts::PI * nu * r * u);
let c = 4.0 / 3.0 * std::f64::consts::PI * r.powi(3) / cells;
let re = u * 2.0 * r / nu;
println!("sphere array, R = {r}, L = {l}: c = {c:.4}, Re = {re:.1e}, K = {k:.4}, Hasimoto {:.4}", hasimoto(c));
assert!(re < 0.1);
(k / hasimoto(c), (flow.mass() - mass0).abs() / mass0)
}
#[test]
fn stokes_drag_of_a_sphere_array_matches_hasimoto() {
let coarse = sphere_drag(3.0, 16, 1500);
let fine = sphere_drag(6.0, 32, 3000);
println!("sphere drag over Hasimoto: R = 3: {:.4}, R = 6: {:.4}", coarse.0, fine.0);
assert!((fine.0 - 1.0).abs() < (coarse.0 - 1.0).abs(), "refining approaches the analytic drag");
assert!((fine.0 - 1.0).abs() < 0.05, "{}", fine.0);
// Round-off: each step's rounding moves the sum by a few ulps at most.
assert!(coarse.1 < 1500.0 * 1e-14 && fine.1 < 3000.0 * 1e-14, "mass is conserved to round-off with bounce-back walls: {} {}", coarse.1, fine.1);
}
#[test]
fn channel_with_a_parabolic_inlet_and_a_pressure_outlet_develops_poiseuille_flow() {
// Plates 1 apart, 6 long; inlet peak 0.1; outlet at gauge pressure 0.
let (h, l, nu, umax, n) = (1.0, 3.0, 0.05, 0.1, 16);
let dx = h / n as f64;
let mut d = domain([0.0; 3], [l, h, dx], dx);
d.faces.x_min = FaceCondition::Velocity { velocity: [umax, 0.0, 0.0], profile: Profile::Parabolic };
d.faces.x_max = FaceCondition::Pressure { pressure: 0.0 };
d.faces.z_min = PERIODIC;
d.faces.z_max = PERIODIC;
// τ = 0.6. The pressure drop changes the density along the channel by Δp / (ρ c_s²) in lattice
// units (the weakly compressible error), here 0.07%; the automatic time step would give 1.6%.
let dt = 0.1 / 3.0 * dx * dx / nu;
let procedure = modelcraft_model::Procedure::Fluid {
domain: "Flow".into(),
end_time: Some(4.0 * h * h / nu),
steps: None,
time_step: Some(dt),
frames: 4,
magic: None,
};
let m = model(1.0, nu, d, procedure);
let r = solve_fluid(&m, Some("Run"), &[], &Limits::default()).unwrap();
println!("inlet/outlet channel: {:?}", r.stats);
assert_eq!(r.frames.len(), 5);
assert!((r.stats.tau_plus - 0.6).abs() < 1e-12, "{:?}", r.stats);
let last = r.frames.last().unwrap();
// The profile at mid-length, against the parabola with the inlet's peak.
let i = r.grid.cells[0] / 2;
let mut err: f64 = 0.0;
for j in 0..n {
let y = (j as f64 + 0.5) * dx;
let exact = 4.0 * umax * y * (h - y) / (h * h);
let c = r.grid.index([i, j, 0]);
err = err.max((f64::from(last.velocity[c][0]) - exact).abs() / umax);
}
// The pressure gradient of plane Poiseuille flow, −12 μ Ū / H² with Ū = 2/3 of the peak.
let p = |i: usize| f64::from(last.pressure[r.grid.index([i, n / 2, 0])]);
let (i0, i1) = (r.grid.cells[0] / 4, 3 * r.grid.cells[0] / 4);
let gradient = (p(i1) - p(i0)) / ((i1 - i0) as f64 * dx);
let expected = -12.0 * nu * (2.0 / 3.0 * umax) / (h * h);
println!("inlet/outlet channel: profile error {err:.2e}, pressure gradient {gradient:.5} vs {expected:.5}");
assert!(err < 5e-3, "{err}");
assert!((gradient / expected - 1.0).abs() < 0.01, "{gradient} vs {expected}");
// The walls take the pressure drop: their drag balances it.
let wall: f64 = r.forces.iter().filter(|f| f.name == "y_min" || f.name == "y_max").map(|f| f.force.last().unwrap()[0]).sum();
assert!(wall > 0.0);
}
#[test]
fn stability_limits_are_enforced_with_clear_errors() {
let channel = |u: f64, dt: f64, nu: f64| {
let mut d = domain([0.0; 3], [4.0, 1.0, 0.25], 0.25);
d.faces.x_min = FaceCondition::Velocity { velocity: [u, 0.0, 0.0], profile: Profile::Uniform };
d.faces.x_max = FaceCondition::Pressure { pressure: 0.0 };
model(1.0, nu, d, step(10.0 * dt, dt, None))
};
let limits = Limits::default();
// Mach: u Δt / Δx = 0.4 → Ma = 0.69.
let e = Setup::new(&channel(1.0, 0.1, 1.0), None, &[], &limits).unwrap_err();
assert!(e.to_string().contains("Mach") && e.to_string().contains("lower the time step"), "{e}");
// τ: ν Δt / Δx² = 1e-4 → τ = 0.5003.
let e = Setup::new(&channel(0.01, 0.1, 6.25e-5), None, &[], &limits).unwrap_err();
assert!(e.to_string().contains("τ") && e.to_string().contains("refine the spacing"), "{e}");
// Caps.
let tight = Limits { max_cells: 10, ..limits };
assert!(matches!(Setup::new(&channel(0.01, 0.01, 0.1), None, &[], &tight), Err(Error::Limit(_))));
let tight = Limits { max_steps: 5, ..limits };
assert!(matches!(Setup::new(&channel(0.01, 0.01, 0.1), None, &[], &tight), Err(Error::Limit(_))));
let tight = Limits { max_cell_updates: 100, ..limits };
assert!(matches!(Setup::new(&channel(0.01, 0.01, 0.1), None, &[], &tight), Err(Error::Limit(_))));
let tight = Limits { max_frame_bytes: 100, ..limits };
assert!(matches!(solve_fluid(&channel(0.01, 0.01, 0.1), None, &[], &tight), Err(Error::Limit(_))));
// A flow pushed beyond the stable speed stops with an error, never a panic or NaNs.
let (setup, mut flow) = prepare(&channel(0.01, 0.01, 0.1), &[]);
flow.set_equilibrium(|_| (1.0, [0.6, 0.0, 0.0]));
assert!(matches!(flow.step(), Err(Error::Unstable(_))));
let _ = setup;
// An obstacle the domain lists but the caller did not prepare.
let mut m = channel(0.01, 0.01, 0.1);
let mut d = m.fluid_domain("Flow").unwrap().clone();
d.obstacles = vec!["Ghost".into()];
m.replace_fluid_domain("Flow", d).unwrap();
assert!(matches!(Setup::new(&m, None, &[], &limits), Err(Error::NotFound(_))));
}
#[test]
fn results_are_bit_for_bit_reproducible() {
let mut d = domain([0.0; 3], [2.0, 1.0, 1.0], 0.125);
d.faces.x_min = FaceCondition::Velocity { velocity: [0.1, 0.02, 0.0], profile: Profile::Parabolic };
d.faces.x_max = FaceCondition::Pressure { pressure: 0.0 };
d.faces.z_max = FaceCondition::Slip {};
d.obstacles = vec!["Ball".into()];
let ball = obstacle("Ball", sphere([0.7, 0.5, 0.45], 0.2, 3), &["skin"]);
let m = model(
1.0,
0.01,
d,
modelcraft_model::Procedure::Fluid { domain: "Flow".into(), end_time: Some(1.0), steps: None, time_step: None, frames: 3, magic: None },
);
let a = solve_fluid(&m, None, std::slice::from_ref(&ball), &Limits::default()).unwrap();
let b = solve_fluid(&m, None, std::slice::from_ref(&ball), &Limits::default()).unwrap();
assert_eq!(a, b);
let skin = a.forces.iter().find(|f| f.name == "skin").unwrap();
assert_eq!(skin.body.as_deref(), Some("Ball"));
assert!(skin.force.last().unwrap()[0] > 0.0, "the flow drags the ball downstream");
assert!(a.solid.iter().any(|s| *s));
let probe = a.probe(3, [1.8, 0.5, 0.5]).unwrap();
assert!(probe.1[0] > 0.0);
}