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

+277 -4
View File
@@ -1,11 +1,14 @@
//! `solve.*`: run the model's steps with the implicit or the explicit solver, keep the results
//! in the session, and read them back.
//! `solve.*`: run the model's steps with the implicit, the explicit or the lattice Boltzmann
//! solver, keep the results in the session, and read them back.
use modelcraft_explicit::{ExplicitResult, solve_explicit};
use modelcraft_lbm::{FluidResult, Obstacle};
use modelcraft_mesh::surface::{BoundarySurface, SurfaceOptions};
use modelcraft_model::FluidDomain;
use modelcraft_solver::{FrequencyOptions, Limits, Location, MassKind, StepResult as ImplicitResult, solve_frequency, solve_static};
use serde_json::{Value, json};
use super::{labels, opt, req};
use super::{labels, opt, points, req};
use crate::{CommandKind, CommandSpec, EngineError, Result, Session, StepResult};
pub const STATIC: CommandSpec = CommandSpec {
@@ -40,10 +43,18 @@ pub const HISTORY: CommandSpec = CommandSpec {
run: history,
};
pub const FLUID: CommandSpec = CommandSpec {
id: "solve.fluid",
label: "Solve Fluid Step",
params: r#"{"step": "Flow", "max_cells": 16000000, "max_steps": 10000000, "max_cell_updates": 1e12, "max_frame_bytes": 2e9} — runs a fluid step with the lattice Boltzmann solver (the only fluid step when `step` is left out): obstacles are triangulated from the open design or taken from imported surfaces; returns the time step, relaxation time, Mach number, mass and the final force on every wall; the caps may only be lowered"#,
kind: CommandKind::Model,
run: run_fluid,
};
pub const PROBE: CommandSpec = CommandSpec {
id: "solve.probe",
label: "Probe Results",
params: r#"{"step": "Load", "nodes": [1, 2], "mode": 1, "frame": 0} — the stored result at nodes: displacement, reaction, stress and von Mises (static), the shape of mode `mode` (frequency, from 1), or displacement and velocity in field frame `frame` (explicit, from 0; the last by default); a coupled reference point's label gives its six displacements (translations, rotations) and reactions (forces, moments)"#,
params: r#"{"step": "Load", "nodes": [1, 2], "mode": 1, "frame": 0} — the stored result at nodes: displacement, reaction, stress and von Mises (static), the shape of mode `mode` (frequency, from 1), or displacement and velocity in field frame `frame` (explicit, from 0; the last by default); a coupled reference point's label gives its six displacements (translations, rotations) and reactions (forces, moments). Fluid steps: {"step": "Flow", "points": [[x, y, z]], "frame": 10 (default the last)} gives velocity and pressure in the cells at the points; {"step": "Flow", "forces": ["x_min", "F2:top", "Body1/F2:top"]} gives those walls' force histories"#,
kind: CommandKind::Model,
run: probe,
};
@@ -51,6 +62,9 @@ pub const PROBE: CommandSpec = CommandSpec {
/// Most nodes one probe lists.
const MAX_PROBE: usize = 10_000;
/// Most force histories one fluid probe lists.
const MAX_PROBE_FORCES: usize = 100;
/// Most history samples one `solve.history` returns.
const MAX_HISTORY_SAMPLES: usize = 10_000;
@@ -206,6 +220,121 @@ fn history(s: &mut Session, p: &Value) -> Result<Value> {
}))
}
/// The surfaces of a fluid domain's obstacles: bodies of the open design (triangulated at the
/// domain's tolerance) or imported surfaces, by name.
fn obstacles(s: &Session, domain: &FluidDomain) -> Result<Vec<Obstacle>> {
let tolerance = domain.tolerance.unwrap_or(domain.spacing / 10.0);
let mut out = Vec::with_capacity(domain.obstacles.len());
for body in &domain.obstacles {
let (mesh, faces) = if let Some(shape) = s.design.as_deref().and_then(|d| d.body(body)) {
(std::borrow::Cow::Owned(shape.tessellate(tolerance)?), shape.faces().iter().map(|f| f.name.clone()).collect())
} else if let Some(t) = s.surfaces.iter().find(|t| &t.name == body) {
// Imported faces have no names: the solver calls them face0, face1, ….
(std::borrow::Cow::Borrowed(&t.mesh), Vec::new())
} else {
return Err(EngineError::Params(format!("obstacle `{body}` is neither a body of the open design nor an imported surface")));
};
let options = SurfaceOptions { weld_distance: mesh.weld_distance, ..SurfaceOptions::default() };
let surface = BoundarySurface::new(&mesh.positions, &mesh.triangles, &mesh.tri_face, &options)
.map_err(|e| EngineError::Params(format!("obstacle `{body}`: its surface cannot bound a solid: {e}")))?;
out.push(Obstacle { body: body.clone(), surface, faces });
}
Ok(out)
}
fn run_fluid(s: &mut Session, p: &Value) -> Result<Value> {
let defaults = modelcraft_lbm::Limits::default();
let limits = modelcraft_lbm::Limits {
max_cells: cap(p, "max_cells", defaults.max_cells)?,
max_steps: cap(p, "max_steps", defaults.max_steps)?,
max_cell_updates: cap(p, "max_cell_updates", defaults.max_cell_updates)?,
max_frame_bytes: cap(p, "max_frame_bytes", defaults.max_frame_bytes)?,
};
let step = opt::<String>(p, "step")?;
let (_, domain) = modelcraft_lbm::fluid_step(&s.model, step.as_deref())?;
let obstacles = obstacles(s, domain)?;
let r = modelcraft_lbm::solve_fluid(&s.model, step.as_deref(), &obstacles, &limits)?;
let summary = fluid_summary(&r);
s.store_result(StepResult::Fluid(r));
Ok(summary)
}
fn fluid_summary(r: &FluidResult) -> Value {
let speed = |v: &[f32; 3]| (f64::from(v[0]).powi(2) + f64::from(v[1]).powi(2) + f64::from(v[2]).powi(2)).sqrt();
let last = r.frames.last();
let max_speed = last.map_or(0.0, |f| f.velocity.iter().map(speed).fold(0.0, f64::max));
// The mean over the last quarter of the history smooths the acoustic noise of the
// weakly compressible scheme.
let tail = r.history_times.len().div_ceil(4).max(1);
let forces: Vec<Value> = r
.forces
.iter()
.filter(|f| f.force.iter().any(|v| *v != [0.0; 3]))
.map(|f| {
let recent = f.force.get(f.force.len().saturating_sub(tail)..).unwrap_or_default();
let n = recent.len().max(1) as f64;
let mean = [0, 1, 2].map(|a| recent.iter().map(|v| v[a]).sum::<f64>() / n);
json!({"name": f.name, "body": f.body, "final": f.force.last(), "mean": mean})
})
.collect();
let (m0, m1) = (r.frames.first().map_or(0.0, |f| f.mass), last.map_or(0.0, |f| f.mass));
json!({
"step": r.step,
"cells": r.stats.cells,
"fluid_cells": r.stats.fluid_cells,
"steps": r.stats.steps,
"time_step": r.stats.time_step,
"end_time": r.stats.end_time,
"tau": r.stats.tau_plus,
"tau_minus": r.stats.tau_minus,
"magic": r.stats.magic,
"velocity_scale": r.stats.velocity,
"lattice_velocity": r.stats.lattice_velocity,
"mach": r.stats.mach,
"frames": r.frames.iter().map(|f| f.time).collect::<Vec<_>>(),
"max_speed": max_speed,
"mass": {"first": m0, "last": m1, "relative_change": if m0 != 0.0 { (m1 - m0) / m0 } else { 0.0 }},
"forces": forces,
"warnings": r.warnings,
})
}
fn probe_fluid(r: &FluidResult, p: &Value) -> Result<Value> {
if let Some(names) = opt::<Vec<String>>(p, "forces")? {
if names.len() > MAX_PROBE_FORCES {
return Err(EngineError::Params(format!("`forces` lists at most {MAX_PROBE_FORCES} walls")));
}
let out: Vec<Value> = names
.iter()
.map(|n| {
let f =
r.forces.iter().find(|f| &f.name == n || f.body.as_ref().is_some_and(|b| format!("{b}/{}", f.name) == *n)).ok_or_else(|| {
EngineError::Params(format!("no wall `{n}` (box faces x_min … z_max, or obstacle faces by name or body/name)"))
})?;
Ok(json!({"name": f.name, "body": f.body, "force": f.force}))
})
.collect::<Result<_>>()?;
return Ok(json!({"step": r.step, "times": r.history_times, "forces": out}));
}
let pts = points(p, "points", MAX_PROBE)?;
let frame = match opt::<usize>(p, "frame")? {
Some(k) if k < r.frames.len() => k,
Some(_) => return Err(EngineError::Params(format!("`frame` must be from 0 to {}", r.frames.len().saturating_sub(1)))),
None => r.frames.len().saturating_sub(1),
};
let time = r.frames.get(frame).map(|f| f.time);
let out: Vec<Value> = pts
.iter()
.map(|&pt| match r.probe(frame, pt) {
Some((cell, velocity, pressure)) => {
json!({"point": pt, "cell": cell, "solid": r.solid.get(cell), "velocity": velocity, "pressure": pressure})
}
None => json!({"point": pt, "cell": null}),
})
.collect();
Ok(json!({"step": r.step, "frame": frame, "time": time, "points": out}))
}
fn probe(s: &mut Session, p: &Value) -> Result<Value> {
let step = opt::<String>(p, "step")?.ok_or_else(|| EngineError::Params("`step` is required".into()))?;
let result = s.result(&step).ok_or_else(|| {
@@ -215,9 +344,13 @@ fn probe(s: &mut Session, p: &Value) -> Result<Value> {
format!("step `{step}` has no result; solve it first")
})
})?;
if let StepResult::Fluid(r) = result.as_ref() {
return probe_fluid(r, p);
}
let nodes = labels(p, "nodes", MAX_PROBE)?;
let result = match result.as_ref() {
StepResult::Explicit(r) => return probe_frame(r, &step, &nodes, p),
StepResult::Fluid(r) => return probe_fluid(r, p),
StepResult::Implicit(r) => r,
};
let locate = |label: u32| {
@@ -409,6 +542,146 @@ pub(crate) mod tests {
}
}
/// Couette flow between y = 0 and y = 1 (eight cells), the top wall sliding at 0.1 along x,
/// periodic in x and z: material, domain and fluid step through commands.
pub(crate) fn couette(s: &mut Session) {
let periodic = json!({"type": "periodic"});
let script = json!([
{"command": "model.material.create", "params": {"name": "Oil", "density": 1.0, "fluid": {"type": "newtonian", "viscosity": 0.1}}},
{"command": "model.fluid_domain.create", "params": {"name": "Gap", "material": "Oil", "min": [0, 0, 0], "max": [0.125, 1, 0.125], "spacing": 0.125,
"faces": {"x_min": periodic, "x_max": periodic, "z_min": periodic, "z_max": periodic}}},
{"command": "model.fluid_domain.boundary", "params": {"domain": "Gap", "at": "y_max", "condition": {"type": "moving_wall", "velocity": [0.1, 0, 0]}}},
{"command": "model.step.create", "params": {"name": "Shear", "procedure": {"type": "fluid", "domain": "Gap", "end_time": 50, "frames": 4}}},
]);
s.run_script(&script).unwrap();
}
#[test]
fn solve_a_fluid_step_and_probe_its_fields_and_wall_forces() {
let mut s = Session::default();
couette(&mut s);
let journal = s.journal.len();
let r = s.execute("solve.fluid", &json!({})).unwrap();
assert_eq!(r["step"], "Shear");
assert_eq!(r["frames"].as_array().unwrap().len(), 5, "{r}");
assert!((r["tau"].as_f64().unwrap() - 1.0).abs() < 1e-3, "the automatic time step keeps τ ≤ 1: {r}");
assert!(r["mass"]["relative_change"].as_f64().unwrap().abs() < 1e-12, "{r}");
assert_eq!(s.journal.len(), journal, "solving does not change the model");
// The wall shear μ U / H over the wall's area 0.125².
let bottom = r["forces"].as_array().unwrap().iter().find(|f| f["name"] == "y_min").unwrap();
let expected = 0.1 * 0.1 / 1.0 * 0.125 * 0.125;
assert!((bottom["mean"][0].as_f64().unwrap() / expected - 1.0).abs() < 1e-3, "{bottom}");
let probe = s.execute("solve.probe", &json!({"step": "Shear", "points": [[0.06, 0.5625, 0.06], [5, 5, 5]]})).unwrap();
let u = probe["points"][0]["velocity"][0].as_f64().unwrap();
assert!((u - 0.05625).abs() < 1e-5, "{probe}");
assert!(probe["points"][1]["cell"].is_null());
let first = s.execute("solve.probe", &json!({"step": "Shear", "points": [[0.06, 0.5625, 0.06]], "frame": 0})).unwrap();
assert_eq!(first["points"][0]["velocity"][0], 0.0, "the initial frame is at rest");
let h = s.execute("solve.probe", &json!({"step": "Shear", "forces": ["y_min", "y_max"]})).unwrap();
assert_eq!(h["forces"].as_array().unwrap().len(), 2);
assert_eq!(h["times"].as_array().unwrap().len(), h["forces"][0]["force"].as_array().unwrap().len());
// An edit makes the result stale; the step solves again.
s.execute(
"model.fluid_domain.boundary",
&json!({"domain": "Gap", "at": "y_max", "condition": {"type": "moving_wall", "velocity": [0.2, 0, 0]}}),
)
.unwrap();
assert!(s.execute("solve.probe", &json!({"step": "Shear", "points": [[0, 0, 0]]})).is_err());
// The domain cannot go while a step uses it; the material cannot lose its fluid behaviour.
assert!(s.execute("model.fluid_domain.delete", &json!({"name": "Gap"})).is_err());
assert!(s.execute("model.material.edit", &json!({"name": "Oil", "density": 1.0})).is_err());
// A structural step is not a fluid one, and a fluid step is not written to decks.
assert!(s.execute("solve.static", &json!({"step": "Shear"})).is_err());
for format in ["abaqus", "nastran", "lsdyna"] {
let e = s.execute("deck.write", &json!({"format": format, "path": "/no/such/dir/x.deck"})).unwrap_err().to_string();
assert!(e.contains("fluid step"), "{format}: {e}");
}
}
#[test]
fn a_fluid_domain_takes_its_obstacles_from_the_open_design() {
use modelcraft_geometry::sample::{Plate, plate_design};
let dir = std::env::temp_dir().join(format!("modelcraft-fluid-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("plate.solidcraft");
std::fs::write(&path, plate_design(&Plate::default()).unwrap()).unwrap();
let mut s = Session::default();
s.execute("geometry.open", &json!({"path": path.to_str().unwrap()})).unwrap();
let _ = std::fs::remove_dir_all(&dir);
let bodies = s.execute("geometry.bodies", &json!({})).unwrap();
let [lo, hi] = [0, 1].map(|k| bodies[0]["bbox"][k].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect::<Vec<_>>());
// A box 20 mm larger than the plate on every side, 5 mm cells: a viscous liquid (ν = 100 mm²/s) flowing along x.
let min: Vec<f64> = lo.iter().map(|v| (v / 5.0).floor() * 5.0 - 20.0).collect();
let max: Vec<f64> = hi.iter().map(|v| (v / 5.0).ceil() * 5.0 + 20.0).collect();
let script = json!([
{"command": "model.material.create", "params": {"name": "Water", "density": 1e-9, "fluid": {"type": "newtonian", "viscosity": 1e-7}}},
{"command": "model.fluid_domain.create", "params": {"name": "Tank", "material": "Water", "min": min, "max": max, "spacing": 5, "obstacles": ["Body1"],
"faces": {"x_min": {"type": "velocity", "velocity": [10, 0, 0]}, "x_max": {"type": "pressure", "pressure": 0}}}},
{"command": "model.step.create", "params": {"name": "Flow", "procedure": {"type": "fluid", "domain": "Tank", "steps": 60, "frames": 2}}},
]);
s.run_script(&script).unwrap();
let r = s.execute("solve.fluid", &json!({"step": "Flow"})).unwrap();
assert!(r["fluid_cells"].as_u64().unwrap() < r["cells"].as_u64().unwrap(), "{r}");
let on_plate: Vec<&Value> = r["forces"].as_array().unwrap().iter().filter(|f| f["body"] == "Body1").collect();
assert!(!on_plate.is_empty(), "{r}");
let faces = bodies[0]["faces"].as_array().unwrap();
for f in &on_plate {
assert!(faces.iter().any(|g| g["name"] == f["name"]), "forces are reported per named face: {f}");
}
let drag: f64 = on_plate.iter().map(|f| f["final"][0].as_f64().unwrap()).sum();
assert!(drag > 0.0, "the flow pushes the plate downstream: {r}");
// An obstacle that is not in the design.
s.execute(
"model.fluid_domain.edit",
&json!({"name": "Tank", "material": "Water", "min": min, "max": max, "spacing": 5, "obstacles": ["Nope"]}),
)
.unwrap();
assert!(s.execute("solve.fluid", &json!({})).is_err());
}
#[test]
fn solve_fluid_hostile_params_never_panic() {
let mut s = Session::default();
couette(&mut s);
s.execute("solve.fluid", &json!({"step": "Shear"})).unwrap();
let hostile = [
json!(null),
json!({"step": 7}),
json!({"step": "Nope"}),
json!({"step": "Shear", "max_cells": 0}),
json!({"step": "Shear", "max_cells": 1e300}),
json!({"step": "Shear", "max_cells": 3}),
json!({"step": "Shear", "max_steps": 3}),
json!({"step": "Shear", "max_steps": -1}),
json!({"step": "Shear", "max_cell_updates": 10}),
json!({"step": "Shear", "max_cell_updates": 0.5}),
json!({"step": "Shear", "max_frame_bytes": 16}),
json!({"step": "Shear", "max_frame_bytes": "lots"}),
json!({"step": "Shear", "points": [[1e308, -1e308, 0]]}),
json!({"step": "Shear", "points": [[f64::MAX, 0, 0]], "frame": 1e300}),
json!({"step": "Shear", "points": [[0, 0, 0]], "frame": 99}),
json!({"step": "Shear", "points": null}),
json!({"step": "Shear", "points": vec![[0, 0, 0]; 20_000]}),
json!({"step": "Shear", "forces": ["nope"]}),
json!({"step": "Shear", "forces": vec!["y_min"; 1000]}),
json!({"step": "Shear", "forces": "y_min"}),
json!({"step": "Shear", "nodes": [1]}),
];
for id in ["solve.fluid", "solve.probe", "solve.static", "solve.frequency"] {
for p in &hostile {
let mut t = s.clone();
let before = t.model.clone();
let results = t.solved_steps().count();
if t.execute(id, p).is_err() {
assert_eq!(t.model, before);
assert_eq!(t.solved_steps().count(), results, "{id} {p}");
}
}
}
}
#[test]
fn solve_and_probe_a_coupled_reference_point() {
// The defined bar: a force on a reference point kinematically coupled to the free end.