Files
OneCAD-Simulation/crates/lbm/tests/verification.rs
T
5e5a2ae251 Fast PR CI: small hostile-test caps, heavy tests in a separate job (#43)
* test: hostile-params test runs under small session work caps

Session gains Limits, the defaults of mesh.tet.generate's max_steiner and
max_tets (production values unchanged; a parameter still overrides them;
file.open keeps them). The registry-wide hostile-params test runs on
sessions with small caps, so the needle-triangle tessellation it meshes is
refused at once instead of meshing to the 50k Steiner cap: the engine test
binary drops from ~350 s to ~20 s locally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* ci: move heavy tests to a heavy job on main, nightly and on demand

Four slow tests are #[ignore = "heavy: ..."] (every corpus recipe, the
cantilever convergence study, the explicit-vs-modal cantilever period, the
generic-predicate rational cross-check); fast representative tests of each
area stay in the PR gate. cargo xtask test --heavy runs every ignored test
(benchmarks included) and the doc tests; cargo xtask test and ci run the
rest without the doc tests. The new heavy CI job runs it on pushes to main,
nightly and on workflow_dispatch. Branch jobs restore main's rust cache and
no longer save their own. AGENTS.md documents the rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* test: the three slow LBM verification cases run in the heavy job

The Stokes-drag sphere array, the staircase-pipe convergence study and the
developing-channel case are heavy (21 s, 11 s, 6 s alone); plane
Poiseuille, Couette, Taylor-Green, reproducibility and the stability
limits stay in the PR gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
2026-10-13 21:21:49 -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 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]
#[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 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]
#[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 = 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);
}