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 <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
55 files changed
+3800
-64
No files matched your search
@@ -104,6 +104,7 @@ pub fn write(model: &Model) -> Result<String> {
|
||||
|
||||
/// [`write`] for a model already resolved.
|
||||
pub fn write_resolved(model: &Model, r: &Resolved) -> Result<String> {
|
||||
crate::refuse_fluid(FORMAT, model)?;
|
||||
let by_type = elements_by_type(model, r)?;
|
||||
check_expressible(model)?;
|
||||
let mut d = Deck::default();
|
||||
@@ -757,6 +758,7 @@ impl Deck {
|
||||
for step in steps {
|
||||
self.comment(&format!("Step {}", step.name));
|
||||
match step.procedure {
|
||||
Procedure::Fluid { .. } => return Err(crate::fluid_step(FORMAT, &step.name)),
|
||||
Procedure::Static {} => {
|
||||
self.line(&format!("*STEP, NAME={}, NLGEOM=NO", step.name));
|
||||
self.line("*STATIC");
|
||||
|
||||
@@ -83,6 +83,19 @@ pub fn with_steps(model: &Model, names: &[String]) -> Result<Model> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A fluid step: solved by ModelCraft's lattice Boltzmann solver, never written to a deck.
|
||||
pub(crate) fn fluid_step(format: &'static str, step: &str) -> Error {
|
||||
Error::Unsupported { format, what: format!("fluid step `{step}` (solve it with solve.fluid; write the structural steps only)") }
|
||||
}
|
||||
|
||||
/// Every writer's first check: the model has no fluid step.
|
||||
pub(crate) fn refuse_fluid(format: &'static str, model: &Model) -> Result<()> {
|
||||
match model.steps().iter().find(|s| s.procedure.fluid_domain().is_some()) {
|
||||
Some(step) => Err(fluid_step(format, &step.name)),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every deck format, one row per writer.
|
||||
pub const FORMATS: &[Format] = &[
|
||||
Format { name: "abaqus", extension: "inp", writer: abaqus::write_resolved },
|
||||
|
||||
@@ -126,6 +126,7 @@ pub(crate) fn write_resolved(model: &Model, r: &Resolved) -> Result<String> {
|
||||
}
|
||||
|
||||
fn write_with(model: &Model, r: &Resolved) -> Result<Written> {
|
||||
crate::refuse_fluid(FORMAT, model)?;
|
||||
let estimate = (model.nodes().len() + model.reference_points().len()) * 130 + model.elements().len() * 260;
|
||||
if estimate > MAX_DECK_BYTES {
|
||||
return Err(Error::Limit(format!("the deck would be about {estimate} bytes; at most {MAX_DECK_BYTES} are written")));
|
||||
@@ -566,6 +567,7 @@ fn write_step(d: &mut Deck, model: &Model, r: &Resolved, plan: &Plan, step: &Ste
|
||||
d.header(&["imflag", "dt0"]);
|
||||
d.card([Field::from(1), Field::from(1.0)]);
|
||||
match step.procedure {
|
||||
Procedure::Fluid { .. } => return Err(crate::fluid_step(FORMAT, &step.name)),
|
||||
Procedure::Static {} => {
|
||||
d.line("*CONTROL_IMPLICIT_SOLUTION");
|
||||
d.header(&["nsolvr"]);
|
||||
|
||||
@@ -60,6 +60,7 @@ pub fn write(model: &Model) -> Result<String> {
|
||||
|
||||
/// [`write`] for a model already resolved.
|
||||
pub fn write_resolved(model: &Model, r: &Resolved) -> Result<String> {
|
||||
crate::refuse_fluid(FORMAT, model)?;
|
||||
let mut deck = Deck::new(model, r, model.steps().iter().collect());
|
||||
deck.plan()?;
|
||||
deck.emit()
|
||||
|
||||
@@ -15,7 +15,7 @@ use modelcraft_model::{
|
||||
// Models
|
||||
|
||||
fn steel() -> Material {
|
||||
Material { elastic: Some(Elastic::Isotropic { young: 210_000.0, poisson: 0.3 }), density: Some(7.85e-9) }
|
||||
Material { elastic: Some(Elastic::Isotropic { young: 210_000.0, poisson: 0.3 }), density: Some(7.85e-9), fluid: None }
|
||||
}
|
||||
|
||||
fn faces(list: &[(u32, u8)]) -> Set {
|
||||
@@ -91,7 +91,7 @@ fn plate() -> Model {
|
||||
m.add_set("top", faces(&[(1, 1), (2, 1), (3, 1), (4, 1)])).unwrap();
|
||||
m.add_set("edge", Set { nodes: vec![1, 4, 7], ..Set::default() }).unwrap();
|
||||
m.add_set("mid", Set { nodes: vec![2, 5, 8], ..Set::default() }).unwrap();
|
||||
let aluminium = Material { elastic: Some(Elastic::Isotropic { young: 70_000.0, poisson: 0.33 }), density: Some(2.7e-9) };
|
||||
let aluminium = Material { elastic: Some(Elastic::Isotropic { young: 70_000.0, poisson: 0.33 }), density: Some(2.7e-9), fluid: None };
|
||||
m.add_material("Aluminium", aluminium).unwrap();
|
||||
let kind = SectionKind::Shell { thickness: 2.0, offset: 0.5, points: 5 };
|
||||
let formulation = Formulation { integration: Integration::Reduced, ..Formulation::default() };
|
||||
@@ -523,7 +523,8 @@ fn largest_labels_longest_numbers_and_names_fit_a_line() {
|
||||
m.set_element(modelcraft_model::MAX_LABEL, ElementKind::Hex20, (0..20).map(|i| base + i).collect()).unwrap();
|
||||
let long = format!("A{}", "x".repeat(modelcraft_model::MAX_NAME_LEN - 1));
|
||||
m.add_set(&long, Set { elements: vec![modelcraft_model::MAX_LABEL], nodes: (0..20).map(|i| base + i).collect(), ..Set::default() }).unwrap();
|
||||
m.add_material(&long, Material { elastic: Some(Elastic::Isotropic { young: ugly.abs(), poisson: 0.3 }), density: Some(1.0) }).unwrap();
|
||||
m.add_material(&long, Material { elastic: Some(Elastic::Isotropic { young: ugly.abs(), poisson: 0.3 }), density: Some(1.0), fluid: None })
|
||||
.unwrap();
|
||||
m.add_section(&long, Section { set: long.clone(), material: long.clone(), kind: SectionKind::Solid {}, formulation: Formulation::default() })
|
||||
.unwrap();
|
||||
m.add_reference_point(&long, ReferencePoint { xyz: [ugly; 3], label: 0 }).unwrap();
|
||||
@@ -639,7 +640,7 @@ fn what_abaqus_cannot_express_is_named() {
|
||||
|
||||
let mut ortho = plate();
|
||||
let carbon = Elastic::EngineeringConstants { young: [140e3, 10e3, 10e3], poisson: [0.3, 0.3, 0.4], shear: [5e3, 5e3, 3.8e3] };
|
||||
ortho.replace_material("Aluminium", Material { elastic: Some(carbon), density: Some(1.6e-9) }).unwrap();
|
||||
ortho.replace_material("Aluminium", Material { elastic: Some(carbon), density: Some(1.6e-9), fluid: None }).unwrap();
|
||||
assert!(unsupported(&ortho).contains("orthotropic material `Aluminium` in a shell section"));
|
||||
|
||||
let mut loose = frequency();
|
||||
@@ -657,7 +658,7 @@ fn what_abaqus_cannot_express_is_named() {
|
||||
fn orthotropic_solids_beams_and_rods_are_written() {
|
||||
let mut m = cantilever();
|
||||
let carbon = Elastic::EngineeringConstants { young: [140e3, 10e3, 10e3], poisson: [0.3, 0.3, 0.4], shear: [5e3, 5e3, 3.8e3] };
|
||||
m.replace_material("Steel", Material { elastic: Some(carbon), density: None }).unwrap();
|
||||
m.replace_material("Steel", Material { elastic: Some(carbon), density: None, fluid: None }).unwrap();
|
||||
let deck = abaqus::write(&m).unwrap();
|
||||
assert!(deck.contains("*ELASTIC, TYPE=ENGINEERING CONSTANTS\n140000.0, 10000.0, 10000.0, 0.3, 0.3, 0.4, 5000.0, 5000.0\n3800.0\n"), "{deck}");
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ use modelcraft_model::*;
|
||||
// Our own models.
|
||||
|
||||
fn steel() -> Material {
|
||||
Material { elastic: Some(Elastic::Isotropic { young: 210000.0, poisson: 0.3 }), density: Some(7.85e-9) }
|
||||
Material { elastic: Some(Elastic::Isotropic { young: 210000.0, poisson: 0.3 }), density: Some(7.85e-9), fluid: None }
|
||||
}
|
||||
|
||||
fn step(name: &str, procedure: Procedure, bcs: &[&str], loads: &[&str]) -> Step {
|
||||
@@ -72,7 +72,7 @@ fn cantilever_modes() -> Model {
|
||||
let mut m = Model::default();
|
||||
cantilever_mesh(&mut m);
|
||||
let ortho = Elastic::EngineeringConstants { young: [140000.0, 10000.0, 10000.0], poisson: [0.3, 0.3, 0.45], shear: [5000.0, 5000.0, 3500.0] };
|
||||
m.add_material("Ply", Material { elastic: Some(ortho), density: Some(1.6e-9) }).unwrap();
|
||||
m.add_material("Ply", Material { elastic: Some(ortho), density: Some(1.6e-9), fluid: None }).unwrap();
|
||||
let f = Formulation { incompatible_modes: true, ..Formulation::default() };
|
||||
m.add_section("beam", Section { set: "all".into(), material: "Ply".into(), kind: SectionKind::Solid {}, formulation: f }).unwrap();
|
||||
m.add_reference_point("RP", ReferencePoint { xyz: [5.0, 0.5, 0.5], label: 0 }).unwrap();
|
||||
@@ -155,7 +155,8 @@ fn solids() -> Model {
|
||||
let h: Vec<u32> = h.iter().map(|p| node(&mut m, *p)).collect();
|
||||
m.set_element(4, ElementKind::Hex20, h).unwrap();
|
||||
|
||||
m.add_material("Aluminium", Material { elastic: Some(Elastic::Isotropic { young: 70e9, poisson: 0.33 }), density: Some(2700.0) }).unwrap();
|
||||
m.add_material("Aluminium", Material { elastic: Some(Elastic::Isotropic { young: 70e9, poisson: 0.33 }), density: Some(2700.0), fluid: None })
|
||||
.unwrap();
|
||||
for (e, name) in [(1, "tet4"), (2, "tet10"), (3, "wedge"), (4, "hex20")] {
|
||||
m.add_set(name, Set { elements: vec![e], ..Set::default() }).unwrap();
|
||||
let f = Formulation { hybrid: e == 1, ..Formulation::default() };
|
||||
@@ -657,7 +658,7 @@ fn hostile_numbers_stay_within_their_fields() {
|
||||
m.set_node(2, [f64::MIN_POSITIVE, -f64::MIN_POSITIVE, 5e-324]).unwrap();
|
||||
m.replace_material(
|
||||
"Steel",
|
||||
Material { elastic: Some(Elastic::Isotropic { young: 1e300, poisson: 0.499_999_999_999_999_9 }), density: Some(1e-300) },
|
||||
Material { elastic: Some(Elastic::Isotropic { young: 1e300, poisson: 0.499_999_999_999_999_9 }), density: Some(1e-300), fluid: None },
|
||||
)
|
||||
.unwrap();
|
||||
m.replace_load(
|
||||
|
||||
@@ -131,7 +131,7 @@ impl Read {
|
||||
// Our own models.
|
||||
|
||||
fn steel() -> Material {
|
||||
Material { elastic: Some(Elastic::Isotropic { young: 210_000.0, poisson: 0.3 }), density: Some(7.85e-9) }
|
||||
Material { elastic: Some(Elastic::Isotropic { young: 210_000.0, poisson: 0.3 }), density: Some(7.85e-9), fluid: None }
|
||||
}
|
||||
|
||||
/// A bar of `n` unit hex8 along X; node (i, y, z) = 1 + 4i + 2y + z.
|
||||
@@ -188,7 +188,11 @@ fn plate() -> Model {
|
||||
m.add_set("all", Set { elements: vec![1, 2, 3, 4], ..Set::default() }).unwrap();
|
||||
m.add_set("edge", Set { nodes: vec![1, 4, 7], ..Set::default() }).unwrap();
|
||||
m.add_set("top", Set { faces: (1..=4).map(|element| ElementFace { element, face: 1 }).collect(), ..Set::default() }).unwrap();
|
||||
m.add_material("Aluminium", Material { elastic: Some(Elastic::Isotropic { young: 70_000.0, poisson: 0.33 }), density: Some(2.7e-9) }).unwrap();
|
||||
m.add_material(
|
||||
"Aluminium",
|
||||
Material { elastic: Some(Elastic::Isotropic { young: 70_000.0, poisson: 0.33 }), density: Some(2.7e-9), fluid: None },
|
||||
)
|
||||
.unwrap();
|
||||
let shell = SectionKind::Shell { thickness: 2.0, offset: 0.0, points: 5 };
|
||||
m.add_section("skin", Section { set: "all".into(), material: "Aluminium".into(), kind: shell, formulation: Formulation::default() }).unwrap();
|
||||
m.add_boundary_condition("clamp", BoundaryCondition::Fixed { target: Target::Set("edge".into()) }).unwrap();
|
||||
@@ -517,6 +521,7 @@ fn beams_rods_orthotropy_couplings_and_prescribed_motion() {
|
||||
let ortho = Material {
|
||||
elastic: Some(Elastic::EngineeringConstants { young: [140e3, 10e3, 12e3], poisson: [0.3, 0.28, 0.4], shear: [5e3, 4.5e3, 3.8e3] }),
|
||||
density: Some(1.6e-9),
|
||||
fluid: None,
|
||||
};
|
||||
m.add_material("Carbon", ortho.clone()).unwrap();
|
||||
m.replace_section(
|
||||
@@ -671,7 +676,7 @@ fn what_nastran_cannot_express_is_named() {
|
||||
// Orthotropic beams, tilted orthotropic shells, one-point shells.
|
||||
let mut m = plate();
|
||||
let ortho = Elastic::EngineeringConstants { young: [140e3, 10e3, 10e3], poisson: [0.3, 0.3, 0.4], shear: [5e3, 5e3, 3.8e3] };
|
||||
m.replace_material("Aluminium", Material { elastic: Some(ortho), density: Some(1.6e-9) }).unwrap();
|
||||
m.replace_material("Aluminium", Material { elastic: Some(ortho), density: Some(1.6e-9), fluid: None }).unwrap();
|
||||
m.set_node(9, [10.0, 10.0, 0.5]).unwrap();
|
||||
assert!(unsupported(&m).contains("planes of constant Z"));
|
||||
m.set_node(9, [10.0, 10.0, 0.0]).unwrap();
|
||||
@@ -690,7 +695,7 @@ fn what_nastran_cannot_express_is_named() {
|
||||
let mut m = plate();
|
||||
m.set_element(9, ElementKind::Rod2, vec![1, 9]).unwrap();
|
||||
m.add_set("bars", Set { elements: vec![9], ..Set::default() }).unwrap();
|
||||
m.add_material("Fibre", Material { elastic: Some(ortho), density: Some(1.6e-9) }).unwrap();
|
||||
m.add_material("Fibre", Material { elastic: Some(ortho), density: Some(1.6e-9), fluid: None }).unwrap();
|
||||
m.add_section(
|
||||
"bars",
|
||||
Section { set: "bars".into(), material: "Fibre".into(), kind: SectionKind::Rod { area: 1.0 }, formulation: Formulation::default() },
|
||||
|
||||
@@ -17,6 +17,7 @@ modelcraft-decks = { workspace = true }
|
||||
modelcraft-solver = { workspace = true }
|
||||
modelcraft-explicit = { workspace = true }
|
||||
modelcraft-fem = { workspace = true }
|
||||
modelcraft-lbm = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}]}),
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -77,7 +77,8 @@ impl Bar {
|
||||
}
|
||||
}
|
||||
m.add_set("end_face", Set { faces, ..Set::default() }).unwrap();
|
||||
m.add_material("Mat", Material { elastic: Some(Elastic::Isotropic { young: YOUNG, poisson: 0.0 }), density: Some(DENSITY) }).unwrap();
|
||||
m.add_material("Mat", Material { elastic: Some(Elastic::Isotropic { young: YOUNG, poisson: 0.0 }), density: Some(DENSITY), fluid: None })
|
||||
.unwrap();
|
||||
let integration = if kind == ElementKind::Hex8 { Integration::Reduced } else { Integration::Full };
|
||||
let formulation = Formulation { integration, ..Formulation::default() };
|
||||
m.add_section("bar", Section { set: "all".into(), material: "Mat".into(), kind: SectionKind::Solid {}, formulation }).unwrap();
|
||||
@@ -333,10 +334,13 @@ fn caps_and_unsupported_models_are_errors() {
|
||||
assert!(solve_explicit(&full.model, None, &Limits::default()).unwrap_err().to_string().contains("not supported yet"));
|
||||
let mut ortho = Bar::new(ElementKind::Tet4, 4, ExplicitDynamic::new(end));
|
||||
let constants = Elastic::EngineeringConstants { young: [YOUNG; 3], poisson: [0.0; 3], shear: [YOUNG / 2.0; 3] };
|
||||
ortho.model.replace_material("Mat", Material { elastic: Some(constants), density: Some(DENSITY) }).unwrap();
|
||||
ortho.model.replace_material("Mat", Material { elastic: Some(constants), density: Some(DENSITY), fluid: None }).unwrap();
|
||||
assert!(solve_explicit(&ortho.model, None, &Limits::default()).unwrap_err().to_string().contains("orthotropic"));
|
||||
let mut light = Bar::new(ElementKind::Tet4, 4, ExplicitDynamic::new(end));
|
||||
light.model.replace_material("Mat", Material { elastic: Some(Elastic::Isotropic { young: YOUNG, poisson: 0.0 }), density: None }).unwrap();
|
||||
light
|
||||
.model
|
||||
.replace_material("Mat", Material { elastic: Some(Elastic::Isotropic { young: YOUNG, poisson: 0.0 }), density: None, fluid: None })
|
||||
.unwrap();
|
||||
assert!(solve_explicit(&light.model, None, &Limits::default()).is_err());
|
||||
// A node behind its wall at the start.
|
||||
let mut behind = ExplicitDynamic::new(end);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "modelcraft-lbm"
|
||||
description = "ModelCraft's lattice Boltzmann flow solver: D3Q19 two-relaxation-time collision, bounce-back walls, Zou–He inlets and outlets, exact voxelisation of face-tagged surfaces, momentum-exchange wall forces"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
modelcraft-model = { workspace = true }
|
||||
modelcraft-mesh = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
@@ -0,0 +1,598 @@
|
||||
//! The lattice Boltzmann state and one time step: collide, stream, open boundaries.
|
||||
//!
|
||||
//! # Scheme
|
||||
//! - **Lattice and equilibrium:** D3Q19 with the second-order equilibrium
|
||||
//! (Qian, d'Humières & Lallemand 1992; Krüger et al. 2017, ch. 3).
|
||||
//! - **Collision:** two relaxation times (Ginzburg, Verhaeghe & d'Humières 2008): the symmetric
|
||||
//! part of each opposite pair relaxes with `1/τ⁺` (which sets the viscosity), the antisymmetric
|
||||
//! part with `1/τ⁻`, related by the parameter `Λ = (τ⁺ − ½)(τ⁻ − ½)`. With `Λ = 3/16`
|
||||
//! halfway bounce-back puts the wall exactly halfway between nodes and steady solutions do not
|
||||
//! depend on the viscosity.
|
||||
//! - **Body force:** Guo, Zheng & Shi 2002 (PRE 65:046308): the velocity is
|
||||
//! `(Σ f c + F/2) / ρ` and the forcing term is split into its symmetric part, relaxed with
|
||||
//! `1 − 1/(2τ⁺)`, and its antisymmetric part, with `1 − 1/(2τ⁻)` (the TRT form of Guo's
|
||||
//! term, Krüger et al. 2017, ch. 10).
|
||||
//! - **Walls:** halfway bounce-back at every link from a fluid cell to a solid cell or across a
|
||||
//! wall face of the box; moving walls add Ladd's term `6 w_q ρ₀ c_q·u_w` (Ladd 1994, JFM
|
||||
//! 271:285). Slip faces reflect populations specularly.
|
||||
//! - **Inlets and outlets:** Zou & He 1997 (Phys. Fluids 9:1591) on the first layer of cells: the
|
||||
//! unknown incoming populations are the opposite ones plus the equilibrium difference
|
||||
//! (bounce-back of the non-equilibrium part), the density (velocity inlet) or the normal velocity
|
||||
//! (pressure outlet) follow from mass and normal momentum, and the transverse momentum is
|
||||
//! corrected on the diagonal unknowns as M. Hecht and J. Harting do for D3Q19 (*Implementation
|
||||
//! of on-site velocity boundary conditions for D3Q19 lattice Boltzmann simulations*, J. Stat.
|
||||
//! Mech. 2010, P01018). The prescribed velocity is the one with the half force of Guo's scheme.
|
||||
//! - **Forces:** momentum exchange on every wall link (Ladd 1994; R. Mei, D. Yu, W. Shyy, L.-S.
|
||||
//! Luo, *Force evaluation in the lattice Boltzmann method involving curved geometry*, PRE
|
||||
//! 65:041203, 2002): a population `f*` leaving a fluid cell toward the wall and the one `f'`
|
||||
//! coming back transfer `c_out f* − c_in f'` to the wall.
|
||||
//!
|
||||
//! # Layout and determinism
|
||||
//! Two arrays of populations, structure of arrays (`f[q n + cell]`), A–B pattern. Streaming
|
||||
//! pulls: every population of a cell comes from one source (a neighbour, a periodic image, the
|
||||
//! cell itself through a wall, or an open boundary). Cells whose 18 neighbours are all fluid
|
||||
//! cells inside the box stream by one shifted copy per direction; the others follow per-link
|
||||
//! rules. Every loop runs in a fixed order with no parallel reductions, so the result is the
|
||||
//! same bit for bit on every platform.
|
||||
|
||||
use modelcraft_model::{BoxFace, FaceCondition, Profile};
|
||||
|
||||
use crate::lattice::{C, Q, W, c, equilibrium, moving_wall, opposite};
|
||||
use crate::setup::{MAX_LATTICE_SPEED, Obstacle, Setup};
|
||||
use crate::voxel::{Grid, TriangleTree, voxelise};
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Where a population of a cell next to a wall or box face comes from.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum Rule {
|
||||
/// The same direction from another cell (a neighbour or a periodic image).
|
||||
Pull { from: u32 },
|
||||
/// Bounce-back at a wall of force group `group`; `du` is the moving-wall term.
|
||||
Bounce { group: u32, du: f64 },
|
||||
/// Specular reflection at a slip face: direction `q` from cell `from`.
|
||||
Reflect { from: u32, q: u8, group: u32 },
|
||||
/// From outside an inlet or outlet: set by the open boundary after streaming.
|
||||
Open,
|
||||
}
|
||||
|
||||
/// A population, direction `q` of `cell`, and where it comes from.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Link {
|
||||
cell: u32,
|
||||
q: u8,
|
||||
rule: Rule,
|
||||
}
|
||||
|
||||
/// An inlet or outlet cell.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct OpenCell {
|
||||
cell: u32,
|
||||
axis: usize,
|
||||
/// +1 at a min face (the inward normal points up the axis), −1 at a max face.
|
||||
inward: f64,
|
||||
target: Target,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum Target {
|
||||
/// The velocity (lattice units).
|
||||
Velocity([f64; 3]),
|
||||
/// The density.
|
||||
Density(f64),
|
||||
}
|
||||
|
||||
/// A force group: a box face or a face of an obstacle.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Group {
|
||||
/// `x_min` … for box faces; the face name for obstacle faces.
|
||||
pub name: String,
|
||||
/// The obstacle body, for obstacle faces.
|
||||
pub body: Option<String>,
|
||||
}
|
||||
|
||||
/// The flow state.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Flow {
|
||||
grid: Grid,
|
||||
n: usize,
|
||||
/// The distance between two directions' arrays in `f` and `g` (at least `n`).
|
||||
stride: usize,
|
||||
fluid: Vec<bool>,
|
||||
f: Vec<f64>,
|
||||
g: Vec<f64>,
|
||||
/// The links that do not stream by the shifted copies, in cell order.
|
||||
links: Vec<Link>,
|
||||
open: Vec<OpenCell>,
|
||||
omega_plus: f64,
|
||||
omega_minus: f64,
|
||||
acceleration: [f64; 3],
|
||||
/// The momentum transferred to each group in the last step (lattice units).
|
||||
forces: Vec<[f64; 3]>,
|
||||
groups: Vec<Group>,
|
||||
/// Per direction, the index offset of the neighbour a population streams from.
|
||||
offset: [isize; Q],
|
||||
fluid_cells: usize,
|
||||
steps_done: u64,
|
||||
/// Obstacles that cover no cell centre (smaller than the spacing, or outside the box).
|
||||
empty_obstacles: Vec<String>,
|
||||
}
|
||||
|
||||
const NONE: u32 = u32::MAX;
|
||||
|
||||
/// A vector allocated without aborting when memory runs out.
|
||||
fn filled<T: Clone>(len: usize, value: T, what: &str) -> Result<Vec<T>> {
|
||||
let mut v = Vec::new();
|
||||
v.try_reserve_exact(len).map_err(|_| Error::Limit(format!("not enough memory for the {what} ({len} entries)")))?;
|
||||
v.resize(len, value);
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
impl Flow {
|
||||
/// Build the grid (voxelising the obstacles), the streaming rules and the initial state (at
|
||||
/// rest or the domain's initial velocity, density 1).
|
||||
pub fn new(setup: &Setup, obstacles: &[Obstacle]) -> Result<Flow> {
|
||||
let grid = setup.grid;
|
||||
let [nx, ny, nz] = grid.cells;
|
||||
let n = grid.len();
|
||||
if n == 0 || n >= NONE as usize {
|
||||
return Err(Error::Limit(format!("{n} cells cannot be addressed")));
|
||||
}
|
||||
// Each direction's array starts an odd number of cache lines (8 doubles) after the
|
||||
// previous one, so the 19 arrays never fall into the same cache sets (a power-of-two grid
|
||||
// would otherwise put them all in one).
|
||||
let lines = n.div_ceil(8);
|
||||
let stride = 8 * (lines + 1 - lines % 2);
|
||||
let q_n = stride.checked_mul(Q).ok_or_else(|| Error::Limit("the grid is too large".into()))?;
|
||||
|
||||
// Solid cells, obstacle by obstacle (the first obstacle that covers a cell owns it).
|
||||
let mut fluid = filled(n, true, "cell flags")?;
|
||||
let mut owner = filled(n, NONE, "cell owners")?;
|
||||
let mut trees = Vec::with_capacity(setup.obstacles.len());
|
||||
let mut empty_obstacles = Vec::new();
|
||||
// Force groups: the six box faces, then every face of every obstacle.
|
||||
let mut groups: Vec<Group> = BoxFace::ALL.iter().map(|f| Group { name: f.name().to_string(), body: None }).collect();
|
||||
let mut base = Vec::with_capacity(setup.obstacles.len());
|
||||
for (k, faces) in setup.obstacles.iter().enumerate() {
|
||||
let o = obstacles.get(faces.index).ok_or_else(|| Error::NotFound(format!("obstacle {}", faces.index)))?;
|
||||
let mut inside = filled(n, false, "obstacle cells")?;
|
||||
if voxelise(&o.surface, &grid, &mut inside)? == 0 {
|
||||
empty_obstacles.push(o.body.clone());
|
||||
}
|
||||
for (c, &solid) in inside.iter().enumerate() {
|
||||
if solid && owner[c] == NONE {
|
||||
owner[c] = k as u32;
|
||||
fluid[c] = false;
|
||||
}
|
||||
}
|
||||
trees.push((TriangleTree::new(&o.surface), o.surface.tri_face()));
|
||||
base.push(groups.len() as u32);
|
||||
groups.extend(faces.names.iter().map(|name| Group { name: name.clone(), body: Some(o.body.clone()) }));
|
||||
}
|
||||
|
||||
let domain = &setup.domain;
|
||||
let periodic = [domain.faces.periodic(0), domain.faces.periodic(1), domain.faces.periodic(2)];
|
||||
let wall_velocity = |face: BoxFace| match *domain.faces.get(face) {
|
||||
FaceCondition::MovingWall { velocity } => Some(setup.to_lattice(velocity)),
|
||||
FaceCondition::Wall {} => Some([0.0; 3]),
|
||||
_ => None,
|
||||
};
|
||||
let is_slip = |face: BoxFace| matches!(domain.faces.get(face), FaceCondition::Slip {});
|
||||
let dims = [nx as i64, ny as i64, nz as i64];
|
||||
let index = |p: [i64; 3]| grid.index([p[0] as usize, p[1] as usize, p[2] as usize]) as u32;
|
||||
let mut offset = [0isize; Q];
|
||||
for (q, o) in offset.iter_mut().enumerate() {
|
||||
let cq = C[q];
|
||||
*o = cq[0] as isize + nx as isize * (cq[1] as isize + ny as isize * cq[2] as isize);
|
||||
}
|
||||
|
||||
// The group and moving-wall term of a link into fluid cell `cell` (direction q) from the
|
||||
// obstacle cell `solid`: the face of the triangle nearest the link's midpoint.
|
||||
let obstacle_link = |cell: usize, solid: u32, q: usize| -> Rule {
|
||||
let k = owner.get(solid as usize).copied().unwrap_or(NONE) as usize;
|
||||
let (Some((tree, tags)), Some(faces), Some(&first)) = (trees.get(k), setup.obstacles.get(k), base.get(k)) else {
|
||||
return Rule::Bounce { group: 0, du: 0.0 };
|
||||
};
|
||||
let (centre, cq) = (grid.centre(cell), c(q));
|
||||
let mid = [0, 1, 2].map(|a| centre[a] - 0.5 * cq[a] * grid.spacing);
|
||||
let tag = tree.nearest(mid).and_then(|t| tags.get(t as usize).copied()).unwrap_or(0);
|
||||
let u = faces.velocities.get(tag as usize).copied().unwrap_or([0.0; 3]);
|
||||
Rule::Bounce { group: first + tag, du: moving_wall(q, u) }
|
||||
};
|
||||
|
||||
// The links that do not stream from the neighbour the shifted copies read.
|
||||
let mut links: Vec<Link> = Vec::new();
|
||||
let mut fluid_cells = 0;
|
||||
for cell in 0..n {
|
||||
if !fluid[cell] {
|
||||
continue;
|
||||
}
|
||||
fluid_cells += 1;
|
||||
let ijk = grid.coords(cell);
|
||||
let p = [ijk[0] as i64, ijk[1] as i64, ijk[2] as i64];
|
||||
for q in 1..Q {
|
||||
let cq = C[q];
|
||||
let mut s = [p[0] - i64::from(cq[0]), p[1] - i64::from(cq[1]), p[2] - i64::from(cq[2])];
|
||||
let mut wrapped = false;
|
||||
let mut crossed: [Option<BoxFace>; 3] = [None; 3];
|
||||
for a in 0..3 {
|
||||
if s[a] < 0 || s[a] >= dims[a] {
|
||||
if periodic[a] {
|
||||
s[a] = s[a].rem_euclid(dims[a]);
|
||||
wrapped = true;
|
||||
} else {
|
||||
crossed[a] = Some(BoxFace::of(a, s[a] >= dims[a]));
|
||||
}
|
||||
}
|
||||
}
|
||||
let crossed_faces = || crossed.iter().flatten().copied();
|
||||
let rule = match crossed_faces().next() {
|
||||
None => {
|
||||
let from = index(s);
|
||||
match (fluid.get(from as usize).copied().unwrap_or(false), wrapped) {
|
||||
(true, false) => continue,
|
||||
(true, true) => Rule::Pull { from },
|
||||
(false, _) => obstacle_link(cell, from, q),
|
||||
}
|
||||
}
|
||||
Some(_) => {
|
||||
if let Some(face) = crossed_faces().find(|f| wall_velocity(*f).is_some()) {
|
||||
Rule::Bounce { group: face.index() as u32, du: moving_wall(q, wall_velocity(face).unwrap_or([0.0; 3])) }
|
||||
} else if let Some(face) = crossed_faces().find(|f| is_slip(*f)) {
|
||||
// The mirror source: the same cell layer, the tangential components shifted.
|
||||
let a = face.axis();
|
||||
let mut m = s;
|
||||
m[a] = p[a];
|
||||
let mut mirrored = C[q];
|
||||
mirrored[a] = -mirrored[a];
|
||||
let q2 = C.iter().position(|v| *v == mirrored).unwrap_or(opposite(q));
|
||||
let single = crossed_faces().count() == 1;
|
||||
let inside = (0..3).all(|b| m[b] >= 0 && m[b] < dims[b]);
|
||||
match inside.then(|| index(m)) {
|
||||
Some(from) if single && fluid.get(from as usize).copied().unwrap_or(false) => {
|
||||
Rule::Reflect { from, q: q2 as u8, group: face.index() as u32 }
|
||||
}
|
||||
_ => Rule::Bounce { group: face.index() as u32, du: 0.0 },
|
||||
}
|
||||
} else {
|
||||
Rule::Open
|
||||
}
|
||||
}
|
||||
};
|
||||
links.push(Link { cell: cell as u32, q: q as u8, rule });
|
||||
}
|
||||
}
|
||||
|
||||
// Inlets and outlets: the fluid cells of each open face's layer.
|
||||
let mut open = Vec::new();
|
||||
for face in BoxFace::ALL {
|
||||
let a = face.axis();
|
||||
let layer = if face.is_max() { grid.cells[a] - 1 } else { 0 };
|
||||
let (t1, t2) = ((a + 1) % 3, (a + 2) % 3);
|
||||
for j in 0..grid.cells[t2] {
|
||||
for i in 0..grid.cells[t1] {
|
||||
let mut ijk = [0usize; 3];
|
||||
ijk[a] = layer;
|
||||
ijk[t1] = i;
|
||||
ijk[t2] = j;
|
||||
let cell = grid.index(ijk);
|
||||
if !fluid[cell] {
|
||||
continue;
|
||||
}
|
||||
let target = match *domain.faces.get(face) {
|
||||
FaceCondition::Velocity { velocity, profile } => {
|
||||
let mut scale = 1.0;
|
||||
if profile == Profile::Parabolic {
|
||||
for t in [t1, t2].into_iter().filter(|t| !periodic[*t]) {
|
||||
let s = (ijk[t] as f64 + 0.5) / grid.cells[t] as f64;
|
||||
scale *= 4.0 * s * (1.0 - s);
|
||||
}
|
||||
}
|
||||
Target::Velocity(setup.to_lattice(velocity).map(|v| v * scale))
|
||||
}
|
||||
FaceCondition::Pressure { pressure } => Target::Density(setup.lattice_density(pressure)),
|
||||
_ => break,
|
||||
};
|
||||
open.push(OpenCell { cell: cell as u32, axis: a, inward: if face.is_max() { -1.0 } else { 1.0 }, target });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The initial state: equilibrium at density 1 and the initial velocity.
|
||||
let mut f = filled(q_n, 0.0, "populations")?;
|
||||
let g = filled(q_n, 0.0, "populations")?;
|
||||
for cell in 0..n {
|
||||
for q in 0..Q {
|
||||
f[q * stride + cell] = if fluid[cell] { equilibrium(q, 1.0, setup.initial_velocity) } else { 0.0 };
|
||||
}
|
||||
}
|
||||
Ok(Flow {
|
||||
grid,
|
||||
n,
|
||||
stride,
|
||||
fluid,
|
||||
f,
|
||||
g,
|
||||
links,
|
||||
open,
|
||||
omega_plus: 1.0 / setup.tau_plus,
|
||||
omega_minus: 1.0 / setup.tau_minus,
|
||||
acceleration: setup.acceleration,
|
||||
forces: vec![[0.0; 3]; groups.len()],
|
||||
groups,
|
||||
offset,
|
||||
fluid_cells,
|
||||
steps_done: 0,
|
||||
empty_obstacles,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn grid(&self) -> &Grid {
|
||||
&self.grid
|
||||
}
|
||||
|
||||
pub fn fluid_cells(&self) -> usize {
|
||||
self.fluid_cells
|
||||
}
|
||||
|
||||
pub fn is_fluid(&self, cell: usize) -> bool {
|
||||
self.fluid.get(cell).copied().unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn groups(&self) -> &[Group] {
|
||||
&self.groups
|
||||
}
|
||||
|
||||
/// The momentum each group received in the last step (lattice units: force per step).
|
||||
pub fn forces(&self) -> &[[f64; 3]] {
|
||||
&self.forces
|
||||
}
|
||||
|
||||
/// Obstacles that cover no cell centre.
|
||||
pub fn empty_obstacles(&self) -> &[String] {
|
||||
&self.empty_obstacles
|
||||
}
|
||||
|
||||
pub fn steps_done(&self) -> u64 {
|
||||
self.steps_done
|
||||
}
|
||||
|
||||
/// Set every fluid cell to the equilibrium of `state(cell) = (density, velocity)` in lattice
|
||||
/// units (velocity as defined with the half force).
|
||||
pub fn set_equilibrium(&mut self, state: impl Fn(usize) -> (f64, [f64; 3])) {
|
||||
let n = self.n;
|
||||
let s = self.stride;
|
||||
for cell in 0..n {
|
||||
if !self.fluid[cell] {
|
||||
continue;
|
||||
}
|
||||
let (rho, u) = state(cell);
|
||||
let bare = [0, 1, 2].map(|a| u[a] - 0.5 * self.acceleration[a]);
|
||||
for q in 0..Q {
|
||||
self.f[q * s + cell] = equilibrium(q, rho, bare);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The density and velocity of a cell (lattice units), or `None` for a solid cell.
|
||||
pub fn moments(&self, cell: usize) -> Option<(f64, [f64; 3])> {
|
||||
if !self.is_fluid(cell) {
|
||||
return None;
|
||||
}
|
||||
let s = self.stride;
|
||||
let (mut rho, mut j) = (0.0, [0.0; 3]);
|
||||
for q in 0..Q {
|
||||
let fq = self.f.get(q * s + cell).copied().unwrap_or(0.0);
|
||||
let cq = c(q);
|
||||
rho += fq;
|
||||
j[0] += fq * cq[0];
|
||||
j[1] += fq * cq[1];
|
||||
j[2] += fq * cq[2];
|
||||
}
|
||||
let u = [0, 1, 2].map(|a| (j[a] + 0.5 * rho * self.acceleration[a]) / rho);
|
||||
Some((rho, u))
|
||||
}
|
||||
|
||||
/// The total mass of the fluid cells (lattice units: the sum of every population).
|
||||
pub fn mass(&self) -> f64 {
|
||||
let n = self.n;
|
||||
let s = self.stride;
|
||||
let mut m = 0.0;
|
||||
for cell in (0..n).filter(|c| self.fluid[*c]) {
|
||||
for q in 0..Q {
|
||||
m += self.f[q * s + cell];
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// One time step. Fails with [`Error::Unstable`] when a density stops being a positive
|
||||
/// number or a lattice speed exceeds [`MAX_LATTICE_SPEED`].
|
||||
pub fn step(&mut self) -> Result<()> {
|
||||
let max_u2 = self.collide();
|
||||
if !(max_u2 <= MAX_LATTICE_SPEED * MAX_LATTICE_SPEED) {
|
||||
return Err(Error::Unstable(format!(
|
||||
"the flow went unstable at time step {}: the lattice speed reached {:.3} (limit {MAX_LATTICE_SPEED}) or a density stopped being a positive number; lower the time step, refine the spacing or check the boundary conditions",
|
||||
self.steps_done + 1,
|
||||
max_u2.sqrt()
|
||||
)));
|
||||
}
|
||||
self.stream();
|
||||
self.open_boundaries();
|
||||
std::mem::swap(&mut self.f, &mut self.g);
|
||||
self.steps_done += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Two-relaxation-time collision with Guo forcing, in place. Returns the largest squared
|
||||
/// lattice speed, or NaN when a density is not positive.
|
||||
fn collide(&mut self) -> f64 {
|
||||
let n = self.n;
|
||||
let s = self.stride;
|
||||
let (wp, wm) = (self.omega_plus, self.omega_minus);
|
||||
let (sp, sm) = (1.0 - 0.5 * wp, 1.0 - 0.5 * wm);
|
||||
let a = self.acceleration;
|
||||
let forced = a != [0.0; 3];
|
||||
let mut max_u2: f64 = 0.0;
|
||||
let mut fq = [0.0; Q];
|
||||
for cell in 0..n {
|
||||
if !self.fluid[cell] {
|
||||
continue;
|
||||
}
|
||||
let (mut rho, mut j) = (0.0, [0.0; 3]);
|
||||
for q in 0..Q {
|
||||
let v = self.f[q * s + cell];
|
||||
fq[q] = v;
|
||||
let cq = C[q];
|
||||
rho += v;
|
||||
j[0] += v * f64::from(cq[0]);
|
||||
j[1] += v * f64::from(cq[1]);
|
||||
j[2] += v * f64::from(cq[2]);
|
||||
}
|
||||
if !(rho > 0.0 && rho.is_finite()) {
|
||||
return f64::NAN;
|
||||
}
|
||||
let force = [rho * a[0], rho * a[1], rho * a[2]];
|
||||
let u = [(j[0] + 0.5 * force[0]) / rho, (j[1] + 0.5 * force[1]) / rho, (j[2] + 0.5 * force[2]) / rho];
|
||||
let uu = u[0] * u[0] + u[1] * u[1] + u[2] * u[2];
|
||||
max_u2 = max_u2.max(uu);
|
||||
let uf = u[0] * force[0] + u[1] * force[1] + u[2] * force[2];
|
||||
// Rest population: symmetric only.
|
||||
let eq0 = W[0] * rho * (1.0 - 1.5 * uu);
|
||||
let mut out0 = fq[0] - wp * (fq[0] - eq0);
|
||||
if forced {
|
||||
out0 += sp * W[0] * (-3.0 * uf);
|
||||
}
|
||||
self.f[cell] = out0;
|
||||
for q in (1..Q).step_by(2) {
|
||||
let o = q + 1;
|
||||
let cq = C[q];
|
||||
let cv = [f64::from(cq[0]), f64::from(cq[1]), f64::from(cq[2])];
|
||||
let cu = cv[0] * u[0] + cv[1] * u[1] + cv[2] * u[2];
|
||||
let w = W[q];
|
||||
let eq_plus = w * rho * (1.0 + 4.5 * cu * cu - 1.5 * uu);
|
||||
let eq_minus = w * rho * 3.0 * cu;
|
||||
let plus = 0.5 * (fq[q] + fq[o]);
|
||||
let minus = 0.5 * (fq[q] - fq[o]);
|
||||
let relax_plus = wp * (plus - eq_plus);
|
||||
let relax_minus = wm * (minus - eq_minus);
|
||||
let (mut s_plus, mut s_minus) = (0.0, 0.0);
|
||||
if forced {
|
||||
let cf = cv[0] * force[0] + cv[1] * force[1] + cv[2] * force[2];
|
||||
s_plus = sp * w * (9.0 * cu * cf - 3.0 * uf);
|
||||
s_minus = sm * w * 3.0 * cf;
|
||||
}
|
||||
self.f[q * s + cell] = fq[q] - relax_plus - relax_minus + s_plus + s_minus;
|
||||
self.f[o * s + cell] = fq[o] - relax_plus + relax_minus + s_plus - s_minus;
|
||||
}
|
||||
}
|
||||
max_u2
|
||||
}
|
||||
|
||||
/// Pull streaming into `g`, with the wall rules and the momentum exchange.
|
||||
fn stream(&mut self) {
|
||||
let n = self.n;
|
||||
let s = self.stride;
|
||||
// Every direction as one shifted copy: right for cells whose neighbours are all fluid
|
||||
// cells in the box; the others are overwritten by their rules below.
|
||||
for q in 0..Q {
|
||||
let off = self.offset[q];
|
||||
let (src, dst) = (&self.f[q * s..q * s + n], &mut self.g[q * s..q * s + n]);
|
||||
if off >= 0 {
|
||||
let o = off as usize;
|
||||
if o < n {
|
||||
dst[o..].copy_from_slice(&src[..n - o]);
|
||||
}
|
||||
} else {
|
||||
let o = off.unsigned_abs();
|
||||
if o < n {
|
||||
dst[..n - o].copy_from_slice(&src[o..]);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.forces.fill([0.0; 3]);
|
||||
for link in &self.links {
|
||||
let (cell, q) = (link.cell as usize, link.q as usize);
|
||||
let cq = c(q);
|
||||
match link.rule {
|
||||
Rule::Pull { from } => self.g[q * s + cell] = self.f[q * s + from as usize],
|
||||
Rule::Bounce { group, du } => {
|
||||
let out = self.f[opposite(q) * s + cell];
|
||||
let back = out + du;
|
||||
self.g[q * s + cell] = back;
|
||||
if let Some(force) = self.forces.get_mut(group as usize) {
|
||||
// c_out f* − c_in f' with c_out = −c_q.
|
||||
for a in 0..3 {
|
||||
force[a] -= cq[a] * (out + back);
|
||||
}
|
||||
}
|
||||
}
|
||||
Rule::Reflect { from, q: q2, group } => {
|
||||
let q2 = q2 as usize;
|
||||
let out = self.f[q2 * s + from as usize];
|
||||
self.g[q * s + cell] = out;
|
||||
let c2 = c(q2);
|
||||
if let Some(force) = self.forces.get_mut(group as usize) {
|
||||
for a in 0..3 {
|
||||
force[a] += c2[a] * out - cq[a] * out;
|
||||
}
|
||||
}
|
||||
}
|
||||
// A placeholder the open boundary replaces.
|
||||
Rule::Open => self.g[q * s + cell] = self.f[opposite(q) * s + cell],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Zou–He velocity and pressure boundaries on the streamed populations.
|
||||
fn open_boundaries(&mut self) {
|
||||
let s = self.stride;
|
||||
let a_half = self.acceleration.map(|v| 0.5 * v);
|
||||
for oc in &self.open {
|
||||
let cell = oc.cell as usize;
|
||||
let axis = oc.axis;
|
||||
let sign = oc.inward;
|
||||
let ci = |q: usize| f64::from(C[q][axis]);
|
||||
// Tangential (c_n = 0) and outgoing (c_n = −s, known) sums.
|
||||
let (mut tangential, mut outgoing) = (0.0, 0.0);
|
||||
for q in 0..Q {
|
||||
let v = self.g[q * s + cell];
|
||||
let cn = ci(q) * sign;
|
||||
if cn == 0.0 {
|
||||
tangential += v;
|
||||
} else if cn < 0.0 {
|
||||
outgoing += v;
|
||||
}
|
||||
}
|
||||
// The bare velocity (without the half force) the populations must carry.
|
||||
let (rho, bare) = match oc.target {
|
||||
Target::Velocity(u) => {
|
||||
let bare = [u[0] - a_half[0], u[1] - a_half[1], u[2] - a_half[2]];
|
||||
let un = sign * bare[axis];
|
||||
((tangential + 2.0 * outgoing) / (1.0 - un), bare)
|
||||
}
|
||||
Target::Density(rho) => {
|
||||
let un = 1.0 - (tangential + 2.0 * outgoing) / rho;
|
||||
let mut bare = [-a_half[0], -a_half[1], -a_half[2]];
|
||||
bare[axis] = sign * un;
|
||||
(rho, bare)
|
||||
}
|
||||
};
|
||||
// Unknowns: bounce-back of the non-equilibrium part.
|
||||
for q in (1..Q).filter(|q| ci(*q) * sign > 0.0) {
|
||||
let cq = c(q);
|
||||
let cu = cq[0] * bare[0] + cq[1] * bare[1] + cq[2] * bare[2];
|
||||
self.g[q * s + cell] = self.g[opposite(q) * s + cell] + 6.0 * W[q] * rho * cu;
|
||||
}
|
||||
// Transverse momentum: correct the two diagonal unknowns along each tangential axis.
|
||||
for t in (0..3).filter(|t| *t != axis) {
|
||||
let mut m = -rho * bare[t];
|
||||
for q in 0..Q {
|
||||
m += self.g[q * s + cell] * f64::from(C[q][t]);
|
||||
}
|
||||
for q in (1..Q).filter(|q| ci(*q) * sign > 0.0 && C[*q][t] != 0) {
|
||||
self.g[q * s + cell] -= 0.5 * m * f64::from(C[q][t]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! The D3Q19 velocity set (Qian, d'Humières & Lallemand 1992; Krüger et al. 2017, ch. 3).
|
||||
//!
|
||||
//! Directions come in opposite pairs: 1–2, 3–4, …, 17–18, so the opposite of an odd `q` is
|
||||
//! `q + 1` and of an even `q` (not 0) is `q − 1`.
|
||||
|
||||
/// The number of velocities.
|
||||
pub const Q: usize = 19;
|
||||
|
||||
/// The lattice velocities `c_q` in lattice units.
|
||||
pub const C: [[i32; 3]; Q] = [
|
||||
[0, 0, 0],
|
||||
[1, 0, 0],
|
||||
[-1, 0, 0],
|
||||
[0, 1, 0],
|
||||
[0, -1, 0],
|
||||
[0, 0, 1],
|
||||
[0, 0, -1],
|
||||
[1, 1, 0],
|
||||
[-1, -1, 0],
|
||||
[1, -1, 0],
|
||||
[-1, 1, 0],
|
||||
[1, 0, 1],
|
||||
[-1, 0, -1],
|
||||
[1, 0, -1],
|
||||
[-1, 0, 1],
|
||||
[0, 1, 1],
|
||||
[0, -1, -1],
|
||||
[0, 1, -1],
|
||||
[0, -1, 1],
|
||||
];
|
||||
|
||||
/// The weights `w_q`: 1/3 at rest, 1/18 along the axes, 1/36 along the face diagonals.
|
||||
pub const W: [f64; Q] = [
|
||||
1.0 / 3.0,
|
||||
1.0 / 18.0,
|
||||
1.0 / 18.0,
|
||||
1.0 / 18.0,
|
||||
1.0 / 18.0,
|
||||
1.0 / 18.0,
|
||||
1.0 / 18.0,
|
||||
1.0 / 36.0,
|
||||
1.0 / 36.0,
|
||||
1.0 / 36.0,
|
||||
1.0 / 36.0,
|
||||
1.0 / 36.0,
|
||||
1.0 / 36.0,
|
||||
1.0 / 36.0,
|
||||
1.0 / 36.0,
|
||||
1.0 / 36.0,
|
||||
1.0 / 36.0,
|
||||
1.0 / 36.0,
|
||||
1.0 / 36.0,
|
||||
];
|
||||
|
||||
/// The squared speed of sound in lattice units, `c_s² = 1/3`.
|
||||
pub const CS2: f64 = 1.0 / 3.0;
|
||||
|
||||
/// The direction opposite `q`.
|
||||
#[inline(always)]
|
||||
pub const fn opposite(q: usize) -> usize {
|
||||
if q == 0 {
|
||||
0
|
||||
} else if q % 2 == 1 {
|
||||
q + 1
|
||||
} else {
|
||||
q - 1
|
||||
}
|
||||
}
|
||||
|
||||
/// `c_q` as floating-point numbers.
|
||||
#[inline(always)]
|
||||
pub fn c(q: usize) -> [f64; 3] {
|
||||
let v = C.get(q).copied().unwrap_or([0; 3]);
|
||||
[f64::from(v[0]), f64::from(v[1]), f64::from(v[2])]
|
||||
}
|
||||
|
||||
/// The second-order equilibrium `w_q ρ (1 + 3 c·u + 9/2 (c·u)² − 3/2 u²)`.
|
||||
#[inline(always)]
|
||||
pub fn equilibrium(q: usize, rho: f64, u: [f64; 3]) -> f64 {
|
||||
let c = c(q);
|
||||
let cu = c[0] * u[0] + c[1] * u[1] + c[2] * u[2];
|
||||
let uu = u[0] * u[0] + u[1] * u[1] + u[2] * u[2];
|
||||
W.get(q).copied().unwrap_or(0.0) * rho * (1.0 + 3.0 * cu + 4.5 * cu * cu - 1.5 * uu)
|
||||
}
|
||||
|
||||
/// Ladd's moving-wall term for a population bounced back into direction `q` off a wall moving
|
||||
/// with `u` (lattice units, reference density 1): `2 w_q c_q·u / c_s²` (Ladd 1994).
|
||||
#[inline(always)]
|
||||
pub fn moving_wall(q: usize, u: [f64; 3]) -> f64 {
|
||||
let c = c(q);
|
||||
2.0 * W.get(q).copied().unwrap_or(0.0) * (c[0] * u[0] + c[1] * u[1] + c[2] * u[2]) / CS2
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn moments_of_the_velocity_set() {
|
||||
// Σ w = 1, Σ w c = 0, Σ w c_a c_b = c_s² δ_ab, and opposite directions pair up.
|
||||
let mut m0 = 0.0;
|
||||
let mut m1 = [0.0; 3];
|
||||
let mut m2 = [[0.0; 3]; 3];
|
||||
for q in 0..Q {
|
||||
let c = c(q);
|
||||
m0 += W[q];
|
||||
for a in 0..3 {
|
||||
m1[a] += W[q] * c[a];
|
||||
for b in 0..3 {
|
||||
m2[a][b] += W[q] * c[a] * c[b];
|
||||
}
|
||||
}
|
||||
let o = opposite(q);
|
||||
assert_eq!(opposite(o), q);
|
||||
assert_eq!([C[o][0], C[o][1], C[o][2]], [-C[q][0], -C[q][1], -C[q][2]]);
|
||||
assert_eq!(W[o], W[q]);
|
||||
}
|
||||
assert!((m0 - 1.0f64).abs() < 1e-15);
|
||||
for a in 0..3 {
|
||||
assert!(m1[a].abs() < 1e-15);
|
||||
for b in 0..3 {
|
||||
let want = if a == b { CS2 } else { 0.0 };
|
||||
assert!((m2[a][b] - want).abs() < 1e-15);
|
||||
}
|
||||
}
|
||||
// The equilibrium carries the density and momentum it is built from.
|
||||
let (rho, u) = (1.03, [0.02, -0.01, 0.005]);
|
||||
let (mut r, mut j) = (0.0, [0.0; 3]);
|
||||
for q in 0..Q {
|
||||
let f = equilibrium(q, rho, u);
|
||||
r += f;
|
||||
for a in 0..3 {
|
||||
j[a] += f * c(q)[a];
|
||||
}
|
||||
}
|
||||
assert!((r - rho).abs() < 1e-15);
|
||||
for a in 0..3 {
|
||||
assert!((j[a] - rho * u[a]).abs() < 1e-15);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//! ModelCraft's lattice Boltzmann flow solver (CPU).
|
||||
//!
|
||||
//! It solves a fluid step of the model ([`modelcraft_model::Procedure::Fluid`]) in its fluid
|
||||
//! domain: weakly compressible, isothermal, Newtonian flow on a uniform grid of cubic cells that
|
||||
//! fills the domain's box, less the cells inside the obstacle bodies.
|
||||
//!
|
||||
//! - [`voxel`]: which cells the obstacles take, decided with exact predicates from the
|
||||
//! face-tagged boundary surface, and which face each wall link meets.
|
||||
//! - [`setup`]: the step in lattice units, the time step, and the stability limits.
|
||||
//! - [`flow`]: the D3Q19 two-relaxation-time scheme with Guo forcing, halfway bounce-back
|
||||
//! walls (moving walls after Ladd), slip faces, Zou–He inlets and outlets, and the
|
||||
//! momentum-exchange force on every wall.
|
||||
//! - [`solve`]: the time loop, output frames and force histories in model units.
|
||||
//!
|
||||
//! # Sources
|
||||
//! - T. Krüger, H. Kusumaatmaja, A. Kuzmin, O. Shardt, G. Silva, E. M. Viggen, *The Lattice
|
||||
//! Boltzmann Method: Principles and Practice*, Springer 2017 (velocity sets, equilibria, units,
|
||||
//! stream–collide, bounce-back, forcing, boundary conditions).
|
||||
//! - Y. H. Qian, D. d'Humières, P. Lallemand, *Lattice BGK models for Navier–Stokes equation*,
|
||||
//! EPL 17(6):479, 1992 (D3Q19).
|
||||
//! - I. Ginzburg, F. Verhaeghe, D. d'Humières, *Two-relaxation-time lattice Boltzmann scheme:
|
||||
//! about parametrization, velocity, pressure and mixed boundary conditions*, Commun. Comput.
|
||||
//! Phys. 3(2):427–478, 2008 (TRT and the parameter Λ).
|
||||
//! - Z. Guo, C. Zheng, B. Shi, *Discrete lattice effects on the forcing term in the lattice
|
||||
//! Boltzmann method*, PRE 65:046308, 2002 (body force).
|
||||
//! - A. J. C. Ladd, *Numerical simulations of particulate suspensions via a discretized Boltzmann
|
||||
//! equation. Part 1*, JFM 271:285–309, 1994 (bounce-back, moving walls, momentum exchange).
|
||||
//! - R. Mei, D. Yu, W. Shyy, L.-S. Luo, *Force evaluation in the lattice Boltzmann method
|
||||
//! involving curved geometry*, PRE 65:041203, 2002 (momentum exchange).
|
||||
//! - Q. Zou, X. He, *On pressure and velocity boundary conditions for the lattice Boltzmann BGK
|
||||
//! model*, Phys. Fluids 9(6):1591–1598, 1997; M. Hecht, J. Harting, J. Stat. Mech. 2010,
|
||||
//! P01018 (the D3Q19 transverse correction).
|
||||
//! - P. Lallemand, L.-S. Luo, *Theory of the lattice Boltzmann method: dispersion, dissipation,
|
||||
//! isotropy, Galilean invariance, and stability*, PRE 61:6546, 2000 (stability limits).
|
||||
//! - H. Edelsbrunner, E. P. Mücke, *Simulation of Simplicity*, ACM TOG 9(1):66–104, 1990;
|
||||
//! J. R. Shewchuk 1997 (exact predicates, through `modelcraft_mesh::predicates`).
|
||||
//!
|
||||
//! No code of any lattice Boltzmann package was read; everything is written from these papers and
|
||||
//! the book.
|
||||
//!
|
||||
//! # Determinism
|
||||
//! Double precision, fixed loop orders, no threads and no parallel reductions: the same model
|
||||
//! gives the same result bit for bit on every platform, wasm included.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
// Population arrays are indexed by cell and direction counts fixed when they are allocated.
|
||||
#![allow(clippy::needless_range_loop)]
|
||||
|
||||
pub mod flow;
|
||||
pub mod lattice;
|
||||
pub mod setup;
|
||||
pub mod solve;
|
||||
pub mod voxel;
|
||||
|
||||
pub use flow::{Flow, Group};
|
||||
pub use setup::{Obstacle, Setup, fluid_step};
|
||||
pub use solve::{FluidResult, FluidStats, ForceHistory, Frame, run, solve_fluid};
|
||||
pub use voxel::Grid;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("invalid input: {0}")]
|
||||
Invalid(String),
|
||||
#[error("not found: {0}")]
|
||||
NotFound(String),
|
||||
#[error("limit reached: {0}")]
|
||||
Limit(String),
|
||||
/// The run diverged.
|
||||
#[error("unstable: {0}")]
|
||||
Unstable(String),
|
||||
#[error(transparent)]
|
||||
Model(#[from] modelcraft_model::Error),
|
||||
#[error(transparent)]
|
||||
Mesh(#[from] modelcraft_mesh::Error),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// Caps on a run's size and work; a run beyond any of them is refused before it starts.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Limits {
|
||||
pub max_cells: u64,
|
||||
pub max_steps: u64,
|
||||
/// Fluid cells times time steps.
|
||||
pub max_cell_updates: u64,
|
||||
/// The memory the output frames may take.
|
||||
pub max_frame_bytes: u64,
|
||||
}
|
||||
|
||||
impl Limits {
|
||||
/// 16 million cells: about 5 GB of populations in double precision.
|
||||
pub const MAX_CELLS: u64 = 16_000_000;
|
||||
pub const MAX_STEPS: u64 = 10_000_000;
|
||||
pub const MAX_CELL_UPDATES: u64 = 1_000_000_000_000;
|
||||
pub const MAX_FRAME_BYTES: u64 = 2_000_000_000;
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Limits {
|
||||
max_cells: Limits::MAX_CELLS,
|
||||
max_steps: Limits::MAX_STEPS,
|
||||
max_cell_updates: Limits::MAX_CELL_UPDATES,
|
||||
max_frame_bytes: Limits::MAX_FRAME_BYTES,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
//! From a fluid step of the model to lattice units: the grid, the time step, the relaxation
|
||||
//! times, and the checks that keep the scheme in its stable and accurate range.
|
||||
//!
|
||||
//! # Units (Krüger et al. 2017, ch. 7)
|
||||
//! Lattice units take the spacing `Δx`, the time step `Δt` and the fluid's density `ρ₀` as 1. A
|
||||
//! velocity converts by `Δx/Δt`, a kinematic viscosity by `Δx²/Δt`, an acceleration by `Δx/Δt²`,
|
||||
//! a pressure by `ρ₀ Δx²/Δt²` and a force by `ρ₀ Δx⁴/Δt²`. The lattice viscosity fixes the
|
||||
//! symmetric relaxation time, `ν = c_s² (τ⁺ − ½)` with `c_s² = 1/3`.
|
||||
//!
|
||||
//! # Time step
|
||||
//! Unless the step gives one, `Δt` is the largest that keeps the characteristic lattice velocity
|
||||
//! at [`TARGET_LATTICE_VELOCITY`] (Mach 0.087: the weakly compressible error is O(Ma²)) and
|
||||
//! `τ⁺` at most [`AUTO_TAU_MAX`] (beyond it the scheme only gets less accurate in time). The
|
||||
//! characteristic velocity is the largest of the inlet and wall velocities, the initial velocity,
|
||||
//! `√(2 Δp / ρ)` across pressure faces and, for a driving acceleration `a`, the smaller of the
|
||||
//! viscous plane-channel peak `a H² / (8 ν)` (H the box's smallest extent) and the free-fall speed
|
||||
//! `√(2 a L)` (L its largest).
|
||||
//!
|
||||
//! # Limits
|
||||
//! A run is refused, naming the numbers and what to change, when the lattice Mach number of the
|
||||
//! characteristic velocity exceeds [`MAX_MACH`] (the scheme is weakly compressible and loses
|
||||
//! stability toward Ma ≈ 0.5), when `τ⁺ < `[`MIN_TAU`] (the grid cannot resolve this viscosity at
|
||||
//! this velocity; linear stability analysis, Lallemand & Luo 2000, shows the stable velocity range
|
||||
//! shrinks to nothing as τ → ½), or when the lattice acceleration exceeds [`MAX_LATTICE_ACCELERATION`].
|
||||
//! While running, a lattice speed above [`MAX_LATTICE_SPEED`] or a non-finite or non-positive
|
||||
//! density stops the run with [`crate::Error::Unstable`].
|
||||
|
||||
use modelcraft_mesh::surface::BoundarySurface;
|
||||
use modelcraft_model::{BoxFace, FaceCondition, FluidDomain, Model, Procedure, SurfaceCondition};
|
||||
|
||||
use crate::voxel::Grid;
|
||||
use crate::{Error, Limits, Result};
|
||||
|
||||
/// The characteristic lattice velocity the automatic time step aims at.
|
||||
pub const TARGET_LATTICE_VELOCITY: f64 = 0.05;
|
||||
/// The largest symmetric relaxation time the automatic time step allows.
|
||||
pub const AUTO_TAU_MAX: f64 = 1.0;
|
||||
/// The largest lattice Mach number `u Δt / (Δx c_s)` of the characteristic velocity.
|
||||
pub const MAX_MACH: f64 = 0.3;
|
||||
/// The smallest symmetric relaxation time τ⁺.
|
||||
pub const MIN_TAU: f64 = 0.505;
|
||||
/// The largest lattice acceleration `a Δt² / Δx`.
|
||||
pub const MAX_LATTICE_ACCELERATION: f64 = 0.01;
|
||||
/// A lattice speed above this during a run means it went unstable (Mach 0.87).
|
||||
pub const MAX_LATTICE_SPEED: f64 = 0.5;
|
||||
/// The default two-relaxation-time parameter Λ: bounce-back walls exactly halfway between cells
|
||||
/// (Ginzburg, Verhaeghe & d'Humières 2008).
|
||||
pub const DEFAULT_MAGIC: f64 = 3.0 / 16.0;
|
||||
|
||||
/// The surface of an obstacle body and the persistent name of each of its faces (by the
|
||||
/// triangles' face tags); prepared by the caller from the geometry.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Obstacle {
|
||||
pub body: String,
|
||||
pub surface: BoundarySurface,
|
||||
/// Face names by tag; tags without a name are called `face0`, `face1`, ….
|
||||
pub faces: Vec<String>,
|
||||
}
|
||||
|
||||
/// An obstacle of the domain as the run sees it.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ObstacleFaces {
|
||||
/// The obstacle's index in the caller's obstacle list.
|
||||
pub index: usize,
|
||||
/// Every face tag's name.
|
||||
pub names: Vec<String>,
|
||||
/// Every face tag's wall velocity in lattice units (zero for walls at rest).
|
||||
pub velocities: Vec<[f64; 3]>,
|
||||
}
|
||||
|
||||
/// A fluid step in lattice units.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Setup {
|
||||
pub step: String,
|
||||
pub domain: FluidDomain,
|
||||
pub grid: Grid,
|
||||
/// ρ₀ and the kinematic viscosity ν = μ / ρ₀, in model units.
|
||||
pub density: f64,
|
||||
pub viscosity: f64,
|
||||
pub time_step: f64,
|
||||
pub steps: u64,
|
||||
pub frames: u32,
|
||||
/// τ⁺ and τ⁻ (Λ = (τ⁺ − ½)(τ⁻ − ½)).
|
||||
pub tau_plus: f64,
|
||||
pub tau_minus: f64,
|
||||
pub magic: f64,
|
||||
/// The characteristic velocity in model and lattice units, and its lattice Mach number.
|
||||
pub velocity: f64,
|
||||
pub lattice_velocity: f64,
|
||||
pub mach: f64,
|
||||
/// The body acceleration and initial velocity in lattice units.
|
||||
pub acceleration: [f64; 3],
|
||||
pub initial_velocity: [f64; 3],
|
||||
/// The domain's obstacles, in its order.
|
||||
pub obstacles: Vec<ObstacleFaces>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
fn norm(v: [f64; 3]) -> f64 {
|
||||
(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()
|
||||
}
|
||||
|
||||
/// The fluid step named `step` (or the only fluid step when `step` is `None`) and its domain.
|
||||
pub fn fluid_step<'m>(model: &'m Model, step: Option<&str>) -> Result<(&'m modelcraft_model::Step, &'m FluidDomain)> {
|
||||
let s = model.select_step(step, |p| p.fluid_domain().is_some(), "fluid")?;
|
||||
let name = s.procedure.fluid_domain().unwrap_or_default();
|
||||
let domain = model.fluid_domain(name).ok_or_else(|| Error::NotFound(format!("fluid domain `{name}`")))?;
|
||||
Ok((s, domain))
|
||||
}
|
||||
|
||||
impl Setup {
|
||||
/// Convert a fluid step, checking it against the stability limits and `limits`.
|
||||
pub fn new(model: &Model, step: Option<&str>, obstacles: &[Obstacle], limits: &Limits) -> Result<Setup> {
|
||||
// The model checked the domain and its material when they were defined.
|
||||
let (s, domain) = fluid_step(model, step)?;
|
||||
let domain = domain.clone();
|
||||
let Procedure::Fluid { end_time, steps, time_step, frames, magic, .. } = &s.procedure else {
|
||||
return Err(Error::Invalid(format!("step `{}` is not a fluid step", s.name)));
|
||||
};
|
||||
let material = model.material(&domain.material);
|
||||
let (Some(density), Some(fluid)) = (material.and_then(|m| m.density), material.and_then(|m| m.fluid)) else {
|
||||
return Err(Error::Invalid(format!("material `{}` needs a density and fluid behaviour", domain.material)));
|
||||
};
|
||||
let viscosity = fluid.viscosity() / density;
|
||||
if !(viscosity > 0.0 && viscosity.is_finite()) {
|
||||
return Err(Error::Invalid(format!("the kinematic viscosity μ/ρ of material `{}` is {viscosity}", domain.material)));
|
||||
}
|
||||
|
||||
// The grid.
|
||||
let counts = domain.cells()?;
|
||||
let mut cells = [0usize; 3];
|
||||
let mut total: u64 = 1;
|
||||
for a in 0..3 {
|
||||
cells[a] = usize::try_from(counts[a]).map_err(|_| Error::Limit("the grid is too large".into()))?;
|
||||
total = total.saturating_mul(counts[a]);
|
||||
}
|
||||
if total > limits.max_cells {
|
||||
return Err(Error::Limit(format!(
|
||||
"the domain has {total} cells ({} × {} × {}), above the cap of {}; coarsen the spacing or raise `max_cells`",
|
||||
cells[0], cells[1], cells[2], limits.max_cells
|
||||
)));
|
||||
}
|
||||
for face in BoxFace::ALL {
|
||||
let open = matches!(domain.faces.get(face), FaceCondition::Velocity { .. } | FaceCondition::Pressure { .. });
|
||||
if open && cells[face.axis()] < 2 {
|
||||
return Err(Error::Invalid(format!("box face {} is an inlet or outlet, so the box needs at least 2 cells across it", face.name())));
|
||||
}
|
||||
}
|
||||
let grid = Grid { origin: domain.min, spacing: domain.spacing, cells };
|
||||
let h = domain.spacing;
|
||||
|
||||
// The characteristic velocity.
|
||||
let mut u_char: f64 = norm(domain.initial_velocity);
|
||||
let mut pressures = Vec::new();
|
||||
for face in BoxFace::ALL {
|
||||
match *domain.faces.get(face) {
|
||||
FaceCondition::MovingWall { velocity } | FaceCondition::Velocity { velocity, .. } => u_char = u_char.max(norm(velocity)),
|
||||
FaceCondition::Pressure { pressure } => pressures.push(pressure),
|
||||
FaceCondition::Wall {} | FaceCondition::Slip {} | FaceCondition::Periodic {} => {}
|
||||
}
|
||||
}
|
||||
for sb in &domain.surfaces {
|
||||
if let SurfaceCondition::MovingWall { velocity } = sb.condition {
|
||||
u_char = u_char.max(norm(velocity));
|
||||
}
|
||||
}
|
||||
let lo = pressures.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
let hi = pressures.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
||||
if hi > lo {
|
||||
u_char = u_char.max((2.0 * (hi - lo) / density).sqrt());
|
||||
}
|
||||
let a = norm(domain.acceleration);
|
||||
if a > 0.0 {
|
||||
let extents: Vec<f64> = (0..3).map(|k| domain.max[k] - domain.min[k]).collect();
|
||||
let small = extents.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
let large = extents.iter().copied().fold(0.0, f64::max);
|
||||
u_char = u_char.max((a * small * small / (8.0 * viscosity)).min((2.0 * a * large).sqrt()));
|
||||
}
|
||||
if !u_char.is_finite() {
|
||||
return Err(Error::Invalid("the flow's velocity scale is not a finite number".into()));
|
||||
}
|
||||
|
||||
// The time step and the number of steps.
|
||||
let diffusive = (AUTO_TAU_MAX - 0.5) * h * h / (3.0 * viscosity);
|
||||
let auto = if u_char > 0.0 { (TARGET_LATTICE_VELOCITY * h / u_char).min(diffusive) } else { diffusive };
|
||||
let (dt, n) = match (*time_step, *end_time, *steps) {
|
||||
(Some(dt), Some(t), _) => {
|
||||
let n = (t / dt).round();
|
||||
if !(n >= 1.0) {
|
||||
return Err(Error::Invalid(format!("the end time {t} is shorter than the time step {dt}")));
|
||||
}
|
||||
(dt, n)
|
||||
}
|
||||
(Some(dt), None, Some(n)) => (dt, n as f64),
|
||||
(None, Some(t), _) => {
|
||||
let n = (t / auto).ceil().max(1.0);
|
||||
(t / n, n)
|
||||
}
|
||||
(None, None, Some(n)) => (auto, n as f64),
|
||||
(_, None, None) => return Err(Error::Invalid(format!("step `{}` needs `end_time` or `steps`", s.name))),
|
||||
};
|
||||
if !(dt > 0.0 && dt.is_finite()) {
|
||||
return Err(Error::Invalid(format!("the time step {dt} is not a positive number")));
|
||||
}
|
||||
if !(n <= limits.max_steps as f64) {
|
||||
return Err(Error::Limit(format!(
|
||||
"the step needs {n} time steps of {dt}, above the cap of {}; shorten it or raise `max_steps`",
|
||||
limits.max_steps
|
||||
)));
|
||||
}
|
||||
let n = n as u64;
|
||||
let updates = (total as f64) * (n as f64);
|
||||
if !(updates <= limits.max_cell_updates as f64) {
|
||||
return Err(Error::Limit(format!(
|
||||
"{total} cells for {n} time steps is {updates:e} cell updates, above the cap of {:e}; coarsen, shorten or raise `max_cell_updates`",
|
||||
limits.max_cell_updates as f64
|
||||
)));
|
||||
}
|
||||
|
||||
// Lattice parameters and the stability limits.
|
||||
let nu = viscosity * dt / (h * h);
|
||||
let tau_plus = 3.0 * nu + 0.5;
|
||||
let lattice_velocity = u_char * dt / h;
|
||||
let mach = lattice_velocity / crate::lattice::CS2.sqrt();
|
||||
if mach > MAX_MACH {
|
||||
return Err(Error::Invalid(format!(
|
||||
"the lattice Mach number is {mach:.3} (velocity {u_char} with time step {dt} and spacing {h}), above {MAX_MACH}: lower the time step"
|
||||
)));
|
||||
}
|
||||
if !(tau_plus >= MIN_TAU) {
|
||||
return Err(Error::Invalid(format!(
|
||||
"the relaxation time τ = {tau_plus:.5} is below {MIN_TAU}: the grid cannot resolve viscosity {viscosity} at this time step (cell Reynolds number {:.1}); refine the spacing or raise the time step",
|
||||
if nu > 0.0 { lattice_velocity / nu } else { f64::INFINITY }
|
||||
)));
|
||||
}
|
||||
let magic = magic.unwrap_or(DEFAULT_MAGIC);
|
||||
let tau_minus = 0.5 + magic / (tau_plus - 0.5);
|
||||
let acceleration = domain.acceleration.map(|c| c * dt * dt / h);
|
||||
if norm(acceleration) > MAX_LATTICE_ACCELERATION {
|
||||
return Err(Error::Invalid(format!(
|
||||
"the lattice acceleration is {:.3e}, above {MAX_LATTICE_ACCELERATION}: lower the time step",
|
||||
norm(acceleration)
|
||||
)));
|
||||
}
|
||||
|
||||
// Obstacles, their face names and their faces' wall velocities.
|
||||
let mut domain_obstacles = Vec::with_capacity(domain.obstacles.len());
|
||||
let mut warnings = Vec::new();
|
||||
for body in &domain.obstacles {
|
||||
let (index, o) = obstacles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, o)| &o.body == body)
|
||||
.ok_or_else(|| Error::NotFound(format!("obstacle body `{body}` has no surface (open the design or import its surface)")))?;
|
||||
let tags = o.surface.tri_face().iter().copied().max().map_or(1, |m| m as usize + 1).max(o.faces.len());
|
||||
let names: Vec<String> = (0..tags).map(|t| o.faces.get(t).cloned().unwrap_or_else(|| format!("face{t}"))).collect();
|
||||
let mut velocities = vec![[0.0; 3]; tags];
|
||||
for sb in domain.surfaces.iter().filter(|sb| &sb.face.body == body) {
|
||||
let mut hit = false;
|
||||
for (tag, name) in names.iter().enumerate() {
|
||||
if sb.face.selects(&modelcraft_model::GeometryRef::face(body.as_str(), name.as_str())?) {
|
||||
hit = true;
|
||||
if let (Some(slot), SurfaceCondition::MovingWall { velocity }) = (velocities.get_mut(tag), sb.condition) {
|
||||
*slot = velocity.map(|c| c * dt / h);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hit {
|
||||
return Err(Error::NotFound(format!("the {} is not a face of the obstacle's surface", sb.face)));
|
||||
}
|
||||
}
|
||||
domain_obstacles.push(ObstacleFaces { index, names, velocities });
|
||||
}
|
||||
if mach > 0.1 + 1e-12 {
|
||||
warnings.push(format!(
|
||||
"lattice Mach number {mach:.3}: the compressibility error, of order Ma² ≈ {:.1}%, may be visible",
|
||||
100.0 * mach * mach
|
||||
));
|
||||
}
|
||||
if tau_plus < 0.51 {
|
||||
warnings.push(format!("τ = {tau_plus:.4} is close to ½: under-resolved flows may go unstable; a finer spacing is safer"));
|
||||
}
|
||||
|
||||
Ok(Setup {
|
||||
step: s.name.clone(),
|
||||
grid,
|
||||
density,
|
||||
viscosity,
|
||||
time_step: dt,
|
||||
steps: n,
|
||||
frames: *frames,
|
||||
tau_plus,
|
||||
tau_minus,
|
||||
magic,
|
||||
velocity: u_char,
|
||||
lattice_velocity,
|
||||
mach,
|
||||
acceleration,
|
||||
initial_velocity: domain.initial_velocity.map(|c| c * dt / h),
|
||||
obstacles: domain_obstacles,
|
||||
warnings,
|
||||
domain,
|
||||
})
|
||||
}
|
||||
|
||||
/// Model units per lattice unit of velocity, pressure and force.
|
||||
pub fn velocity_scale(&self) -> f64 {
|
||||
self.grid.spacing / self.time_step
|
||||
}
|
||||
|
||||
pub fn pressure_scale(&self) -> f64 {
|
||||
self.density * self.grid.spacing * self.grid.spacing / (self.time_step * self.time_step)
|
||||
}
|
||||
|
||||
pub fn force_scale(&self) -> f64 {
|
||||
self.pressure_scale() * self.grid.spacing * self.grid.spacing
|
||||
}
|
||||
|
||||
/// A velocity in lattice units.
|
||||
pub fn to_lattice(&self, v: [f64; 3]) -> [f64; 3] {
|
||||
let k = 1.0 / self.velocity_scale();
|
||||
v.map(|c| c * k)
|
||||
}
|
||||
|
||||
/// The lattice density of a gauge pressure: `1 + p / (c_s² ρ₀ Δx²/Δt²)`.
|
||||
pub fn lattice_density(&self, pressure: f64) -> f64 {
|
||||
1.0 + pressure / (crate::lattice::CS2 * self.pressure_scale())
|
||||
}
|
||||
|
||||
/// The gauge pressure of a lattice density (the inverse of [`Setup::lattice_density`]).
|
||||
pub fn gauge_pressure(&self, rho: f64) -> f64 {
|
||||
(rho - 1.0) * crate::lattice::CS2 * self.pressure_scale()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//! Running a fluid step: the time loop, output frames and force histories, in model units.
|
||||
|
||||
use modelcraft_model::Model;
|
||||
|
||||
use crate::flow::Flow;
|
||||
use crate::setup::{Obstacle, Setup};
|
||||
use crate::voxel::Grid;
|
||||
use crate::{Error, Limits, Result};
|
||||
|
||||
/// Most samples a force history keeps; longer runs record every k-th step.
|
||||
pub const MAX_HISTORY: u64 = 10_000;
|
||||
|
||||
/// A relative density variation above this at the end of a run is reported.
|
||||
pub const COMPRESSIBILITY_WARNING: f64 = 0.01;
|
||||
|
||||
/// The velocity and pressure field at one time, per cell (index `i + nx (j + ny k)`; zero in
|
||||
/// solid cells). Stored in single precision: output for display and probing.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Frame {
|
||||
pub time: f64,
|
||||
pub step: u64,
|
||||
pub velocity: Vec<[f32; 3]>,
|
||||
/// Gauge pressure.
|
||||
pub pressure: Vec<f32>,
|
||||
/// The total fluid mass (density times volume).
|
||||
pub mass: f64,
|
||||
}
|
||||
|
||||
/// The force the fluid exerts on one wall over time.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ForceHistory {
|
||||
/// `x_min` … for box faces, the face's persistent name for obstacle faces.
|
||||
pub name: String,
|
||||
/// The obstacle body, for obstacle faces.
|
||||
pub body: Option<String>,
|
||||
/// One value per [`FluidResult::history_times`].
|
||||
pub force: Vec<[f64; 3]>,
|
||||
}
|
||||
|
||||
/// Run statistics.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct FluidStats {
|
||||
pub cells: usize,
|
||||
pub fluid_cells: usize,
|
||||
pub steps: u64,
|
||||
pub time_step: f64,
|
||||
pub end_time: f64,
|
||||
pub tau_plus: f64,
|
||||
pub tau_minus: f64,
|
||||
pub magic: f64,
|
||||
/// The characteristic velocity (model units), its lattice value and its lattice Mach number.
|
||||
pub velocity: f64,
|
||||
pub lattice_velocity: f64,
|
||||
pub mach: f64,
|
||||
/// Fluid cells times steps.
|
||||
pub cell_updates: u64,
|
||||
}
|
||||
|
||||
/// The result of a fluid step.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct FluidResult {
|
||||
pub step: String,
|
||||
pub grid: Grid,
|
||||
/// Per cell: not fluid (inside an obstacle).
|
||||
pub solid: Vec<bool>,
|
||||
/// The initial field and `frames` more, evenly spaced to the end.
|
||||
pub frames: Vec<Frame>,
|
||||
pub history_times: Vec<f64>,
|
||||
/// Every box wall and every obstacle face.
|
||||
pub forces: Vec<ForceHistory>,
|
||||
pub stats: FluidStats,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
impl FluidResult {
|
||||
/// The cell containing a point and the frame's velocity and pressure there (`None` outside
|
||||
/// the grid; zeros in a solid cell).
|
||||
pub fn probe(&self, frame: usize, p: [f64; 3]) -> Option<(usize, [f32; 3], f32)> {
|
||||
let cell = self.grid.cell_at(p)?;
|
||||
let f = self.frames.get(frame)?;
|
||||
Some((cell, *f.velocity.get(cell)?, *f.pressure.get(cell)?))
|
||||
}
|
||||
}
|
||||
|
||||
/// Solve the fluid step named `step` (or the model's only fluid step) with the obstacles' surfaces
|
||||
/// prepared by the caller.
|
||||
pub fn solve_fluid(model: &Model, step: Option<&str>, obstacles: &[Obstacle], limits: &Limits) -> Result<FluidResult> {
|
||||
let setup = Setup::new(model, step, obstacles, limits)?;
|
||||
let mut flow = Flow::new(&setup, obstacles)?;
|
||||
run(&setup, &mut flow, limits)
|
||||
}
|
||||
|
||||
fn frame(setup: &Setup, flow: &Flow, step: u64) -> Result<Frame> {
|
||||
let n = setup.grid.len();
|
||||
let mut velocity = Vec::new();
|
||||
let mut pressure = Vec::new();
|
||||
velocity.try_reserve_exact(n).map_err(|_| Error::Limit("not enough memory for an output frame".into()))?;
|
||||
pressure.try_reserve_exact(n).map_err(|_| Error::Limit("not enough memory for an output frame".into()))?;
|
||||
let vs = setup.velocity_scale();
|
||||
for cell in 0..n {
|
||||
match flow.moments(cell) {
|
||||
Some((rho, u)) => {
|
||||
velocity.push(u.map(|c| (c * vs) as f32));
|
||||
pressure.push(setup.gauge_pressure(rho) as f32);
|
||||
}
|
||||
None => {
|
||||
velocity.push([0.0; 3]);
|
||||
pressure.push(0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mass = flow.mass() * setup.density * setup.grid.spacing.powi(3);
|
||||
Ok(Frame { time: step as f64 * setup.time_step, step, velocity, pressure, mass })
|
||||
}
|
||||
|
||||
/// Run a prepared flow (fresh from [`Flow::new`], possibly with its own initial state) through
|
||||
/// the setup's steps, recording frames and force histories.
|
||||
pub fn run(setup: &Setup, flow: &mut Flow, limits: &Limits) -> Result<FluidResult> {
|
||||
let n = setup.grid.len();
|
||||
let total = setup.steps;
|
||||
let frames = u64::from(setup.frames).min(total).max(1);
|
||||
let frame_bytes = (frames + 1) as f64 * n as f64 * 16.0;
|
||||
if !(frame_bytes <= limits.max_frame_bytes as f64) {
|
||||
return Err(Error::Limit(format!(
|
||||
"{} frames of {n} cells need {:.0} MB, above the cap of {:.0} MB; write fewer frames or raise `max_frame_bytes`",
|
||||
frames + 1,
|
||||
frame_bytes / 1e6,
|
||||
limits.max_frame_bytes as f64 / 1e6
|
||||
)));
|
||||
}
|
||||
let every = total.div_ceil(MAX_HISTORY).max(1);
|
||||
let fs = setup.force_scale();
|
||||
|
||||
let mut out_frames = vec![frame(setup, flow, 0)?];
|
||||
let mut history_times = Vec::new();
|
||||
let mut forces: Vec<Vec<[f64; 3]>> = vec![Vec::new(); flow.groups().len()];
|
||||
let mut next_frame = 1u64;
|
||||
for k in 1..=total {
|
||||
flow.step()?;
|
||||
if k % every == 0 || k == total {
|
||||
history_times.push(k as f64 * setup.time_step);
|
||||
for (h, f) in forces.iter_mut().zip(flow.forces()) {
|
||||
h.push(f.map(|c| c * fs));
|
||||
}
|
||||
}
|
||||
// Frame j (1..=frames) at step round(j · total / frames).
|
||||
while next_frame <= frames && k == (next_frame * total + frames / 2) / frames {
|
||||
out_frames.push(frame(setup, flow, k)?);
|
||||
next_frame += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut warnings = setup.warnings.clone();
|
||||
// The weakly compressible scheme turns pressure differences into density differences of
|
||||
// Δp / (ρ c_s²) in lattice units; large ones show the time step is too long for the flow.
|
||||
let spread = (0..n).filter_map(|c| flow.moments(c)).map(|(rho, _)| (rho - 1.0).abs()).fold(0.0, f64::max);
|
||||
if spread > COMPRESSIBILITY_WARNING {
|
||||
warnings.push(format!(
|
||||
"the density varies by {:.1}% at the end: the weakly compressible error is visible; a shorter time step reduces it (quadratically)",
|
||||
100.0 * spread
|
||||
));
|
||||
}
|
||||
for body in flow.empty_obstacles() {
|
||||
warnings.push(format!("obstacle `{body}` covers no cell centre: it is smaller than the spacing or outside the box"));
|
||||
}
|
||||
let solid: Vec<bool> = (0..n).map(|c| !flow.is_fluid(c)).collect();
|
||||
Ok(FluidResult {
|
||||
step: setup.step.clone(),
|
||||
grid: setup.grid,
|
||||
solid,
|
||||
frames: out_frames,
|
||||
history_times,
|
||||
forces: flow.groups().iter().zip(forces).map(|(g, force)| ForceHistory { name: g.name.clone(), body: g.body.clone(), force }).collect(),
|
||||
stats: FluidStats {
|
||||
cells: n,
|
||||
fluid_cells: flow.fluid_cells(),
|
||||
steps: total,
|
||||
time_step: setup.time_step,
|
||||
end_time: total as f64 * setup.time_step,
|
||||
tau_plus: setup.tau_plus,
|
||||
tau_minus: setup.tau_minus,
|
||||
magic: setup.magic,
|
||||
velocity: setup.velocity,
|
||||
lattice_velocity: setup.lattice_velocity,
|
||||
mach: setup.mach,
|
||||
cell_updates: (flow.fluid_cells() as u64).saturating_mul(total),
|
||||
},
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
//! Voxelisation: which cell centres of a grid lie inside a closed, outward-oriented triangle
|
||||
//! surface, decided with exact predicates, and which triangle a lattice link meets.
|
||||
//!
|
||||
//! # Inside or outside
|
||||
//! A cell centre `p` is inside when the winding number of the surface around it is positive. The
|
||||
//! winding number is counted along the ray from `p` in +x: every triangle the ray crosses adds
|
||||
//! the sign of its normal's x component (+1 leaving the solid, −1 entering it). All centres on one
|
||||
//! grid line (fixed y, z) share the triangles their ray can meet, so each triangle is visited once
|
||||
//! per line it covers:
|
||||
//! - whether the line meets the triangle is the sign of three [`orient2d`] tests of the line's
|
||||
//! (y, z) against the triangle's projected edges;
|
||||
//! - which centres lie before the triangle's plane is the sign of [`orient3d`] of the triangle and
|
||||
//! the centre, searched from a floating-point estimate of the crossing (the sign is monotone
|
||||
//! along the line, so the estimate only decides where the exact search starts).
|
||||
//!
|
||||
//! Degenerate positions (a line through a projected edge or vertex, a centre on a triangle's
|
||||
//! plane) are resolved by one symbolic perturbation of the centre, `p + (ε, δ, δ²)` with
|
||||
//! infinitesimals `1 ≫ ε ≫ δ` (Edelsbrunner & Mücke, *Simulation of Simplicity*, ACM TOG 9(1),
|
||||
//! 1990): the line moves by `(δ, δ²)` in (y, z), so a line through a shared edge or vertex meets
|
||||
//! exactly one of the triangles around it, and a centre on a plane the line crosses moves off it
|
||||
//! by `ε` in +x. Every test sees the same perturbed point, so a watertight surface gives a
|
||||
//! consistent count and the result never depends on rounding. Triangles parallel to x are met by
|
||||
//! no perturbed line and are skipped.
|
||||
//!
|
||||
//! # Link faces
|
||||
//! Momentum-exchange forces are reported per B-rep face, so every wall link (a fluid centre next
|
||||
//! to a solid one) is given the face of the triangle nearest its midpoint ([`TriangleTree`]): a
|
||||
//! bounding-volume tree with branch-and-bound nearest search and the closest point on a triangle
|
||||
//! after C. Ericson, *Real-Time Collision Detection*, Morgan Kaufmann 2005, §5.1.5. Ties go to the
|
||||
//! lower triangle index, so the assignment is deterministic. This is a classification for
|
||||
//! reporting, not a meshing decision, so floating-point distances are adequate.
|
||||
|
||||
use modelcraft_mesh::predicates::{Point2, Point3, Sign, orient2d, orient3d, snap_to_domain};
|
||||
use modelcraft_mesh::surface::BoundarySurface;
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// A uniform grid of cubic cells: cell `(i, j, k)` has its centre at
|
||||
/// `origin + (i + ½, j + ½, k + ½) · spacing` and index `i + nx (j + ny k)`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Grid {
|
||||
pub origin: [f64; 3],
|
||||
pub spacing: f64,
|
||||
pub cells: [usize; 3],
|
||||
}
|
||||
|
||||
impl Grid {
|
||||
/// The number of cells.
|
||||
pub fn len(&self) -> usize {
|
||||
self.cells[0] * self.cells[1] * self.cells[2]
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
/// The index of cell `(i, j, k)`.
|
||||
#[inline(always)]
|
||||
pub fn index(&self, [i, j, k]: [usize; 3]) -> usize {
|
||||
i + self.cells[0] * (j + self.cells[1] * k)
|
||||
}
|
||||
|
||||
/// The `(i, j, k)` of a cell index.
|
||||
#[inline(always)]
|
||||
pub fn coords(&self, c: usize) -> [usize; 3] {
|
||||
let [nx, ny, _] = self.cells;
|
||||
[c % nx, (c / nx) % ny, c / (nx * ny)]
|
||||
}
|
||||
|
||||
/// The centre coordinate of cell `i` along `axis`, snapped into the exact predicates' domain.
|
||||
#[inline]
|
||||
pub fn centre_along(&self, axis: usize, i: usize) -> f64 {
|
||||
snap_to_domain(self.origin[axis] + (i as f64 + 0.5) * self.spacing)
|
||||
}
|
||||
|
||||
/// The centre of a cell.
|
||||
pub fn centre(&self, c: usize) -> [f64; 3] {
|
||||
let ijk = self.coords(c);
|
||||
[self.centre_along(0, ijk[0]), self.centre_along(1, ijk[1]), self.centre_along(2, ijk[2])]
|
||||
}
|
||||
|
||||
/// The cell containing a point, if it is in the grid.
|
||||
pub fn cell_at(&self, p: [f64; 3]) -> Option<usize> {
|
||||
let mut ijk = [0usize; 3];
|
||||
for a in 0..3 {
|
||||
let t = (p[a] - self.origin[a]) / self.spacing;
|
||||
if !(t >= 0.0 && t < self.cells[a] as f64) {
|
||||
return None;
|
||||
}
|
||||
ijk[a] = (t.floor() as usize).min(self.cells[a] - 1);
|
||||
}
|
||||
Some(self.index(ijk))
|
||||
}
|
||||
}
|
||||
|
||||
/// The sign of `orient2d(a, b, p)` for `p` perturbed by `(δ, δ²)` in its two coordinates.
|
||||
/// `orient2d` is affine in `p` with gradient `(a_v − b_v, b_u − a_u)`, so when it is zero the
|
||||
/// sign is that of the first nonzero gradient component; both are zero only when `a = b`.
|
||||
#[inline]
|
||||
fn edge_sign(a: &Point2, b: &Point2, p: &Point2) -> Sign {
|
||||
let s = orient2d(a, b, p);
|
||||
if !s.is_zero() {
|
||||
return s;
|
||||
}
|
||||
let ([au, av], [bu, bv]) = (a.coords(), b.coords());
|
||||
if av != bv { Sign::of(av - bv) } else { Sign::of(bu - au) }
|
||||
}
|
||||
|
||||
fn point3(surface: &BoundarySurface, v: u32) -> Result<Point3> {
|
||||
surface.point3(v).ok_or_else(|| Error::Invalid(format!("surface vertex {v} is missing")))
|
||||
}
|
||||
|
||||
/// Mark every cell of `grid` whose centre is inside `surface` (a positive winding number):
|
||||
/// `inside[c]` is set to true for those. Returns how many cells were marked that were not marked
|
||||
/// before.
|
||||
pub fn voxelise(surface: &BoundarySurface, grid: &Grid, inside: &mut [bool]) -> Result<usize> {
|
||||
let [nx, ny, nz] = grid.cells;
|
||||
if inside.len() != grid.len() {
|
||||
return Err(Error::Invalid(format!("{} flags for {} cells", inside.len(), grid.len())));
|
||||
}
|
||||
let lines = ny * nz;
|
||||
// Winding-number changes along each line: +s at the line's first cell and −s at the first
|
||||
// centre beyond the triangle's plane.
|
||||
let mut steps = vec![0i32; lines * (nx + 1)];
|
||||
let h = grid.spacing;
|
||||
let range = |axis: usize, lo: f64, hi: f64, n: usize| -> (usize, usize) {
|
||||
// Centres with lo ≤ centre ≤ hi, widened by one cell on each side: the exact tests decide.
|
||||
let first = ((lo - grid.origin[axis]) / h - 0.5).floor() - 1.0;
|
||||
let last = ((hi - grid.origin[axis]) / h - 0.5).ceil() + 1.0;
|
||||
let clamp = |t: f64| if t.is_nan() || t < 0.0 { 0usize } else { (t as usize).min(n - 1) };
|
||||
(clamp(first), clamp(last))
|
||||
};
|
||||
for tri in surface.triangles() {
|
||||
let [a, b, c] = [point3(surface, tri[0])?, point3(surface, tri[1])?, point3(surface, tri[2])?];
|
||||
let proj = |p: &Point3| -> Result<Point2> {
|
||||
let [_, y, z] = p.coords();
|
||||
Ok(Point2::new(y, z)?)
|
||||
};
|
||||
let (a2, b2, c2) = (proj(&a)?, proj(&b)?, proj(&c)?);
|
||||
let s = orient2d(&a2, &b2, &c2);
|
||||
if s.is_zero() {
|
||||
continue;
|
||||
}
|
||||
let ([ax, ay, az], [bx, by, bz], [cx, cy, cz]) = (a.coords(), b.coords(), c.coords());
|
||||
let (j0, j1) = range(1, ay.min(by).min(cy), ay.max(by).max(cy), ny);
|
||||
let (k0, k1) = range(2, az.min(bz).min(cz), az.max(bz).max(cz), nz);
|
||||
// The plane's normal, for the floating-point estimate of the crossing only.
|
||||
let (u, v) = ([bx - ax, by - ay, bz - az], [cx - ax, cy - ay, cz - az]);
|
||||
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]];
|
||||
for k in k0..=k1 {
|
||||
let z = grid.centre_along(2, k);
|
||||
for j in j0..=j1 {
|
||||
let y = grid.centre_along(1, j);
|
||||
let p2 = Point2::new(y, z)?;
|
||||
if edge_sign(&a2, &b2, &p2) != s || edge_sign(&b2, &c2, &p2) != s || edge_sign(&c2, &a2, &p2) != s {
|
||||
continue;
|
||||
}
|
||||
// Centres before the plane (the +x ray from them crosses the triangle):
|
||||
// orient3d(a, b, c, p) = −(p − a)·n has the sign s there. A centre on the plane is
|
||||
// perturbed by +ε in x (n_x has the sign s) and counts as beyond it.
|
||||
let behind = |i: usize| -> Result<bool> { Ok(orient3d(&a, &b, &c, &Point3::new(grid.centre_along(0, i), y, z)?) == s) };
|
||||
let x = if n[0] != 0.0 { ax - (n[1] * (y - ay) + n[2] * (z - az)) / n[0] } else { ax };
|
||||
let t = ((x - grid.origin[0]) / h - 0.5).ceil();
|
||||
let mut split = if t.is_nan() || t < 0.0 { 0 } else { (t as usize).min(nx) };
|
||||
while split > 0 && !behind(split - 1)? {
|
||||
split -= 1;
|
||||
}
|
||||
while split < nx && behind(split)? {
|
||||
split += 1;
|
||||
}
|
||||
let line = (j + ny * k) * (nx + 1);
|
||||
let w = s as i32;
|
||||
if let Some(first) = steps.get_mut(line) {
|
||||
*first += w;
|
||||
}
|
||||
if let Some(end) = steps.get_mut(line + split) {
|
||||
*end -= w;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut marked = 0;
|
||||
for line in 0..lines {
|
||||
let mut w = 0i32;
|
||||
for i in 0..nx {
|
||||
w += steps.get(line * (nx + 1) + i).copied().unwrap_or(0);
|
||||
if w > 0
|
||||
&& let Some(flag) = inside.get_mut(line * nx + i)
|
||||
&& !*flag
|
||||
{
|
||||
*flag = true;
|
||||
marked += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(marked)
|
||||
}
|
||||
|
||||
/// A bounding-volume tree over a surface's triangles for nearest-triangle queries.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TriangleTree {
|
||||
triangles: Vec<[[f64; 3]; 3]>,
|
||||
order: Vec<u32>,
|
||||
nodes: Vec<Node>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Node {
|
||||
lo: [f64; 3],
|
||||
hi: [f64; 3],
|
||||
/// Leaf: the range `first..first + count` of `order`; inner: `count == 0` and the children
|
||||
/// are `first` and `first + 1`.
|
||||
first: u32,
|
||||
count: u32,
|
||||
}
|
||||
|
||||
const LEAF: usize = 4;
|
||||
|
||||
fn bounds(tris: &[[[f64; 3]; 3]], ids: &[u32]) -> ([f64; 3], [f64; 3]) {
|
||||
let mut lo = [f64::INFINITY; 3];
|
||||
let mut hi = [f64::NEG_INFINITY; 3];
|
||||
for &t in ids {
|
||||
if let Some(tri) = tris.get(t as usize) {
|
||||
for p in tri {
|
||||
for a in 0..3 {
|
||||
lo[a] = lo[a].min(p[a]);
|
||||
hi[a] = hi[a].max(p[a]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(lo, hi)
|
||||
}
|
||||
|
||||
fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
|
||||
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
|
||||
}
|
||||
|
||||
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||
}
|
||||
|
||||
/// The squared distance from `p` to triangle `abc` (Ericson 2005, §5.1.5: the closest point by
|
||||
/// Voronoi regions of the vertices, edges and face).
|
||||
fn distance2(p: [f64; 3], [a, b, c]: &[[f64; 3]; 3]) -> f64 {
|
||||
let (ab, ac, ap) = (sub(*b, *a), sub(*c, *a), sub(p, *a));
|
||||
let at = |s: f64, t: f64| [a[0] + s * ab[0] + t * ac[0], a[1] + s * ab[1] + t * ac[1], a[2] + s * ab[2] + t * ac[2]];
|
||||
let d2 = |q: [f64; 3]| {
|
||||
let d = sub(p, q);
|
||||
dot(d, d)
|
||||
};
|
||||
let (d1, d2v) = (dot(ab, ap), dot(ac, ap));
|
||||
if d1 <= 0.0 && d2v <= 0.0 {
|
||||
return d2(*a);
|
||||
}
|
||||
let bp = sub(p, *b);
|
||||
let (d3, d4) = (dot(ab, bp), dot(ac, bp));
|
||||
if d3 >= 0.0 && d4 <= d3 {
|
||||
return d2(*b);
|
||||
}
|
||||
let vc = d1 * d4 - d3 * d2v;
|
||||
if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
|
||||
return d2(at(d1 / (d1 - d3), 0.0));
|
||||
}
|
||||
let cp = sub(p, *c);
|
||||
let (d5, d6) = (dot(ab, cp), dot(ac, cp));
|
||||
if d6 >= 0.0 && d5 <= d6 {
|
||||
return d2(*c);
|
||||
}
|
||||
let vb = d5 * d2v - d1 * d6;
|
||||
if vb <= 0.0 && d2v >= 0.0 && d6 <= 0.0 {
|
||||
return d2(at(0.0, d2v / (d2v - d6)));
|
||||
}
|
||||
let va = d3 * d6 - d5 * d4;
|
||||
if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
|
||||
let w = (d4 - d3) / ((d4 - d3) + (d5 - d6));
|
||||
return d2([b[0] + w * (c[0] - b[0]), b[1] + w * (c[1] - b[1]), b[2] + w * (c[2] - b[2])]);
|
||||
}
|
||||
let denom = 1.0 / (va + vb + vc);
|
||||
d2(at(vb * denom, vc * denom))
|
||||
}
|
||||
|
||||
fn box_distance2(p: [f64; 3], n: &Node) -> f64 {
|
||||
let mut d = 0.0;
|
||||
for a in 0..3 {
|
||||
let e = (n.lo[a] - p[a]).max(0.0).max(p[a] - n.hi[a]);
|
||||
d += e * e;
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
impl TriangleTree {
|
||||
pub fn new(surface: &BoundarySurface) -> TriangleTree {
|
||||
let pts = surface.points();
|
||||
let at = |v: u32| pts.get(v as usize).copied().unwrap_or([0.0; 3]);
|
||||
let triangles: Vec<[[f64; 3]; 3]> = surface.triangles().iter().map(|t| [at(t[0]), at(t[1]), at(t[2])]).collect();
|
||||
let centroid = |t: u32, a: usize| triangles.get(t as usize).map_or(0.0, |tri| tri[0][a] + tri[1][a] + tri[2][a]);
|
||||
let mut order: Vec<u32> = (0..triangles.len() as u32).collect();
|
||||
let mut nodes = Vec::with_capacity(2 * triangles.len() / LEAF + 1);
|
||||
let (lo, hi) = bounds(&triangles, &order);
|
||||
nodes.push(Node { lo, hi, first: 0, count: order.len() as u32 });
|
||||
// Split nodes breadth-first; each split halves a range, so this ends after fewer than
|
||||
// 2 n / LEAF nodes.
|
||||
let mut next = 0;
|
||||
while next < nodes.len() {
|
||||
let Some(node) = nodes.get(next).copied() else { break };
|
||||
next += 1;
|
||||
let (first, count) = (node.first as usize, node.count as usize);
|
||||
if count <= LEAF {
|
||||
continue;
|
||||
}
|
||||
let Some(ids) = order.get_mut(first..first + count) else { continue };
|
||||
let extent = sub(node.hi, node.lo);
|
||||
let axis = if extent[0] >= extent[1] && extent[0] >= extent[2] {
|
||||
0
|
||||
} else if extent[1] >= extent[2] {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
};
|
||||
ids.sort_by(|&p, &q| centroid(p, axis).total_cmp(¢roid(q, axis)).then(p.cmp(&q)));
|
||||
let half = count / 2;
|
||||
let (left, right) = ids.split_at(half);
|
||||
let (llo, lhi) = bounds(&triangles, left);
|
||||
let (rlo, rhi) = bounds(&triangles, right);
|
||||
let child = nodes.len() as u32;
|
||||
nodes.push(Node { lo: llo, hi: lhi, first: first as u32, count: half as u32 });
|
||||
nodes.push(Node { lo: rlo, hi: rhi, first: (first + half) as u32, count: (count - half) as u32 });
|
||||
if let Some(n) = nodes.get_mut(next - 1) {
|
||||
n.first = child;
|
||||
n.count = 0;
|
||||
}
|
||||
}
|
||||
TriangleTree { triangles, order, nodes }
|
||||
}
|
||||
|
||||
/// The triangle nearest `p` (the lowest index among equally near ones).
|
||||
pub fn nearest(&self, p: [f64; 3]) -> Option<u32> {
|
||||
let mut best: Option<(f64, u32)> = None;
|
||||
let better = |d: f64, t: u32, best: &Option<(f64, u32)>| best.is_none_or(|(bd, bt)| d < bd || (d == bd && t < bt));
|
||||
let mut stack = vec![0u32];
|
||||
// Every node is pushed at most once.
|
||||
while let Some(i) = stack.pop() {
|
||||
let Some(node) = self.nodes.get(i as usize) else { continue };
|
||||
if best.is_some_and(|(bd, _)| box_distance2(p, node) > bd) {
|
||||
continue;
|
||||
}
|
||||
if node.count > 0 {
|
||||
let range = node.first as usize..(node.first + node.count) as usize;
|
||||
for &t in self.order.get(range).unwrap_or_default() {
|
||||
if let Some(tri) = self.triangles.get(t as usize) {
|
||||
let d = distance2(p, tri);
|
||||
if better(d, t, &best) {
|
||||
best = Some((d, t));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let (l, r) = (node.first, node.first + 1);
|
||||
let dist = |c: u32| self.nodes.get(c as usize).map_or(f64::INFINITY, |n| box_distance2(p, n));
|
||||
// Visit the nearer child first (pushed last).
|
||||
if dist(l) <= dist(r) {
|
||||
stack.extend([r, l]);
|
||||
} else {
|
||||
stack.extend([l, r]);
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, t)| t)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use modelcraft_mesh::surface::SurfaceOptions;
|
||||
|
||||
/// The surface of the box [lo, hi], outward, two triangles per side, side k tagged k.
|
||||
pub(crate) fn cube(lo: [f64; 3], hi: [f64; 3]) -> BoundarySurface {
|
||||
let p = |i: usize| [if i & 1 == 0 { lo[0] } else { hi[0] }, if i & 2 == 0 { lo[1] } else { hi[1] }, if i & 4 == 0 { lo[2] } else { hi[2] }];
|
||||
let positions: Vec<[f64; 3]> = (0..8).map(p).collect();
|
||||
// Quads counter-clockwise seen from outside.
|
||||
let quads = [[0, 4, 6, 2], [1, 3, 7, 5], [0, 1, 5, 4], [2, 6, 7, 3], [0, 2, 3, 1], [4, 5, 7, 6]];
|
||||
let mut triangles = Vec::new();
|
||||
let mut tags = Vec::new();
|
||||
for (k, q) in quads.iter().enumerate() {
|
||||
triangles.push([q[0], q[1], q[2]]);
|
||||
triangles.push([q[0], q[2], q[3]]);
|
||||
tags.extend([k as u32; 2]);
|
||||
}
|
||||
BoundarySurface::new(&positions, &triangles, &tags, &SurfaceOptions::default()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projected_orientation_is_the_normal_x_sign() {
|
||||
let pt = |x, y, z| Point2::new(y, z).map(|p| (x, p)).unwrap().1;
|
||||
// Triangle (0,0,0), (0,1,0), (0,0,1): normal +x; its (y, z) projection is counter-clockwise.
|
||||
assert_eq!(orient2d(&pt(0.0, 0.0, 0.0), &pt(0.0, 1.0, 0.0), &pt(0.0, 0.0, 1.0)), Sign::Positive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_box_is_voxelised_exactly_even_with_faces_on_centres() {
|
||||
// Cells of size 1 from 0 to 8; the box's faces pass through cell centres (x = 2.5, 5.5)
|
||||
// and between them (y = 2, 6), and its edges lie on grid lines.
|
||||
let grid = Grid { origin: [0.0; 3], spacing: 1.0, cells: [8, 8, 8] };
|
||||
let s = cube([2.5, 2.0, 2.5], [5.5, 6.0, 5.5]);
|
||||
let mut inside = vec![false; grid.len()];
|
||||
let n = voxelise(&s, &grid, &mut inside).unwrap();
|
||||
// Centres on a face are decided by the perturbation p + (ε, δ, δ²): beyond it in +x, y and z.
|
||||
let mut count = 0;
|
||||
for c in 0..grid.len() {
|
||||
let [x, y, z] = grid.centre(c);
|
||||
let strictly = x > 2.5 && x < 5.5 && y > 2.0 && y < 6.0 && z > 2.5 && z < 5.5;
|
||||
let maybe = (2.5..=5.5).contains(&x) && (2.0..=6.0).contains(&y) && (2.5..=5.5).contains(&z);
|
||||
if strictly {
|
||||
assert!(inside[c], "{x} {y} {z}");
|
||||
}
|
||||
if !maybe {
|
||||
assert!(!inside[c], "{x} {y} {z}");
|
||||
}
|
||||
count += usize::from(inside[c]);
|
||||
}
|
||||
assert_eq!(n, count);
|
||||
// x: 2.5 (moved inside), 3.5, 4.5 (5.5 moves outside); y: 2.5 … 5.5; z as x.
|
||||
assert_eq!(n, 3 * 4 * 3, "{n}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearest_triangle_and_its_face() {
|
||||
let s = cube([0.0; 3], [1.0; 3]);
|
||||
let tree = TriangleTree::new(&s);
|
||||
// Just outside the +y side, near the -x edge: side 3 (y = hi).
|
||||
let t = tree.nearest([0.1, 1.2, 0.5]).unwrap();
|
||||
assert_eq!(s.tri_face()[t as usize], 3);
|
||||
let t = tree.nearest([0.5, 0.5, -0.3]).unwrap();
|
||||
assert_eq!(s.tri_face()[t as usize], 4);
|
||||
// Against brute force on scattered points.
|
||||
for i in 0..200 {
|
||||
let f = |k: u32| ((i * 7919 + k * 104_729) % 1000) as f64 / 400.0 - 0.75;
|
||||
let p = [f(1), f(2), f(3)];
|
||||
let brute = (0..s.triangles().len() as u32)
|
||||
.min_by(|&a, &b| {
|
||||
let tri = |t: u32| {
|
||||
let v = s.triangles()[t as usize];
|
||||
[s.points()[v[0] as usize], s.points()[v[1] as usize], s.points()[v[2] as usize]]
|
||||
};
|
||||
distance2(p, &tri(a)).total_cmp(&distance2(p, &tri(b))).then(a.cmp(&b))
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(tree.nearest(p), Some(brute), "{p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -136,7 +136,9 @@ impl Model {
|
||||
let mut resolved = Resolved::default();
|
||||
let units = self.units;
|
||||
|
||||
if self.elements.is_empty() {
|
||||
// A model of fluid steps alone needs no mesh: its domains carry their own grid.
|
||||
let fluid_only = !self.steps.is_empty() && self.steps.iter().all(|s| s.procedure.fluid_domain().is_some());
|
||||
if self.elements.is_empty() && !fluid_only {
|
||||
d.error("model", "the mesh has no elements");
|
||||
}
|
||||
if units.length().is_none() {
|
||||
@@ -248,7 +250,7 @@ impl Model {
|
||||
let why = match s.procedure {
|
||||
Procedure::Frequency { .. } => "extracts frequencies",
|
||||
Procedure::ExplicitDynamic(_) => "is dynamic",
|
||||
Procedure::Static {} => return None,
|
||||
Procedure::Static {} | Procedure::Fluid { .. } => return None,
|
||||
};
|
||||
Some(format!("step `{}` {why}", s.name))
|
||||
})
|
||||
@@ -373,6 +375,8 @@ impl Model {
|
||||
d.warning(&subject, "nothing loads or moves the model in this step");
|
||||
}
|
||||
}
|
||||
// The fluid domain holds its own conditions; nothing structural applies.
|
||||
Procedure::Fluid { .. } => continue,
|
||||
}
|
||||
// A free body is a natural case of transient dynamics (flight, impact).
|
||||
if s.boundary_conditions.is_empty() && !s.procedure.is_dynamic() {
|
||||
|
||||
@@ -0,0 +1,577 @@
|
||||
//! Fluid domains: the box a fluid step solves the flow in.
|
||||
//!
|
||||
//! A [`FluidDomain`] is an axis-aligned box filled with a fluid material and divided into cubic
|
||||
//! cells of one spacing. Each of the six box faces carries a [`FaceCondition`] (a wall, a moving
|
||||
//! or slip wall, a velocity inlet, a pressure outlet, or a periodic pair). Solid bodies of the
|
||||
//! geometry placed in the box ([`FluidDomain::obstacles`], by body name) take their cells out of
|
||||
//! the fluid; their faces are no-slip walls unless a [`SurfaceBoundary`] names one as a moving
|
||||
//! wall. The forces the fluid exerts on every face are a fluid step's result.
|
||||
//!
|
||||
//! Like the rest of the model this is solver-neutral and unit-consistent: lengths, velocities,
|
||||
//! pressures and accelerations are in the model's unit system, and the solver chooses its own
|
||||
//! scaled units from them.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::geometry::{Entity, GeometryRef};
|
||||
use crate::{Error, Model, Result, context, finite, insert_new, not_in_use, positive, remove_existing, replace_existing, require};
|
||||
|
||||
/// Most obstacle bodies in one domain.
|
||||
pub const MAX_OBSTACLES: usize = 1000;
|
||||
|
||||
/// Most surface conditions in one domain.
|
||||
pub const MAX_SURFACE_CONDITIONS: usize = 10_000;
|
||||
|
||||
/// Most cells along one side of a domain: the cell count is the product of the three, so this
|
||||
/// bounds every count derived from a domain before the solver applies its own caps.
|
||||
pub const MAX_CELLS_PER_SIDE: u64 = 1 << 20;
|
||||
|
||||
/// How far the box extent may be from a whole number of spacings, relative to the spacing.
|
||||
const WHOLE_CELLS_TOLERANCE: f64 = 1e-6;
|
||||
|
||||
/// A face of the domain box.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BoxFace {
|
||||
XMin,
|
||||
XMax,
|
||||
YMin,
|
||||
YMax,
|
||||
ZMin,
|
||||
ZMax,
|
||||
}
|
||||
|
||||
impl BoxFace {
|
||||
pub const ALL: [BoxFace; 6] = [BoxFace::XMin, BoxFace::XMax, BoxFace::YMin, BoxFace::YMax, BoxFace::ZMin, BoxFace::ZMax];
|
||||
|
||||
/// The axis normal to the face: 0, 1 or 2.
|
||||
pub fn axis(self) -> usize {
|
||||
match self {
|
||||
BoxFace::XMin | BoxFace::XMax => 0,
|
||||
BoxFace::YMin | BoxFace::YMax => 1,
|
||||
BoxFace::ZMin | BoxFace::ZMax => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the face is at the upper end of its axis.
|
||||
pub fn is_max(self) -> bool {
|
||||
matches!(self, BoxFace::XMax | BoxFace::YMax | BoxFace::ZMax)
|
||||
}
|
||||
|
||||
/// The face across the box.
|
||||
pub fn opposite(self) -> BoxFace {
|
||||
match self {
|
||||
BoxFace::XMin => BoxFace::XMax,
|
||||
BoxFace::XMax => BoxFace::XMin,
|
||||
BoxFace::YMin => BoxFace::YMax,
|
||||
BoxFace::YMax => BoxFace::YMin,
|
||||
BoxFace::ZMin => BoxFace::ZMax,
|
||||
BoxFace::ZMax => BoxFace::ZMin,
|
||||
}
|
||||
}
|
||||
|
||||
/// `x_min`, `x_max`, …
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
BoxFace::XMin => "x_min",
|
||||
BoxFace::XMax => "x_max",
|
||||
BoxFace::YMin => "y_min",
|
||||
BoxFace::YMax => "y_max",
|
||||
BoxFace::ZMin => "z_min",
|
||||
BoxFace::ZMax => "z_max",
|
||||
}
|
||||
}
|
||||
|
||||
/// The face's index in [`BoxFace::ALL`].
|
||||
pub fn index(self) -> usize {
|
||||
2 * self.axis() + usize::from(self.is_max())
|
||||
}
|
||||
|
||||
/// The face of `axis` (0, 1, 2; larger values mean z) at its upper end when `max`.
|
||||
pub fn of(axis: usize, max: bool) -> BoxFace {
|
||||
match (axis, max) {
|
||||
(0, false) => BoxFace::XMin,
|
||||
(0, true) => BoxFace::XMax,
|
||||
(1, false) => BoxFace::YMin,
|
||||
(1, true) => BoxFace::YMax,
|
||||
(_, false) => BoxFace::ZMin,
|
||||
(_, true) => BoxFace::ZMax,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a velocity inlet's velocity varies over its face.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Profile {
|
||||
/// The same velocity everywhere on the face.
|
||||
#[default]
|
||||
Uniform,
|
||||
/// The velocity times `4 s (1 − s)` across each direction of the face that ends at walls
|
||||
/// (not periodic), `s` from 0 to 1 across the box: fully developed laminar flow between
|
||||
/// plates (one such direction) or in a rectangular duct approximated by the product (two).
|
||||
/// The given velocity is the peak.
|
||||
Parabolic,
|
||||
}
|
||||
|
||||
/// The condition on a face of the domain box.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum FaceCondition {
|
||||
/// A no-slip wall at rest.
|
||||
Wall {},
|
||||
/// A no-slip wall sliding in its own plane (the velocity has no component normal to the
|
||||
/// face).
|
||||
MovingWall { velocity: [f64; 3] },
|
||||
/// A frictionless wall: no flow through it, no shear.
|
||||
Slip {},
|
||||
/// A velocity inlet (or outlet, if the velocity points out of the box).
|
||||
Velocity {
|
||||
velocity: [f64; 3],
|
||||
#[serde(default, skip_serializing_if = "is_uniform")]
|
||||
profile: Profile,
|
||||
},
|
||||
/// A pressure outlet (or inlet) at this gauge pressure; the flow crosses it normally.
|
||||
Pressure { pressure: f64 },
|
||||
/// What leaves through this face enters through the opposite one, which must be periodic too.
|
||||
Periodic {},
|
||||
}
|
||||
|
||||
fn is_uniform(p: &Profile) -> bool {
|
||||
*p == Profile::Uniform
|
||||
}
|
||||
|
||||
impl Default for FaceCondition {
|
||||
fn default() -> Self {
|
||||
FaceCondition::Wall {}
|
||||
}
|
||||
}
|
||||
|
||||
impl FaceCondition {
|
||||
/// `wall`, `moving_wall`, `slip`, `velocity`, `pressure` or `periodic`.
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
FaceCondition::Wall {} => "wall",
|
||||
FaceCondition::MovingWall { .. } => "moving_wall",
|
||||
FaceCondition::Slip {} => "slip",
|
||||
FaceCondition::Velocity { .. } => "velocity",
|
||||
FaceCondition::Pressure { .. } => "pressure",
|
||||
FaceCondition::Periodic {} => "periodic",
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(&self, face: BoxFace) -> Result<()> {
|
||||
match *self {
|
||||
FaceCondition::Wall {} | FaceCondition::Slip {} | FaceCondition::Periodic {} => Ok(()),
|
||||
FaceCondition::MovingWall { velocity } => {
|
||||
vector("wall velocity", velocity)?;
|
||||
if velocity.get(face.axis()).is_some_and(|v| *v != 0.0) {
|
||||
return Err(Error::Invalid(format!(
|
||||
"a moving wall slides in its own plane: the {} component of its velocity must be 0",
|
||||
["x", "y", "z"].get(face.axis()).copied().unwrap_or("normal")
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FaceCondition::Velocity { velocity, .. } => vector("inlet velocity", velocity).map(|_| ()),
|
||||
FaceCondition::Pressure { pressure } => finite("pressure", pressure).map(|_| ()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The conditions on the six faces of the box; every face is a wall unless given.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct BoxFaces {
|
||||
pub x_min: FaceCondition,
|
||||
pub x_max: FaceCondition,
|
||||
pub y_min: FaceCondition,
|
||||
pub y_max: FaceCondition,
|
||||
pub z_min: FaceCondition,
|
||||
pub z_max: FaceCondition,
|
||||
}
|
||||
|
||||
impl BoxFaces {
|
||||
pub fn get(&self, face: BoxFace) -> &FaceCondition {
|
||||
match face {
|
||||
BoxFace::XMin => &self.x_min,
|
||||
BoxFace::XMax => &self.x_max,
|
||||
BoxFace::YMin => &self.y_min,
|
||||
BoxFace::YMax => &self.y_max,
|
||||
BoxFace::ZMin => &self.z_min,
|
||||
BoxFace::ZMax => &self.z_max,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_mut(&mut self, face: BoxFace) -> &mut FaceCondition {
|
||||
match face {
|
||||
BoxFace::XMin => &mut self.x_min,
|
||||
BoxFace::XMax => &mut self.x_max,
|
||||
BoxFace::YMin => &mut self.y_min,
|
||||
BoxFace::YMax => &mut self.y_max,
|
||||
BoxFace::ZMin => &mut self.z_min,
|
||||
BoxFace::ZMax => &mut self.z_max,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the faces of `axis` (0, 1, 2) are a periodic pair.
|
||||
pub fn periodic(&self, axis: usize) -> bool {
|
||||
BoxFace::ALL.iter().any(|f| f.axis() == axis && matches!(self.get(*f), FaceCondition::Periodic {}))
|
||||
}
|
||||
}
|
||||
|
||||
/// The condition on a face of an obstacle body.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum SurfaceCondition {
|
||||
/// A no-slip wall at rest (every obstacle face is one unless told otherwise).
|
||||
Wall {},
|
||||
/// A no-slip wall moving with this velocity (tangential motion of a rotating or sliding
|
||||
/// surface; the body itself does not move).
|
||||
MovingWall { velocity: [f64; 3] },
|
||||
}
|
||||
|
||||
/// A condition on one named face of an obstacle body.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SurfaceBoundary {
|
||||
/// `{"body": "Body1", "face": "F2:top"}`; the face name also selects its split pieces
|
||||
/// (`F2:top#0`, …), as in sets.
|
||||
pub face: GeometryRef,
|
||||
pub condition: SurfaceCondition,
|
||||
}
|
||||
|
||||
/// Where a boundary condition goes: a face of the box, or a named face of an obstacle.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum FluidBoundary {
|
||||
Box(BoxFace),
|
||||
Surface(GeometryRef),
|
||||
}
|
||||
|
||||
/// A fluid domain.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct FluidDomain {
|
||||
/// A material with a density and fluid behaviour ([`crate::Fluid`]).
|
||||
pub material: String,
|
||||
/// The box's lower and upper corners.
|
||||
pub min: [f64; 3],
|
||||
pub max: [f64; 3],
|
||||
/// The side of the cubic cells; each extent of the box must be a whole number of spacings.
|
||||
pub spacing: f64,
|
||||
#[serde(default)]
|
||||
pub faces: BoxFaces,
|
||||
/// Solid bodies of the geometry in the box (by name): their cells are not fluid.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub obstacles: Vec<String>,
|
||||
/// Conditions on named faces of the obstacles (each face once).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub surfaces: Vec<SurfaceBoundary>,
|
||||
/// The fluid's velocity at the start.
|
||||
#[serde(default, skip_serializing_if = "is_zero")]
|
||||
pub initial_velocity: [f64; 3],
|
||||
/// A body acceleration on the fluid (gravity, or a uniform driving force per mass).
|
||||
#[serde(default, skip_serializing_if = "is_zero")]
|
||||
pub acceleration: [f64; 3],
|
||||
/// The chordal tolerance obstacles are triangulated with (default: a tenth of the spacing).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tolerance: Option<f64>,
|
||||
}
|
||||
|
||||
fn is_zero(v: &[f64; 3]) -> bool {
|
||||
v.iter().all(|c| *c == 0.0)
|
||||
}
|
||||
|
||||
fn vector(what: &str, v: [f64; 3]) -> Result<[f64; 3]> {
|
||||
for c in v {
|
||||
finite(what, c)?;
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
impl FluidDomain {
|
||||
/// The number of cells along each axis, checked to be whole and within
|
||||
/// [`MAX_CELLS_PER_SIDE`].
|
||||
pub fn cells(&self) -> Result<[u64; 3]> {
|
||||
let h = positive("spacing", self.spacing)?;
|
||||
let mut n = [0u64; 3];
|
||||
for (axis, slot) in n.iter_mut().enumerate() {
|
||||
let (lo, hi) = (self.min.get(axis).copied().unwrap_or(0.0), self.max.get(axis).copied().unwrap_or(0.0));
|
||||
let name = ["x", "y", "z"].get(axis).copied().unwrap_or("?");
|
||||
if !(lo.is_finite() && hi.is_finite() && hi > lo) {
|
||||
return Err(Error::Invalid(format!("the box's {name} range {lo}..{hi} is empty or not finite")));
|
||||
}
|
||||
let cells = (hi - lo) / h;
|
||||
let whole = cells.round();
|
||||
if !(whole >= 1.0 && whole <= MAX_CELLS_PER_SIDE as f64) {
|
||||
return Err(Error::Invalid(format!("the box spans {cells} spacings along {name}; it must span 1 to {MAX_CELLS_PER_SIDE} cells")));
|
||||
}
|
||||
if (cells - whole).abs() > WHOLE_CELLS_TOLERANCE * whole.max(1.0) {
|
||||
return Err(Error::Invalid(format!("the box's {name} extent {} is not a whole number of spacings {h} ({cells} cells)", hi - lo)));
|
||||
}
|
||||
*slot = whole as u64;
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Check everything that does not need the rest of the model.
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
for (what, v) in [("min", self.min), ("max", self.max), ("initial velocity", self.initial_velocity), ("acceleration", self.acceleration)] {
|
||||
vector(what, v)?;
|
||||
}
|
||||
self.cells()?;
|
||||
for face in BoxFace::ALL {
|
||||
let c = self.faces.get(face);
|
||||
c.validate(face).map_err(|e| context("box face", face.name(), e))?;
|
||||
let other = self.faces.get(face.opposite());
|
||||
// Both faces of a pair are visited, so checking one direction finds every broken pair.
|
||||
if matches!(c, FaceCondition::Periodic {}) && !matches!(other, FaceCondition::Periodic {}) {
|
||||
return Err(Error::Invalid(format!(
|
||||
"box face {} is periodic but {} is {}: periodic faces come in opposite pairs",
|
||||
face.name(),
|
||||
face.opposite().name(),
|
||||
other.name()
|
||||
)));
|
||||
}
|
||||
}
|
||||
if self.obstacles.len() > MAX_OBSTACLES {
|
||||
return Err(Error::Limit(format!("a fluid domain holds at most {MAX_OBSTACLES} obstacles")));
|
||||
}
|
||||
for (i, b) in self.obstacles.iter().enumerate() {
|
||||
GeometryRef::body(b.as_str())?;
|
||||
if self.obstacles.get(..i).is_some_and(|before| before.contains(b)) {
|
||||
return Err(Error::Invalid(format!("obstacle `{b}` is listed twice")));
|
||||
}
|
||||
}
|
||||
if self.surfaces.len() > MAX_SURFACE_CONDITIONS {
|
||||
return Err(Error::Limit(format!("a fluid domain holds at most {MAX_SURFACE_CONDITIONS} surface conditions")));
|
||||
}
|
||||
for (i, s) in self.surfaces.iter().enumerate() {
|
||||
if !matches!(s.face.entity, Entity::Face(_)) {
|
||||
return Err(Error::Invalid(format!("surface conditions go on faces, not on the {}", s.face)));
|
||||
}
|
||||
if !self.obstacles.contains(&s.face.body) {
|
||||
return Err(Error::Invalid(format!(
|
||||
"the {} is not on an obstacle of the domain (list body `{}` in `obstacles`)",
|
||||
s.face, s.face.body
|
||||
)));
|
||||
}
|
||||
if let SurfaceCondition::MovingWall { velocity } = s.condition {
|
||||
vector("wall velocity", velocity)?;
|
||||
}
|
||||
if self.surfaces.get(..i).is_some_and(|before| before.iter().any(|o| o.face == s.face)) {
|
||||
return Err(Error::Invalid(format!("the {} has two conditions", s.face)));
|
||||
}
|
||||
}
|
||||
if let Some(t) = self.tolerance {
|
||||
positive("tolerance", t)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set the condition on one boundary: a box face, or a named face of an obstacle, which
|
||||
/// takes a wall or a moving wall (a wall there removes the entry, walls being the default).
|
||||
/// The caller validates the result ([`Model::set_fluid_boundary`] does).
|
||||
pub fn set_boundary(&mut self, at: FluidBoundary, condition: FaceCondition) -> Result<()> {
|
||||
match at {
|
||||
FluidBoundary::Box(face) => *self.faces.get_mut(face) = condition,
|
||||
FluidBoundary::Surface(face) => {
|
||||
let c = match condition {
|
||||
FaceCondition::Wall {} => SurfaceCondition::Wall {},
|
||||
FaceCondition::MovingWall { velocity } => SurfaceCondition::MovingWall { velocity },
|
||||
other => return Err(Error::Invalid(format!("the {face} takes a wall or a moving wall, not {}", other.name()))),
|
||||
};
|
||||
self.surfaces.retain(|s| s.face != face);
|
||||
if c != (SurfaceCondition::Wall {}) {
|
||||
self.surfaces.push(SurfaceBoundary { face, condition: c });
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Model {
|
||||
pub fn fluid_domains(&self) -> &BTreeMap<String, FluidDomain> {
|
||||
&self.fluid_domains
|
||||
}
|
||||
|
||||
pub fn fluid_domain(&self, name: &str) -> Option<&FluidDomain> {
|
||||
self.fluid_domains.get(name)
|
||||
}
|
||||
|
||||
fn check_fluid_domain(&self, d: &FluidDomain) -> Result<()> {
|
||||
d.validate()?;
|
||||
require(&self.materials, "material", &d.material)?;
|
||||
match self.materials.get(&d.material) {
|
||||
Some(m) if m.fluid.is_none() => Err(Error::Invalid(format!("material `{}` has no fluid behaviour (a viscosity)", d.material))),
|
||||
Some(m) if m.density.is_none() => Err(Error::Invalid(format!("material `{}` has no density", d.material))),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_fluid_domain(&mut self, name: &str, domain: FluidDomain) -> Result<()> {
|
||||
self.check_fluid_domain(&domain).map_err(|e| context("fluid domain", name, e))?;
|
||||
insert_new(&mut self.fluid_domains, "fluid domain", name, domain)
|
||||
}
|
||||
|
||||
pub fn replace_fluid_domain(&mut self, name: &str, domain: FluidDomain) -> Result<()> {
|
||||
self.check_fluid_domain(&domain).map_err(|e| context("fluid domain", name, e))?;
|
||||
replace_existing(&mut self.fluid_domains, "fluid domain", name, domain)
|
||||
}
|
||||
|
||||
pub fn remove_fluid_domain(&mut self, name: &str) -> Result<()> {
|
||||
let users = self.steps.iter().filter(|s| s.procedure.fluid_domain() == Some(name)).map(|s| format!("step `{}`", s.name)).collect();
|
||||
not_in_use("fluid domain", name, users)?;
|
||||
remove_existing(&mut self.fluid_domains, "fluid domain", name).map(|_| ())
|
||||
}
|
||||
|
||||
/// Set the condition on one boundary of a domain.
|
||||
pub fn set_fluid_boundary(&mut self, name: &str, at: FluidBoundary, condition: FaceCondition) -> Result<()> {
|
||||
let mut d = self.fluid_domains.get(name).cloned().ok_or_else(|| Error::NotFound(format!("fluid domain `{name}`")))?;
|
||||
d.set_boundary(at, condition).map_err(|e| context("fluid domain", name, e))?;
|
||||
self.replace_fluid_domain(name, d)
|
||||
}
|
||||
|
||||
/// The fluid domains that use a material.
|
||||
pub(crate) fn fluid_domain_users(&self, material: &str) -> impl Iterator<Item = &str> {
|
||||
self.fluid_domains.iter().filter(move |(_, d)| d.material == material).map(|(n, _)| n.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Fluid, Material, Procedure, Step};
|
||||
|
||||
fn water() -> Material {
|
||||
Material { density: Some(1.0e-9), fluid: Some(Fluid::Newtonian { viscosity: 1.0e-9 }), ..Material::default() }
|
||||
}
|
||||
|
||||
pub(crate) fn channel() -> FluidDomain {
|
||||
FluidDomain {
|
||||
material: "Water".into(),
|
||||
min: [0.0, 0.0, 0.0],
|
||||
max: [10.0, 2.0, 1.0],
|
||||
spacing: 0.5,
|
||||
faces: BoxFaces {
|
||||
x_min: FaceCondition::Velocity { velocity: [1.0, 0.0, 0.0], profile: Profile::Parabolic },
|
||||
x_max: FaceCondition::Pressure { pressure: 0.0 },
|
||||
z_min: FaceCondition::Periodic {},
|
||||
z_max: FaceCondition::Periodic {},
|
||||
..BoxFaces::default()
|
||||
},
|
||||
obstacles: vec![],
|
||||
surfaces: vec![],
|
||||
initial_velocity: [0.0; 3],
|
||||
acceleration: [0.0; 3],
|
||||
tolerance: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domains_validate_and_refer_to_a_fluid_material() {
|
||||
let mut m = Model::default();
|
||||
assert!(m.add_fluid_domain("Flow", channel()).is_err(), "no material yet");
|
||||
m.add_material("Water", water()).unwrap();
|
||||
m.add_fluid_domain("Flow", channel()).unwrap();
|
||||
assert_eq!(m.fluid_domain("Flow").unwrap().cells().unwrap(), [20, 4, 2]);
|
||||
|
||||
let bad = |f: &dyn Fn(&mut FluidDomain)| {
|
||||
let mut d = channel();
|
||||
f(&mut d);
|
||||
d
|
||||
};
|
||||
for d in [
|
||||
bad(&|d| d.spacing = 0.0),
|
||||
bad(&|d| d.spacing = f64::NAN),
|
||||
bad(&|d| d.spacing = 0.3),
|
||||
bad(&|d| d.max[0] = -1.0),
|
||||
bad(&|d| d.min[1] = f64::INFINITY),
|
||||
bad(&|d| d.spacing = 1e-300),
|
||||
bad(&|d| d.faces.z_max = FaceCondition::Wall {}),
|
||||
bad(&|d| d.faces.y_min = FaceCondition::MovingWall { velocity: [1.0, 1.0, 0.0] }),
|
||||
bad(&|d| d.faces.x_min = FaceCondition::Velocity { velocity: [f64::NAN, 0.0, 0.0], profile: Profile::Uniform }),
|
||||
bad(&|d| d.faces.x_max = FaceCondition::Pressure { pressure: f64::INFINITY }),
|
||||
bad(&|d| d.obstacles = vec!["A".into(), "A".into()]),
|
||||
bad(&|d| d.obstacles = vec![String::new()]),
|
||||
bad(&|d| d.surfaces = vec![SurfaceBoundary { face: GeometryRef::face("B", "f").unwrap(), condition: SurfaceCondition::Wall {} }]),
|
||||
bad(&|d| {
|
||||
d.obstacles = vec!["B".into()];
|
||||
d.surfaces = vec![SurfaceBoundary { face: GeometryRef::body("B").unwrap(), condition: SurfaceCondition::Wall {} }];
|
||||
}),
|
||||
bad(&|d| d.tolerance = Some(0.0)),
|
||||
bad(&|d| d.acceleration = [0.0, f64::NAN, 0.0]),
|
||||
] {
|
||||
assert!(m.add_fluid_domain("Bad", d.clone()).is_err(), "{d:?}");
|
||||
}
|
||||
let mut solid = water();
|
||||
solid.fluid = None;
|
||||
m.add_material("Steel", Material { elastic: None, density: Some(7.85e-9), fluid: None }).unwrap();
|
||||
assert!(m.add_fluid_domain("Dry", FluidDomain { material: "Steel".into(), ..channel() }).is_err());
|
||||
assert!(m.replace_material("Water", solid).is_err(), "a domain needs the fluid behaviour");
|
||||
assert!(matches!(m.remove_material("Water"), Err(Error::InUse(_))));
|
||||
|
||||
// A step refers to the domain, which then cannot be deleted.
|
||||
let fluid = Procedure::Fluid { domain: "Flow".into(), end_time: Some(1.0), steps: None, time_step: None, frames: 4, magic: None };
|
||||
m.add_step(Step::new("Run", fluid.clone()), None).unwrap();
|
||||
assert!(matches!(m.remove_fluid_domain("Flow"), Err(Error::InUse(_))));
|
||||
// The JSON form reads back to the same model, through the same checks.
|
||||
let text = serde_json::to_string(&m).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Model>(&text).unwrap(), m);
|
||||
assert!(serde_json::from_str::<Model>(&text.replace("\"spacing\":0.5", "\"spacing\":0.3")).is_err());
|
||||
// Fluid steps take nothing structural, and need a domain and one length.
|
||||
let mut listed = Step::new("Listed", fluid.clone());
|
||||
listed.output.insert(crate::OutputVariable::Stress);
|
||||
assert!(m.add_step(listed, None).is_err());
|
||||
for bad in [
|
||||
Procedure::Fluid { domain: "Nope".into(), end_time: Some(1.0), steps: None, time_step: None, frames: 4, magic: None },
|
||||
Procedure::Fluid { domain: "Flow".into(), end_time: Some(1.0), steps: Some(5), time_step: None, frames: 4, magic: None },
|
||||
Procedure::Fluid { domain: "Flow".into(), end_time: None, steps: None, time_step: None, frames: 4, magic: None },
|
||||
Procedure::Fluid { domain: "Flow".into(), end_time: Some(-1.0), steps: None, time_step: None, frames: 4, magic: None },
|
||||
Procedure::Fluid { domain: "Flow".into(), end_time: None, steps: Some(0), time_step: None, frames: 4, magic: None },
|
||||
Procedure::Fluid { domain: "Flow".into(), end_time: Some(1.0), steps: None, time_step: Some(0.0), frames: 4, magic: None },
|
||||
Procedure::Fluid { domain: "Flow".into(), end_time: Some(1.0), steps: None, time_step: None, frames: 0, magic: None },
|
||||
Procedure::Fluid { domain: "Flow".into(), end_time: Some(1.0), steps: None, time_step: None, frames: 4, magic: Some(0.0) },
|
||||
Procedure::Fluid { domain: "Flow".into(), end_time: Some(1.0), steps: None, time_step: None, frames: 4, magic: Some(f64::NAN) },
|
||||
] {
|
||||
assert!(m.add_step(Step::new("Bad", bad.clone()), None).is_err(), "{bad:?}");
|
||||
}
|
||||
m.remove_step("Run").unwrap();
|
||||
m.remove_fluid_domain("Flow").unwrap();
|
||||
m.remove_material("Water").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundaries_are_set_one_at_a_time() {
|
||||
let mut m = Model::default();
|
||||
m.add_material("Water", water()).unwrap();
|
||||
let mut d = channel();
|
||||
d.obstacles = vec!["Ball".into()];
|
||||
m.add_fluid_domain("Flow", d).unwrap();
|
||||
m.set_fluid_boundary("Flow", FluidBoundary::Box(BoxFace::YMax), FaceCondition::Slip {}).unwrap();
|
||||
assert_eq!(m.fluid_domain("Flow").unwrap().faces.y_max, FaceCondition::Slip {});
|
||||
let face = GeometryRef::face("Ball", "F1").unwrap();
|
||||
let spin = FaceCondition::MovingWall { velocity: [0.0, 1.0, 0.0] };
|
||||
m.set_fluid_boundary("Flow", FluidBoundary::Surface(face.clone()), spin).unwrap();
|
||||
assert_eq!(m.fluid_domain("Flow").unwrap().surfaces.len(), 1);
|
||||
m.set_fluid_boundary("Flow", FluidBoundary::Surface(face.clone()), FaceCondition::Wall {}).unwrap();
|
||||
assert!(m.fluid_domain("Flow").unwrap().surfaces.is_empty(), "walls are the default");
|
||||
// Wrong kinds and broken pairs are refused and change nothing.
|
||||
let before = m.clone();
|
||||
assert!(m.set_fluid_boundary("Flow", FluidBoundary::Box(BoxFace::ZMin), FaceCondition::Wall {}).is_err());
|
||||
assert!(m.set_fluid_boundary("Flow", FluidBoundary::Surface(face), FaceCondition::Slip {}).is_err(), "obstacle faces are walls");
|
||||
assert!(m.set_fluid_boundary("Nope", FluidBoundary::Box(BoxFace::XMin), FaceCondition::Wall {}).is_err());
|
||||
assert_eq!(m, before);
|
||||
|
||||
// The JSON forms.
|
||||
let at: FluidBoundary = serde_json::from_value(serde_json::json!("x_min")).unwrap();
|
||||
assert_eq!(at, FluidBoundary::Box(BoxFace::XMin));
|
||||
let at: FluidBoundary = serde_json::from_value(serde_json::json!({"body": "Ball", "face": "F1"})).unwrap();
|
||||
assert!(matches!(at, FluidBoundary::Surface(_)));
|
||||
assert!(serde_json::from_value::<FaceCondition>(serde_json::json!({"type": "wall", "velocity": [1, 0, 0]})).is_err());
|
||||
}
|
||||
}
|
||||
+13
-2
@@ -33,6 +33,7 @@
|
||||
pub mod bcs;
|
||||
pub mod check;
|
||||
pub mod constraints;
|
||||
pub mod fluids;
|
||||
pub mod geometry;
|
||||
pub mod loads;
|
||||
pub mod materials;
|
||||
@@ -50,9 +51,10 @@ use serde::{Deserialize, Serialize};
|
||||
pub use bcs::{BoundaryCondition, Dofs, Symmetry, Target};
|
||||
pub use check::{Diagnostic, Resolved, ResolvedSet, Severity};
|
||||
pub use constraints::{Coupling, CouplingKind, ReferencePoint};
|
||||
pub use fluids::{BoxFace, BoxFaces, FaceCondition, FluidBoundary, FluidDomain, Profile, SurfaceBoundary, SurfaceCondition};
|
||||
pub use geometry::{Classification, Entity, GeometryRef};
|
||||
pub use loads::{Distribution, Load};
|
||||
pub use materials::{Elastic, Material};
|
||||
pub use materials::{Elastic, Fluid, Material};
|
||||
pub use mesh::{Element, ElementFace, ElementKind, Node, check_coordinates};
|
||||
pub use sections::{BeamProfile, Formulation, Integration, Section, SectionKind};
|
||||
pub use sets::Set;
|
||||
@@ -113,6 +115,8 @@ pub struct Model {
|
||||
couplings: BTreeMap<String, Coupling>,
|
||||
boundary_conditions: BTreeMap<String, BoundaryCondition>,
|
||||
loads: BTreeMap<String, Load>,
|
||||
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
|
||||
fluid_domains: BTreeMap<String, FluidDomain>,
|
||||
/// In analysis order.
|
||||
steps: Vec<Step>,
|
||||
}
|
||||
@@ -144,6 +148,8 @@ struct ModelData {
|
||||
#[serde(default)]
|
||||
loads: BTreeMap<String, Load>,
|
||||
#[serde(default)]
|
||||
fluid_domains: BTreeMap<String, FluidDomain>,
|
||||
#[serde(default)]
|
||||
steps: Vec<Step>,
|
||||
}
|
||||
|
||||
@@ -198,6 +204,9 @@ impl TryFrom<ModelData> for Model {
|
||||
for (n, v) in d.loads {
|
||||
m.add_load(&n, v)?;
|
||||
}
|
||||
for (n, v) in d.fluid_domains {
|
||||
m.add_fluid_domain(&n, v)?;
|
||||
}
|
||||
for s in d.steps {
|
||||
m.add_step(s, None)?;
|
||||
}
|
||||
@@ -300,12 +309,13 @@ impl Model {
|
||||
couplings: self.couplings.clone(),
|
||||
boundary_conditions: self.boundary_conditions.clone(),
|
||||
loads: self.loads.clone(),
|
||||
fluid_domains: self.fluid_domains.clone(),
|
||||
steps: self.steps.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts of each kind of definition, for summaries.
|
||||
pub fn definition_counts(&self) -> [(&'static str, usize); 8] {
|
||||
pub fn definition_counts(&self) -> [(&'static str, usize); 9] {
|
||||
[
|
||||
("sets", self.sets.len()),
|
||||
("materials", self.materials.len()),
|
||||
@@ -314,6 +324,7 @@ impl Model {
|
||||
("couplings", self.couplings.len()),
|
||||
("boundary_conditions", self.boundary_conditions.len()),
|
||||
("loads", self.loads.len()),
|
||||
("fluid_domains", self.fluid_domains.len()),
|
||||
("steps", self.steps.len()),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Materials: linear elasticity and mass density.
|
||||
//! Materials: linear elasticity, mass density and fluid behaviour.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -60,6 +60,24 @@ impl Elastic {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fluid behaviour.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Fluid {
|
||||
/// A Newtonian fluid of this dynamic viscosity μ (shear stress per shear rate; for example
|
||||
/// 1.0e-9 MPa·s for water in mm, t, s). Its kinematic viscosity is μ over the density.
|
||||
Newtonian { viscosity: f64 },
|
||||
}
|
||||
|
||||
impl Fluid {
|
||||
/// The dynamic viscosity μ.
|
||||
pub fn viscosity(&self) -> f64 {
|
||||
match *self {
|
||||
Fluid::Newtonian { viscosity } => viscosity,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A material.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -69,12 +87,21 @@ pub struct Material {
|
||||
/// Mass per volume, needed by frequency steps and gravity.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub density: Option<f64>,
|
||||
/// How it flows, for fluid domains (needs a density).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub fluid: Option<Fluid>,
|
||||
}
|
||||
|
||||
impl Material {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if self.elastic.is_none() && self.density.is_none() {
|
||||
return Err(Error::Invalid("a material needs elastic constants or a density".into()));
|
||||
if self.elastic.is_none() && self.density.is_none() && self.fluid.is_none() {
|
||||
return Err(Error::Invalid("a material needs elastic constants, a density or fluid behaviour".into()));
|
||||
}
|
||||
if let Some(f) = &self.fluid {
|
||||
positive("viscosity", f.viscosity())?;
|
||||
if self.density.is_none() {
|
||||
return Err(Error::Invalid("a fluid needs a density".into()));
|
||||
}
|
||||
}
|
||||
if let Some(e) = &self.elastic {
|
||||
e.validate()?;
|
||||
@@ -102,11 +129,17 @@ impl Model {
|
||||
|
||||
pub fn replace_material(&mut self, name: &str, material: Material) -> Result<()> {
|
||||
material.validate().map_err(|e| context("material", name, e))?;
|
||||
if material.fluid.is_none()
|
||||
&& let Some(d) = self.fluid_domain_users(name).next()
|
||||
{
|
||||
return Err(Error::Invalid(format!("material `{name}`: fluid domain `{d}` needs its fluid behaviour")));
|
||||
}
|
||||
replace_existing(&mut self.materials, "material", name, material)
|
||||
}
|
||||
|
||||
pub fn remove_material(&mut self, name: &str) -> Result<()> {
|
||||
let users = self.sections.iter().filter(|(_, s)| s.material == name).map(|(n, _)| format!("section `{n}`")).collect();
|
||||
let mut users: Vec<String> = self.sections.iter().filter(|(_, s)| s.material == name).map(|(n, _)| format!("section `{n}`")).collect();
|
||||
users.extend(self.fluid_domain_users(name).map(|n| format!("fluid domain `{n}`")));
|
||||
not_in_use("material", name, users)?;
|
||||
remove_existing(&mut self.materials, "material", name).map(|_| ())
|
||||
}
|
||||
@@ -117,7 +150,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn iso(young: f64, poisson: f64) -> Material {
|
||||
Material { elastic: Some(Elastic::Isotropic { young, poisson }), density: None }
|
||||
Material { elastic: Some(Elastic::Isotropic { young, poisson }), ..Material::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -129,12 +162,17 @@ mod tests {
|
||||
let e = m.add_material("Bad", bad).unwrap_err();
|
||||
assert!(e.to_string().contains("material `Bad`"), "{e}");
|
||||
}
|
||||
assert!(m.add_material("Light", Material { elastic: None, density: Some(-1.0) }).is_err());
|
||||
assert!(m.add_material("Light", Material { density: Some(-1.0), ..Material::default() }).is_err());
|
||||
let fluid = |viscosity, density| Material { density, fluid: Some(Fluid::Newtonian { viscosity }), ..Material::default() };
|
||||
m.add_material("Water", fluid(1.0e-9, Some(1.0e-9))).unwrap();
|
||||
for bad in [fluid(0.0, Some(1.0)), fluid(-1.0, Some(1.0)), fluid(f64::NAN, Some(1.0)), fluid(1.0, None)] {
|
||||
assert!(m.add_material("Bad", bad).is_err());
|
||||
}
|
||||
assert!(m.add_material("1st", iso(1.0, 0.3)).is_err(), "names start with a letter");
|
||||
assert!(m.add_material("has space", iso(1.0, 0.3)).is_err());
|
||||
m.replace_material("Steel", iso(2.0e5, 0.29)).unwrap();
|
||||
assert!(m.replace_material("Nope", iso(2.0e5, 0.29)).is_err());
|
||||
assert_eq!(m.materials().len(), 1);
|
||||
assert_eq!(m.materials().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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))?;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,8 @@ fn defined() -> Model {
|
||||
m.add_set("all", Set::of_geometry(vec![GeometryRef::body("Bar").unwrap()])).unwrap();
|
||||
m.add_set("root", Set::of_geometry(vec![GeometryRef::face("Bar", "left").unwrap()])).unwrap();
|
||||
m.add_set("end", Set::of_geometry(vec![GeometryRef::face("Bar", "right").unwrap()])).unwrap();
|
||||
m.add_material("Steel", Material { elastic: Some(Elastic::Isotropic { young: 2.1e5, poisson: 0.3 }), density: Some(7.85e-9) }).unwrap();
|
||||
m.add_material("Steel", Material { elastic: Some(Elastic::Isotropic { young: 2.1e5, poisson: 0.3 }), density: Some(7.85e-9), fluid: None })
|
||||
.unwrap();
|
||||
m.add_section("bar", Section { set: "all".into(), material: "Steel".into(), kind: SectionKind::Solid {}, formulation: Formulation::default() })
|
||||
.unwrap();
|
||||
m.add_reference_point("RP", ReferencePoint { xyz: [3.0, 0.5, 0.5], label: 0 }).unwrap();
|
||||
@@ -120,7 +121,7 @@ fn the_check_names_what_is_missing() {
|
||||
m.add_set("side", Set { faces: vec![ElementFace { element: 1, face: 3 }], ..Set::default() }).unwrap();
|
||||
m.add_load("ghost-load", Load::Pressure { set: "ghost".into(), magnitude: 1.0 }).unwrap();
|
||||
m.add_load("twist", Load::Moment { target: Target::Set("end".into()), vector: [1.0, 0.0, 0.0], distribution: Distribution::Total }).unwrap();
|
||||
m.add_material("Soft", Material { elastic: Some(Elastic::Isotropic { young: 2.1e11, poisson: 0.3 }), density: None }).unwrap();
|
||||
m.add_material("Soft", Material { elastic: Some(Elastic::Isotropic { young: 2.1e11, poisson: 0.3 }), density: None, fluid: None }).unwrap();
|
||||
m.add_section("again", Section { set: "side".into(), material: "Soft".into(), kind: SectionKind::Solid {}, formulation: Formulation::default() })
|
||||
.unwrap();
|
||||
m.add_section(
|
||||
@@ -326,7 +327,8 @@ fn a_total_force_on_a_face_is_the_same_on_every_mesh() {
|
||||
m.set_units(UnitSystem::MmTonneS);
|
||||
m.add_set("all", Set::of_geometry(vec![GeometryRef::body("Block").unwrap()])).unwrap();
|
||||
m.add_set("end", Set::of_geometry(vec![GeometryRef::face("Block", "end").unwrap()])).unwrap();
|
||||
m.add_material("Steel", Material { elastic: Some(Elastic::Isotropic { young: 2.1e5, poisson: 0.3 }), density: Some(7.85e-9) }).unwrap();
|
||||
m.add_material("Steel", Material { elastic: Some(Elastic::Isotropic { young: 2.1e5, poisson: 0.3 }), density: Some(7.85e-9), fluid: None })
|
||||
.unwrap();
|
||||
m.add_section("block", Section { set: "all".into(), material: "Steel".into(), kind: SectionKind::Solid {}, formulation: Formulation::default() })
|
||||
.unwrap();
|
||||
let force = |distribution| Load::Force { target: Target::Set("end".into()), vector: [100.0, 0.0, -50.0], distribution };
|
||||
|
||||
@@ -123,7 +123,7 @@ impl Grid {
|
||||
pub fn define(&mut self, elastic: Elastic, density: Option<f64>, formulation: Formulation, procedure: Procedure) {
|
||||
let all: Vec<u32> = self.model.elements().keys().copied().collect();
|
||||
self.model.add_set("all", Set { elements: all, ..Set::default() }).unwrap();
|
||||
self.model.add_material("Mat", Material { elastic: Some(elastic), density }).unwrap();
|
||||
self.model.add_material("Mat", Material { elastic: Some(elastic), density, fluid: None }).unwrap();
|
||||
self.model.add_section("solid", Section { set: "all".into(), material: "Mat".into(), kind: SectionKind::Solid {}, formulation }).unwrap();
|
||||
self.model.add_step(Step::new("Step", procedure), None).unwrap();
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ impl Plot {
|
||||
pub fn mode(&self) -> Option<(usize, f64)> {
|
||||
match self.result.kind() {
|
||||
Kind::Frequency(f) => f.modes.get(self.pick.mode.checked_sub(1)?).map(|m| (self.pick.mode, m.frequency)),
|
||||
Kind::Static(_) | Kind::Explicit(_) => None,
|
||||
Kind::Static(_) | Kind::Explicit(_) | Kind::Fluid(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ pub fn swing(result: &StepResult, frame: Option<u64>) -> f64 {
|
||||
match result.kind() {
|
||||
Kind::Frequency(_) => a.sin(),
|
||||
Kind::Static(_) => 0.5 * (1.0 - a.cos()),
|
||||
Kind::Explicit(_) => 1.0,
|
||||
Kind::Explicit(_) | Kind::Fluid(_) => 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user