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

+16 -3
View File
@@ -261,8 +261,21 @@ pub static CATALOGUE: &[Capability] = &[
"Force, moment, pressure, traction, gravity or body force",
&["model.load.edit", "model.load.delete"],
),
soon("flow.domain", "Fluid Domain", Loads, "Flow", "M12", "The fluid grid voxelised from the face-tagged surface"),
soon("flow.boundary", "Inlet & Outlet", Loads, "Flow", "M12", "Velocity inlets, pressure outlets and walls"),
ready_more(
"model.fluid_domain.create",
"Fluid Domain",
Loads,
"Flow",
"The box of fluid cells, its fluid, and the obstacle bodies voxelised from their face-tagged surfaces",
&["model.fluid_domain.edit", "model.fluid_domain.delete"],
),
ready(
"model.fluid_domain.boundary",
"Inlet & Outlet",
Loads,
"Flow",
"Velocity inlets, pressure outlets, walls, moving and slip walls, periodic faces",
),
// ---- Interactions ----
ready_more(
"model.reference_point.create",
@@ -306,7 +319,7 @@ pub static CATALOGUE: &[Capability] = &[
"Transient dynamics of an explicit step by central differences (contact and erosion coming in M10)",
),
soon("solve.blast", "Blast & Impact", Solve, "Explicit", "M14", "EOS-driven fluids with fluid–structure contact (ALE or SPH)"),
soon("solve.flow", "Lattice Boltzmann", Solve, "Fluids", "M12", "Lattice Boltzmann flow on the CPU or the GPU"),
ready("solve.fluid", "Lattice Boltzmann", Solve, "Fluids", "Lattice Boltzmann flow of a fluid step on the CPU, with wall forces"),
soon("solve.fsi", "Fluid–Structure", Solve, "Fluids", "M13", "Coupled fluid and structure solve"),
// ---- Results ----
ready_more("results.deformed", "Deformed", Results, "Display", "The deformed shape of a solved step, to scale", &["results.clear"]),
+38 -5
View File
@@ -1,11 +1,14 @@
//! Model definition commands: create, edit and delete sets, materials, sections, reference
//! points, couplings, boundary conditions, loads and steps, and the step activity table.
//! points, couplings, boundary conditions, loads, fluid domains and steps, and the step activity
//! table.
//!
//! Each definition's parameters are its JSON form in the model (the same form a `.modelcraft`
//! file stores), plus its `name`. `create` fails when the name is taken, `edit` replaces an
//! existing definition, `delete` refuses while another definition refers to it.
use modelcraft_model::{BoundaryCondition, Coupling, Load, Material, Model, ReferencePoint, Section, Set, Step};
use modelcraft_model::{
BoundaryCondition, Coupling, FaceCondition, FluidBoundary, FluidDomain, Load, Material, Model, ReferencePoint, Section, Set, Step,
};
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
@@ -77,7 +80,7 @@ definition_commands!(
Material,
"material",
"Material",
r#"{"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3} | {"type": "engineering_constants", "young": [E1, E2, E3], "poisson": [ν12, ν13, ν23], "shear": [G12, G13, G23]}, "density": 7.85e-9} — in the model's unit system (model.units), never converted"#,
r#"{"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3} | {"type": "engineering_constants", "young": [E1, E2, E3], "poisson": [ν12, ν13, ν23], "shear": [G12, G13, G23]}, "density": 7.85e-9, "fluid": {"type": "newtonian", "viscosity": 1.0e-9} (dynamic viscosity; a fluid needs a density)} — in the model's unit system (model.units), never converted"#,
add_material,
replace_material,
remove_material,
@@ -156,10 +159,40 @@ definition_commands!(
LOAD_DELETE
);
definition_commands!(
"fluid domain",
FluidDomain,
"fluid_domain",
"Fluid Domain",
r#"{"name": "Flow", "material": "Water" (a density and fluid behaviour), "min": [x, y, z], "max": [x, y, z], "spacing": 1.0 (cubic cells; every extent a whole number of spacings), "faces": {"x_min": {"type": "velocity", "velocity": [u, v, w], "profile": "uniform|parabolic"}, "x_max": {"type": "pressure", "pressure": 0}, "y_min": {"type": "wall"} | {"type": "moving_wall", "velocity": [u, v, w] (in the face's plane)} | {"type": "slip"} | {"type": "periodic"} (in opposite pairs), …} (default walls), "obstacles": ["Body1"] (solid bodies of the design or imported surfaces, by name), "surfaces": [{"face": {"body": "Body1", "face": "F2:top"}, "condition": {"type": "moving_wall", "velocity": [u, v, w]}}] (obstacle faces are walls unless listed), "initial_velocity": [u, v, w], "acceleration": [x, y, z] (gravity or a driving force per mass), "tolerance": 0.1 (obstacle triangulation; default a tenth of the spacing)}"#,
add_fluid_domain,
replace_fluid_domain,
remove_fluid_domain,
FLUID_DOMAIN_CREATE,
FLUID_DOMAIN_EDIT,
FLUID_DOMAIN_DELETE
);
pub const FLUID_BOUNDARY: CommandSpec = CommandSpec {
id: "model.fluid_domain.boundary",
label: "Inlet, Outlet or Wall",
params: r#"{"domain": "Flow", "at": "x_min" (a box face) | {"body": "Body1", "face": "F2:top"} (an obstacle face), "condition": {"type": "velocity", "velocity": [u, v, w], "profile": "uniform|parabolic"} | {"type": "pressure", "pressure": 0} | {"type": "wall"} | {"type": "moving_wall", "velocity": [u, v, w]} | {"type": "slip"} | {"type": "periodic"}} — sets one boundary's condition (obstacle faces take walls and moving walls)"#,
kind: CommandKind::Model,
run: fluid_boundary,
};
fn fluid_boundary(s: &mut Session, p: &Value) -> Result<Value> {
let domain: &str = req(p, "domain")?;
let at: FluidBoundary = req(p, "at")?;
let condition: FaceCondition = req(p, "condition")?;
s.model.set_fluid_boundary(domain, at, condition)?;
Ok(json!({"domain": domain}))
}
pub const STEP_CREATE: CommandSpec = CommandSpec {
id: "model.step.create",
label: "New Step",
params: r#"{"name": "Bend", "procedure": {"type": "static"} | {"type": "frequency", "modes": 10, "min_hz": 0, "max_hz": 500} | {"type": "explicit_dynamic", "end_time": 0.001, "dt_scale": 0.9, "max_increments": 10000000, "output_interval", "history_interval", "history_nodes": "set", "initial_velocities": [{"set", "velocity": [vx, vy, vz]}], "rigid_walls": [{"set", "point", "normal"}], "hourglass": {"type": "viscous|stiffness", "coefficient": 0.1}, "bulk_viscosity": {"linear": 0.06, "quadratic": 1.5}}, "output": ["displacement", "reaction_force", "stress", "strain"] (default by procedure), "boundary_conditions": [names], "loads": [names] (active in this step; nothing carries over between steps), "before": "step" (default: last)}"#,
params: r#"{"name": "Bend", "procedure": {"type": "static"} | {"type": "frequency", "modes": 10, "min_hz": 0, "max_hz": 500} | {"type": "fluid", "domain": "Flow", "end_time": 1.0 | "steps": 1000, "time_step": (default: chosen for Mach 0.087 and τ ≤ 1), "frames": 10, "magic": 0.1875 (the two-relaxation-time Λ)} | {"type": "explicit_dynamic", "end_time": 0.001, "dt_scale": 0.9, "max_increments": 10000000, "output_interval", "history_interval", "history_nodes": "set", "initial_velocities": [{"set", "velocity": [vx, vy, vz]}], "rigid_walls": [{"set", "point", "normal"}], "hourglass": {"type": "viscous|stiffness", "coefficient": 0.1}, "bulk_viscosity": {"linear": 0.06, "quadratic": 1.5}}, "output": ["displacement", "reaction_force", "stress", "strain"] (default by procedure), "boundary_conditions": [names], "loads": [names] (active in this step; nothing carries over between steps), "before": "step" (default: last)}"#,
kind: CommandKind::Model,
run: step_create,
};
@@ -207,7 +240,7 @@ fn step_edit(s: &mut Session, p: &Value) -> Result<Value> {
let n = name(p)?;
let mut step = s.model.step(n).cloned().ok_or_else(|| EngineError::Params(format!("no step `{n}`")))?;
if let Some(procedure) = opt(p, "procedure")? {
step.procedure = procedure;
step.set_procedure(procedure);
}
if let Some(o) = opt(p, "output")? {
step.output = o;
+5
View File
@@ -57,6 +57,10 @@ pub static ALL: &[CommandSpec] = &[
define::LOAD_CREATE,
define::LOAD_EDIT,
define::LOAD_DELETE,
define::FLUID_DOMAIN_CREATE,
define::FLUID_DOMAIN_EDIT,
define::FLUID_DOMAIN_DELETE,
define::FLUID_BOUNDARY,
define::STEP_CREATE,
define::STEP_EDIT,
define::STEP_DELETE,
@@ -75,6 +79,7 @@ pub static ALL: &[CommandSpec] = &[
solve::FREQUENCY,
solve::EXPLICIT,
solve::HISTORY,
solve::FLUID,
solve::PROBE,
results::DEFORMED,
results::CONTOUR,
+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.
+20 -1
View File
@@ -50,6 +50,8 @@ pub enum EngineError {
Solver(#[from] modelcraft_solver::Error),
#[error(transparent)]
Explicit(#[from] modelcraft_explicit::Error),
#[error(transparent)]
Fluid(#[from] modelcraft_lbm::Error),
/// A command panicked; the model is unchanged.
#[error("internal error in {command}: {message}")]
Internal { command: String, message: String },
@@ -57,11 +59,12 @@ pub enum EngineError {
pub type Result<T> = std::result::Result<T, EngineError>;
/// The result of a solved step, from the implicit or the explicit solver.
/// The result of a solved step, from the implicit, the explicit or the lattice Boltzmann solver.
#[derive(Debug, Clone, PartialEq)]
pub enum StepResult {
Implicit(modelcraft_solver::StepResult),
Explicit(ExplicitResult),
Fluid(modelcraft_lbm::FluidResult),
}
impl StepResult {
@@ -69,6 +72,7 @@ impl StepResult {
match self {
StepResult::Implicit(r) => r.step(),
StepResult::Explicit(r) => &r.step,
StepResult::Fluid(r) => &r.step,
}
}
@@ -77,6 +81,8 @@ impl StepResult {
match self {
StepResult::Implicit(r) => r.nodes(),
StepResult::Explicit(r) => &r.nodes,
// A fluid result is per cell, not per node.
StepResult::Fluid(_) => &[],
}
}
}
@@ -357,6 +363,19 @@ mod tests {
json!({"step": "Bend", "loads": ["nope"], "active": "yes"}),
json!({"step": "Bend", "boundary_conditions": [], "loads": []}),
json!({"system": "furlongs"}),
// Fluid domains, boundaries and steps.
json!({"name": "A", "material": "Steel", "min": [0, 0, 0], "max": [1e308, 1, 1], "spacing": 1e-300}),
json!({"name": "A", "material": "Steel", "min": [0, 0, 0], "max": [1, 1, 1], "spacing": 0.5, "faces": {"x_min": {"type": "periodic"}}}),
json!({"name": "A", "material": "nope", "min": [0, 0, 0], "max": [1, 1, 1], "spacing": 0.5, "obstacles": ["", ""]}),
json!({"name": "A", "density": 1, "fluid": {"type": "newtonian", "viscosity": -1}}),
json!({"name": "A", "fluid": {"type": "newtonian", "viscosity": 1}}),
json!({"domain": "A", "at": "x_mid", "condition": {"type": "wall"}}),
json!({"domain": "A", "at": {"body": "B", "face": "f"}, "condition": {"type": "pressure", "pressure": 1e308}}),
json!({"domain": null, "at": null, "condition": null}),
json!({"name": "A", "procedure": {"type": "fluid", "domain": "A", "steps": 1e300}}),
json!({"name": "A", "procedure": {"type": "fluid", "domain": "A", "end_time": -1, "steps": 3}}),
json!({"name": "Bend", "procedure": {"type": "fluid", "domain": "nope", "end_time": 1}}),
json!({"step": "Bend", "max_cells": 1e300, "max_steps": -3}),
json!({"geometry": {"body": "Bar"}, "elements": [1, 99], "faces": [{"element": 1, "face": 200}]}),
json!({"geometry": {"body": "Bar", "vertex": "v"}, "nodes": [1], "edges": [{"element": 1, "edge": 0}]}),
];
+12 -3
View File
@@ -20,6 +20,8 @@ pub enum Kind<'a> {
Static(&'a StaticResult),
Frequency(&'a FrequencyResult),
Explicit(&'a ExplicitResult),
/// Per cell, not per node: no nodal fields to display yet.
Fluid(&'a modelcraft_lbm::FluidResult),
}
impl StepResult {
@@ -28,6 +30,7 @@ impl StepResult {
StepResult::Implicit(modelcraft_solver::StepResult::Static(r)) => Kind::Static(r),
StepResult::Implicit(modelcraft_solver::StepResult::Frequency(r)) => Kind::Frequency(r),
StepResult::Explicit(r) => Kind::Explicit(r),
StepResult::Fluid(r) => Kind::Fluid(r),
}
}
}
@@ -146,8 +149,10 @@ impl Field {
Kind::Static(_) => (true, false),
Kind::Frequency(_) => (false, false),
Kind::Explicit(e) => e.frames.last().map_or((false, false), |f| (!f.stress.is_empty(), !f.velocity.is_empty())),
// Cell fields have their own display (M12 streamlines); no nodal field.
Kind::Fluid(_) => return None.into_iter().flatten(),
};
Field::ALL.into_iter().filter(move |f| if *f == Field::Velocity { velocity } else { stress || !f.is_stress() })
Some(Field::ALL.into_iter().filter(move |f| if *f == Field::Velocity { velocity } else { stress || !f.is_stress() })).into_iter().flatten()
}
}
@@ -222,6 +227,7 @@ pub fn displacements(r: &StepResult, pick: Pick) -> Result<&[[f64; 3]]> {
}
Ok(&f.displacement)
}
Kind::Fluid(f) => Err(EngineError::Params(format!("step `{}` is a fluid step: its fields are per cell (solve.probe)", f.step))),
}
}
@@ -233,7 +239,7 @@ pub fn field_values(model: &Model, r: &StepResult, field: Field, pick: Pick) ->
let stresses = || -> Result<std::borrow::Cow<'_, [[f64; 6]]>> {
match r.kind() {
Kind::Static(s) => Ok(std::borrow::Cow::Borrowed(s.stress.as_slice())),
Kind::Frequency(_) => Err(unavailable()),
Kind::Frequency(_) | Kind::Fluid(_) => Err(unavailable()),
Kind::Explicit(e) => {
let f = frame(e, pick)?;
if f.stress.is_empty() {
@@ -255,7 +261,7 @@ pub fn field_values(model: &Model, r: &StepResult, field: Field, pick: Pick) ->
let f = frame(e, pick)?;
if f.velocity.is_empty() { Err(unavailable()) } else { Ok(f.velocity.iter().map(|v| magnitude(*v)).collect()) }
}
Kind::Static(_) | Kind::Frequency(_) => Err(unavailable()),
Kind::Static(_) | Kind::Frequency(_) | Kind::Fluid(_) => Err(unavailable()),
},
Field::VonMises => each(&modelcraft_fem::von_mises),
Field::MaxPrincipal => principal(0),
@@ -338,6 +344,9 @@ impl Session {
out["time"] = json!(f.time);
}
}
Kind::Fluid(f) => {
out["frames"] = f.frames.iter().map(|f| f.time).collect();
}
Kind::Static(_) => {}
}
out