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

+86 -1
View File
@@ -30,6 +30,16 @@ pub const MAX_HISTORY_SAMPLES: f64 = 1_000_000.0;
/// Most initial velocities or rigid walls an explicit dynamic step lists.
pub const MAX_STEP_ITEMS: usize = 10_000;
/// Most time steps a fluid step asks for (the solver caps a run lower by default).
pub const MAX_FLUID_STEPS: u64 = 1_000_000_000;
/// Most output frames a fluid step writes.
pub const MAX_FLUID_FRAMES: u32 = 1000;
fn default_frames() -> u32 {
10
}
/// What a step solves.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
@@ -47,6 +57,25 @@ pub enum Procedure {
},
/// Transient dynamics by explicit time integration up to `end_time`.
ExplicitDynamic(ExplicitDynamic),
/// Time-dependent incompressible flow in a fluid domain, from rest (or the domain's initial
/// velocity) to `end_time`, or for `steps` time steps of `time_step` (exactly one of the two
/// lengths). The solver picks the time step when `time_step` is left out. `frames` velocity
/// and pressure fields are written, evenly spaced, after the initial one; forces on every
/// wall are recorded throughout. `magic` is the lattice Boltzmann two-relaxation-time
/// parameter Λ (default 3/16, which puts bounce-back walls exactly halfway between cells).
Fluid {
domain: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
end_time: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
steps: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
time_step: Option<f64>,
#[serde(default = "default_frames")]
frames: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
magic: Option<f64>,
},
}
/// The settings of an explicit dynamic step.
@@ -281,6 +310,15 @@ impl Procedure {
Procedure::Static {} => "static",
Procedure::Frequency { .. } => "frequency",
Procedure::ExplicitDynamic(_) => "explicit_dynamic",
Procedure::Fluid { .. } => "fluid",
}
}
/// The fluid domain a fluid step solves in.
pub fn fluid_domain(&self) -> Option<&str> {
match self {
Procedure::Fluid { domain, .. } => Some(domain),
Procedure::Static {} | Procedure::Frequency { .. } | Procedure::ExplicitDynamic(_) => None,
}
}
@@ -293,7 +331,7 @@ impl Procedure {
pub fn sets(&self) -> impl Iterator<Item = &str> {
let explicit = match self {
Procedure::ExplicitDynamic(e) => Some(e),
Procedure::Static {} | Procedure::Frequency { .. } => None,
Procedure::Static {} | Procedure::Frequency { .. } | Procedure::Fluid { .. } => None,
};
explicit.into_iter().flat_map(ExplicitDynamic::sets)
}
@@ -302,6 +340,27 @@ impl Procedure {
if let Procedure::ExplicitDynamic(e) = self {
return e.validate();
}
if let Procedure::Fluid { end_time, steps, time_step, frames, magic, .. } = *self {
match (end_time, steps) {
(Some(t), None) => {
crate::positive("end time", t)?;
}
(None, Some(n)) if (1..=MAX_FLUID_STEPS).contains(&n) => {}
(None, Some(n)) => return Err(Error::Invalid(format!("a fluid step runs 1 to {MAX_FLUID_STEPS} time steps, got {n}"))),
_ => return Err(Error::Invalid("a fluid step needs exactly one of `end_time` and `steps`".into())),
}
if let Some(dt) = time_step {
crate::positive("time step", dt)?;
}
if !(1..=MAX_FLUID_FRAMES).contains(&frames) {
return Err(Error::Invalid(format!("a fluid step writes 1 to {MAX_FLUID_FRAMES} frames, got {frames}")));
}
if let Some(m) = magic
&& !(m > 0.0 && m <= 1.0)
{
return Err(Error::Invalid(format!("the two-relaxation-time parameter Λ must be above 0 and at most 1, got {m}")));
}
}
if let Procedure::Frequency { modes, min_hz, max_hz } = *self {
if !(1..=MAX_MODES).contains(&modes) {
return Err(Error::Invalid(format!("a frequency step extracts 1 to {MAX_MODES} modes, got {modes}")));
@@ -326,6 +385,8 @@ impl Procedure {
Procedure::Static {} => OutputVariable::ALL.into_iter().collect(),
Procedure::Frequency { .. } => [OutputVariable::Displacement].into_iter().collect(),
Procedure::ExplicitDynamic(_) => [OutputVariable::Displacement, OutputVariable::Stress].into_iter().collect(),
// Velocity, pressure and wall forces, always.
Procedure::Fluid { .. } => BTreeSet::new(),
}
}
}
@@ -367,6 +428,20 @@ impl Step {
pub fn new(name: impl Into<String>, procedure: Procedure) -> Step {
Step { name: name.into(), output: procedure.default_output(), procedure, boundary_conditions: BTreeSet::new(), loads: BTreeSet::new() }
}
/// Change the procedure. Moving between a structural and a fluid procedure starts from the
/// new procedure's default output and, for a fluid step, nothing active (a fluid step takes
/// its conditions from its domain); between structural procedures the output and table stay.
pub fn set_procedure(&mut self, procedure: Procedure) {
if procedure.fluid_domain().is_some() != self.procedure.fluid_domain().is_some() {
self.output = procedure.default_output();
if procedure.fluid_domain().is_some() {
self.boundary_conditions.clear();
self.loads.clear();
}
}
self.procedure = procedure;
}
}
impl Model {
@@ -415,6 +490,16 @@ impl Model {
for set in step.procedure.sets() {
require(&self.sets, "set", set).map_err(|e| context("step", &step.name, e))?;
}
if let Some(domain) = step.procedure.fluid_domain() {
require(&self.fluid_domains, "fluid domain", domain).map_err(|e| context("step", &step.name, e))?;
if !(step.boundary_conditions.is_empty() && step.loads.is_empty() && step.output.is_empty()) {
return Err(context(
"step",
&step.name,
Error::Invalid("a fluid step takes its conditions from its fluid domain and always writes velocity, pressure and wall forces: list no boundary conditions, loads or output".into()),
));
}
}
for b in &step.boundary_conditions {
require(&self.boundary_conditions, "boundary condition", b).map_err(|e| context("step", &step.name, e))?;
}