Files
OneCAD-Simulation/crates/lbm/tests/verification.rs
T
jyvikoandClaude Opus 5.5 d5bd74570f Rename the project to FieldCraft
Crates fieldcraft-*, binaries fieldcraft and fieldcraft-cli, the .fieldcraft file (format tag
fieldcraft/1, manifest fieldcraft.json), FIELDCRAFT_* environment variables, the MCP server
name, egui widget ids (fc_*), the app icon document FieldCraft.icon and its generated family,
docs and licences. No compatibility shim: no files of the earlier name exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-13 23:51:40 -04:00

387 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 fieldcraft-lbm -- --nocapture`).
mod common;
use common::*;
use fieldcraft_lbm::{Error, Limits, Setup, solve_fluid};
use fieldcraft_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 fieldcraft_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]
#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]
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 fieldcraft_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,
fieldcraft_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]
#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]
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]
#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]
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 = fieldcraft_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,
fieldcraft_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);
}