forked from 1b-agent/fieldcraft-upstream
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:
55 files changed
+3800
-64
No files matched your search
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user