forked from 1b-agent/fieldcraft-upstream
Lattice Boltzmann flow on the CPU: model, solver, engine (M12) (#36)
Model: Newtonian fluid materials; fluid domains (box, spacing, conditions on the box faces, obstacle bodies with conditions on their named faces, initial velocity, body acceleration); fluid steps with caps. Deck writers refuse fluid steps; a fluid-only model needs no mesh in model.check; one step selector (Model::select_step) for every solver. New L2 crate modelcraft-lbm: D3Q19 two-relaxation-time collision with Guo forcing; halfway bounce-back walls (moving walls after Ladd), slip faces, periodic pairs; Zou–He velocity and pressure faces with the D3Q19 transverse correction; obstacles voxelised from the face-tagged boundary surface with exact predicates and symbolic perturbation; momentum-exchange forces per box face and per B-rep face; unit conversion, automatic time step and Mach/tau limits; bounded runs with frames and force histories. Engine: model.fluid_domain.create/edit/delete, model.fluid_domain.boundary, solve.fluid (obstacles from the open design or imported surfaces, lowerable caps), solve.probe for fluid fields and wall force histories. Fluid Domain, Inlet & Outlet and Lattice Boltzmann toolbar entries are ready. Verification (analytic): plane Poiseuille exact with Λ = 3/16 and second order (2.000) with Λ = 1/4; Couette exact with the wall shear; Taylor–Green decay second order (2.01, 2.00); staircase pipe; Stokes drag of a sphere array within 1.7% of Hasimoto at R = 6; Zou–He channel; mass conserved to round-off; bit-for-bit reproducibility. Benchmark: Schäfer–Turek 2D-1. Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
55 files changed
+3800
-64
No files matched your search
@@ -79,7 +79,7 @@ People trust ModelCraft with their models; a crash loses their work. **This outr
|
||||
JSON `run`) + tests. The UI, scripts, CLI and control channel all reach the same commands.
|
||||
- **Programmatic calls never open dialogs.**
|
||||
- **Layering** is enforced by `cargo xtask layers` (`docs/architecture.md`): L0 `model` → L1
|
||||
`geometry`, `mesh`, `fem`, `decks`, `render` → L2 solvers (`solver`; `explicit`, `lbm` later) →
|
||||
`geometry`, `mesh`, `fem`, `decks`, `render` → L2 solvers (`solver`, `lbm`; `explicit` later) →
|
||||
L3 `fsi` (later) → L4 `doc` → L5 `io` → L6 `engine` → L7 `ui-egui`, `mcp` → apps. Nothing below
|
||||
L7 depends on egui/eframe/winit/wgpu/rfd. **Only
|
||||
`crates/geometry` may use `solidcraft-*`.**
|
||||
|
||||
+3
-3
@@ -26,8 +26,6 @@ is committed and listed here with its exports.
|
||||
| assets/icons/file.recover.svg, assets/icons/file.recover.vectorcraft | ModelCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/file.recover.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/file.save_as.svg, assets/icons/file.save_as.vectorcraft | ModelCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/file.save_as.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/file.save.svg, assets/icons/file.save.vectorcraft | ModelCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/file.save.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/flow.boundary.svg, assets/icons/flow.boundary.vectorcraft | ModelCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/flow.boundary.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/flow.domain.svg, assets/icons/flow.domain.vectorcraft | ModelCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/flow.domain.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/geometry.bodies.svg, assets/icons/geometry.bodies.vectorcraft | ModelCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.bodies.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/geometry.datum.svg, assets/icons/geometry.datum.vectorcraft | ModelCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.datum.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/geometry.import.mesh.svg, assets/icons/geometry.import.mesh.vectorcraft | ModelCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.import.mesh.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
@@ -76,6 +74,8 @@ is committed and listed here with its exports.
|
||||
| assets/icons/model.boundary_condition.create.svg, assets/icons/model.boundary_condition.create.vectorcraft | ModelCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/model.boundary_condition.create.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/model.check.svg, assets/icons/model.check.vectorcraft | ModelCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/model.check.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/model.coupling.create.svg, assets/icons/model.coupling.create.vectorcraft | ModelCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/model.coupling.create.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/model.fluid_domain.boundary.svg, assets/icons/model.fluid_domain.boundary.vectorcraft | ModelCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/model.fluid_domain.boundary.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/model.fluid_domain.create.svg, assets/icons/model.fluid_domain.create.vectorcraft | ModelCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/model.fluid_domain.create.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/model.info.svg, assets/icons/model.info.vectorcraft | ModelCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/model.info.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/model.load.create.svg, assets/icons/model.load.create.vectorcraft | ModelCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/model.load.create.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/model.material.create.svg, assets/icons/model.material.create.vectorcraft | ModelCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/model.material.create.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
@@ -111,7 +111,7 @@ is committed and listed here with its exports.
|
||||
| assets/icons/results.xy.svg, assets/icons/results.xy.vectorcraft | ModelCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/results.xy.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/solve.blast.svg, assets/icons/solve.blast.vectorcraft | ModelCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/solve.blast.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/solve.explicit.svg, assets/icons/solve.explicit.vectorcraft | ModelCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/solve.explicit.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/solve.flow.svg, assets/icons/solve.flow.vectorcraft | ModelCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/solve.flow.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/solve.fluid.svg, assets/icons/solve.fluid.vectorcraft | ModelCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/solve.fluid.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/solve.frequency.svg, assets/icons/solve.frequency.vectorcraft | ModelCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/solve.frequency.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/solve.fsi.svg, assets/icons/solve.fsi.vectorcraft | ModelCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/solve.fsi.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/solve.static.svg, assets/icons/solve.static.vectorcraft | ModelCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/solve.static.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
|
||||
Generated
+10
@@ -1884,6 +1884,7 @@ dependencies = [
|
||||
"modelcraft-fem",
|
||||
"modelcraft-geometry",
|
||||
"modelcraft-io",
|
||||
"modelcraft-lbm",
|
||||
"modelcraft-mesh",
|
||||
"modelcraft-model",
|
||||
"modelcraft-solver",
|
||||
@@ -1940,6 +1941,15 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "modelcraft-lbm"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"modelcraft-mesh",
|
||||
"modelcraft-model",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "modelcraft-mesh"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -20,6 +20,7 @@ modelcraft-io = { path = "crates/io" }
|
||||
modelcraft-fem = { path = "crates/fem" }
|
||||
modelcraft-solver = { path = "crates/solver" }
|
||||
modelcraft-explicit = { path = "crates/explicit" }
|
||||
modelcraft-lbm = { path = "crates/lbm" }
|
||||
modelcraft-engine = { path = "crates/engine" }
|
||||
modelcraft-testkit = { path = "crates/testkit" }
|
||||
modelcraft-render = { path = "crates/render" }
|
||||
@@ -75,6 +76,11 @@ opt-level = 2
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
|
||||
# The lattice Boltzmann kernels are compute loops over millions of cells; their verification
|
||||
# runs (tests) need them optimised.
|
||||
[profile.dev.package.modelcraft-lbm]
|
||||
opt-level = 3
|
||||
|
||||
# Tests and lints (`cargo xtask ci`, GitHub CI). Debug assertions are off because truck's
|
||||
# debug-only checks reject valid geometry (SolidCraft builds its tests the same way).
|
||||
[profile.ci]
|
||||
@@ -88,6 +94,9 @@ incremental = false
|
||||
[profile.ci.package."*"]
|
||||
opt-level = 2
|
||||
|
||||
[profile.ci.package.modelcraft-lbm]
|
||||
opt-level = 3
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ hex meshes today and it gives the best elements where stresses are read.
|
||||
| M9 Post-processing and deck readers | Results readers for public formats (Nastran OP2, LS-DYNA d3plot, Abaqus `.fil`); XY and path plots, cuts, display groups, free-body forces; deck readers that keep unknown keywords; mesh convergence studies | planned |
|
||||
| M10 Explicit dynamics | Shared element and material kernels with the implicit solver (`fem`); central-difference solver with lumped mass, stable time step, one-point hex8 with hourglass control and nodal-pressure tet4, bulk viscosity, energy accounting (X1 core: done — `modelcraft-explicit`, `explicit_dynamic` steps with initial velocities, prescribed motion and planar rigid walls, `solve.explicit`/`solve.history`, small-strain linear elastic; bar wave, rigid-wall impact, energy balance and cantilever-period tests); large-deformation materials (objective rates, J2, Johnson–Cook, damage, erosion); contact (global search, penalty with friction, rigid walls); runs on hex and tet meshes | started |
|
||||
| M11 Equations of state and shells | Polynomial, ideal-gas, Mie–Grüneisen, JWL with programmed burn, tabulated; energy coupling; shock verification (Rankine–Hugoniot, Sod, Noh, Sedov); Belytschko–Lin–Tsay shells with self-contact on quad meshes | planned |
|
||||
| M12 CFD: lattice Boltzmann | D3Q19/D3Q27 on grids voxelised from the face-tagged surface; TRT, regularised and cumulant collision; interpolated walls, inlets and outlets; LES; octree refinement shared with the hex mesher; wgpu backend (deterministic per device, checked against the CPU) | planned |
|
||||
| M12 CFD: lattice Boltzmann | D3Q19/D3Q27 on grids voxelised from the face-tagged surface; TRT, regularised and cumulant collision; interpolated walls, inlets and outlets; LES; octree refinement shared with the hex mesher; wgpu backend (deterministic per device, checked against the CPU) (CPU core: done — `modelcraft-lbm` with D3Q19 TRT and Guo forcing, halfway bounce-back and moving walls, slip and periodic faces, Zou–He velocity and pressure faces, exact voxelisation of obstacle bodies, momentum-exchange forces per face; fluid materials, fluid domains and fluid steps in the model; `model.fluid_domain.*`, `solve.fluid`; verified by Poiseuille channel and pipe, Couette, Taylor–Green decay, sphere-array Stokes drag and mass conservation; Schäfer–Turek 2D-1 as a benchmark) | started |
|
||||
| M13 Fluid–structure interaction | Immersed-boundary coupling of the lattice Boltzmann fluid with the explicit or implicit structure; interface force transfer; sub-cycling; Aitken then interface quasi-Newton coupling; free-surface flow | planned |
|
||||
| M14 Blast and impact fluids | EOS-driven fluids in the explicit solver: ALE multi-material or SPH, with fluid–structure contact | planned |
|
||||
| Continuous: verification and validation | Every capability checked against theory (closed forms), experiments (published measurements) and published benchmarks (NAFEMS, vendor benchmark and verification manuals such as Abaqus's), each case our own model in `tests/oracle/` with its cited reference and tolerance; `cargo xtask oracle` runs them and writes docs/oracle.md; once a case matches, our result is frozen as a fixture so fidelity cannot regress (docs/verification.md) | started |
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||
<title>flow.boundary</title>
|
||||
<title>model.fluid_domain.boundary</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M3 5 L21 5 M3 19 L21 19" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3 9.5 L9 9.5 M6.19 11.03 L9 9.5 L6.19 7.97" fill="none" stroke="#f0b44a" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||
<title>flow.domain</title>
|
||||
<title>model.fluid_domain.create</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M3 3 L21 3 L21 21 L3 21 Z" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7.5 3 L7.5 21 M3 7.5 L21 7.5 M12 3 L12 21 M3 12 L21 12 M16.5 3 L16.5 21 M3 16.5 L21 16.5" fill="none" stroke="#dde2ea" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||
<title>solve.flow</title>
|
||||
<title>solve.fluid</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M8.5 12 C8.5 10.067 10.067 8.5 12 8.5 C13.933 8.5 15.5 10.067 15.5 12 C15.5 13.933 13.933 15.5 12 15.5 C10.067 15.5 8.5 13.933 8.5 12 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3 6 C8 6 9 4.5 12 4.5 C15 4.5 16 6 21 6" fill="none" stroke="#4f9ce8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
Binary file not shown.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+39
-1
@@ -24,8 +24,9 @@ crates in strictly lower layers.
|
||||
| L1 | `modelcraft-fem` | Element kernels shared by every structural solver: the element table (tet4, tet10, wedge6, hex8 full and incompatible modes, hex20 full and reduced), shape functions, quadrature, element stiffness, consistent and lumped mass, body and pressure loads, stresses and their extrapolation, isotropic and orthotropic elasticity, and the uniform-strain (one-point) hex8 and tet4 kinematics, hourglass shape vectors and element frequency bounds of the explicit solver. Pure functions, no solver loop. | started |
|
||||
| L1 | `modelcraft-decks` | Abaqus input, Nastran bulk data and LS-DYNA keyword readers and writers, to and from `model`. Text out, no file system; `deck.write` saves it. | started |
|
||||
| L1 | `modelcraft-render` | Display data without a GPU dependency: the visible faces, element edges and nodes of a model, the orbit camera, a CPU rasterizer for headless snapshots, geometry surfaces with their face boundaries, deformed shapes and banded contour plots of nodal results, picking. | started |
|
||||
| L2 | `modelcraft-solver` | Implicit solver: assembly from the resolved model, boundary conditions, sparse direct solution, linear static and modal analysis, stresses and reactions. The lattice Boltzmann solver (`modelcraft-lbm`) joins this layer. | started |
|
||||
| L2 | `modelcraft-solver` | Implicit solver: assembly from the resolved model, boundary conditions, sparse direct solution, linear static and modal analysis, stresses and reactions. | started |
|
||||
| L2 | `modelcraft-explicit` | Explicit dynamics: central-difference time integration with lumped mass, one-point hex8 with hourglass control, nodal-pressure tet4, bulk viscosity, rigid walls, energy account, histories and field frames. | started |
|
||||
| L2 | `modelcraft-lbm` | Lattice Boltzmann flow solver on the CPU: D3Q19 two-relaxation-time scheme, bounce-back and slip walls, Zou–He inlets and outlets, obstacles voxelised from the face-tagged surface with exact predicates, momentum-exchange forces per face. Uses `mesh` for the predicates and the boundary surface. | started |
|
||||
| L3 | `modelcraft-fsi` | Fluid–structure coupling between the L2 solvers (later). | planned |
|
||||
| L4 | `modelcraft-doc` | The document: geometry, mesh, model, results, history. | planned |
|
||||
| L5 | `modelcraft-io` | `.modelcraft` files (docs/formats/modelcraft.md): text model definition, binary mesh, geometry references with content hashes, the journal; atomic saves; autosave storage for crash recovery. Bytes in, bytes out; the file system only in `fs` and `recovery`. Result files later. | started |
|
||||
@@ -205,6 +206,43 @@ The first version is geometrically linear with linear elastic materials. Verific
|
||||
speed, plateau force ρ c v₀ A), the energy balance, bit-for-bit repeatability; in the engine
|
||||
(`solve.rs`), a cantilever's free-vibration period against `solve.frequency`.
|
||||
|
||||
## Lattice Boltzmann solver
|
||||
|
||||
`modelcraft-lbm` solves a model's fluid steps (`solve.fluid`; `solve.probe` reads velocity and
|
||||
pressure at points and wall force histories). Papers are cited in the module docs and in
|
||||
`docs/solvers/literature.md` (Part I).
|
||||
|
||||
1. **Model.** A fluid material (density, Newtonian viscosity), a fluid domain (box, cubic cell
|
||||
spacing, a condition on each box face: wall, moving wall, slip, velocity inlet with a uniform
|
||||
or parabolic profile, pressure outlet, periodic pair; obstacle bodies and moving-wall
|
||||
conditions on their named faces; initial velocity; body acceleration) and a fluid step (end
|
||||
time or steps, optional time step, frames, the TRT parameter Λ). Deck writers refuse fluid
|
||||
steps.
|
||||
2. **Setup.** Lattice units from the spacing, the time step and the density. Without a given
|
||||
time step the solver keeps the characteristic lattice velocity at 0.05 and τ ≤ 1. A lattice
|
||||
Mach number above 0.3, τ below 0.505 or a lattice acceleration above 0.01 is refused with the
|
||||
numbers and what to change; caps on cells, steps, cell updates and frame memory are checked
|
||||
before anything is allocated.
|
||||
3. **Grid.** The engine triangulates each obstacle body (or takes an imported surface) and
|
||||
prepares its `BoundarySurface`; cells whose centres have a positive winding number are solid,
|
||||
decided with exact `orient2d`/`orient3d` and one symbolic perturbation, so faces through cell
|
||||
centres and lines through edges are classified consistently.
|
||||
4. **Stepping.** TRT collision with Guo forcing in place; pull streaming as one shifted copy per
|
||||
direction with per-link rules for the cells next to walls and box faces; Zou–He on inlet and
|
||||
outlet layers. Wall links give the momentum exchanged with every box face and B-rep face
|
||||
(the face of the triangle nearest the link). A non-positive density or a lattice speed above
|
||||
0.5 stops the run with an error.
|
||||
5. **Results.** Frames of velocity and gauge pressure per cell (single precision), force
|
||||
histories per wall and the fluid mass, in model units, stored per step like the implicit
|
||||
results.
|
||||
|
||||
Verification (`crates/lbm/tests/verification.rs`, analytic): plane Poiseuille exact to round-off
|
||||
with Λ = 3/16 and second order with Λ = 1/4; Couette exact with the wall shear μU/H; Taylor–Green
|
||||
decay second order; Poiseuille in a staircase pipe; Stokes drag of a simple cubic sphere array
|
||||
against Hasimoto (1959); a parabolic-inlet, pressure-outlet channel against Poiseuille; mass
|
||||
conserved to round-off; bit-for-bit reproducibility. `crates/lbm/tests/benchmark.rs` (ignored,
|
||||
published reference values): Schäfer–Turek 2D-1.
|
||||
|
||||
## Commands
|
||||
|
||||
Every user-visible action is a `CommandSpec` in `crates/engine/src/cmd/<area>.rs` with a JSON
|
||||
|
||||
+4
-4
@@ -5,7 +5,7 @@ Generated from the capability catalogue (`crates/engine/src/catalogue.rs`) by
|
||||
Every entry is a toolbar button. **Ready** entries run their command; **coming soon** entries are
|
||||
shown greyed with the roadmap milestone that brings them (ROADMAP.md).
|
||||
|
||||
42 ready, 58 coming soon.
|
||||
45 ready, 55 coming soon.
|
||||
|
||||
## Geometry
|
||||
|
||||
@@ -90,8 +90,8 @@ shown greyed with the roadmap milestone that brings them (ROADMAP.md).
|
||||
|---|---|---|---|
|
||||
| Boundary Conditions | Boundary Condition — Fixed, displacement or symmetry condition on a set or reference point | ready | `model.boundary_condition.create`, `model.boundary_condition.edit`, `model.boundary_condition.delete` |
|
||||
| Loads | Load — Force, moment, pressure, traction, gravity or body force | ready | `model.load.create`, `model.load.edit`, `model.load.delete` |
|
||||
| Flow | Fluid Domain — The fluid grid voxelised from the face-tagged surface | coming soon (M12) | |
|
||||
| Flow | Inlet & Outlet — Velocity inlets, pressure outlets and walls | coming soon (M12) | |
|
||||
| Flow | Fluid Domain — The box of fluid cells, its fluid, and the obstacle bodies voxelised from their face-tagged surfaces | ready | `model.fluid_domain.create`, `model.fluid_domain.edit`, `model.fluid_domain.delete` |
|
||||
| Flow | Inlet & Outlet — Velocity inlets, pressure outlets, walls, moving and slip walls, periodic faces | ready | `model.fluid_domain.boundary` |
|
||||
|
||||
## Interactions
|
||||
|
||||
@@ -123,7 +123,7 @@ shown greyed with the roadmap milestone that brings them (ROADMAP.md).
|
||||
| Implicit | Frequency — Natural frequencies and mode shapes of a frequency step | ready | `solve.frequency` |
|
||||
| Explicit | Explicit — Transient dynamics of an explicit step by central differences (contact and erosion coming in M10) | ready | `solve.explicit` |
|
||||
| Explicit | Blast & Impact — EOS-driven fluids with fluid–structure contact (ALE or SPH) | coming soon (M14) | |
|
||||
| Fluids | Lattice Boltzmann — Lattice Boltzmann flow on the CPU or the GPU | coming soon (M12) | |
|
||||
| Fluids | Lattice Boltzmann — Lattice Boltzmann flow of a fluid step on the CPU, with wall forces | ready | `solve.fluid` |
|
||||
| Fluids | Fluid–Structure — Coupled fluid and structure solve | coming soon (M13) | |
|
||||
|
||||
## Results
|
||||
|
||||
@@ -1024,3 +1024,32 @@ again in the module docs):
|
||||
element keeps 1 − 1/n² of the bending energy with n elements through the depth, which predicts
|
||||
its period with viscous hourglass control to 0.03%.
|
||||
|
||||
## Part I: Lattice Boltzmann core (M12, plan L1), as implemented
|
||||
|
||||
`modelcraft-lbm` is written from these papers and the book (cited again in its module docs). No
|
||||
lattice Boltzmann package's code was opened.
|
||||
|
||||
- Lattice, equilibrium, units, stream–collide, bounce-back, forcing and boundary conditions:
|
||||
Krüger et al. 2017 (C1.2); D3Q19 from Qian, d'Humières & Lallemand 1992 (C1.3).
|
||||
- Collision: two relaxation times, Ginzburg, Verhaeghe & d'Humières 2008 (C2.2). The parameter
|
||||
Λ = (τ⁺ − ½)(τ⁻ − ½) is the step's `magic` (default 3/16: walls exactly halfway).
|
||||
- Body force: Guo, Zheng & Shi 2002 (C1.6), split into the symmetric and antisymmetric parts
|
||||
relaxed with τ⁺ and τ⁻.
|
||||
- Walls: halfway bounce-back, Ladd's moving-wall term and momentum exchange (Ladd 1994, C3.1);
|
||||
momentum exchange also after R. Mei, D. Yu, W. Shyy, L.-S. Luo, *Force evaluation in the
|
||||
lattice Boltzmann method involving curved geometry*, PRE 65:041203, 2002 (DOI not checked).
|
||||
- Inlets and outlets: Zou & He 1997 (C3.3), with the D3Q19 transverse-momentum correction of
|
||||
M. Hecht, J. Harting, *Implementation of on-site velocity boundary conditions for D3Q19
|
||||
lattice Boltzmann simulations*, J. Stat. Mech. 2010, P01018 (DOI not checked).
|
||||
- Stability limits (Mach ≤ 0.3, τ⁺ ≥ 0.505, runtime speed ≤ 0.5): Sterling & Chen 1996 and
|
||||
Lallemand & Luo 2000 (C1.5) for the shrinking stable region as τ → ½.
|
||||
- Voxelisation: Shewchuk's exact `orient2d`/`orient3d` (`modelcraft_mesh::predicates`) with a
|
||||
symbolic perturbation after H. Edelsbrunner, E. P. Mücke, *Simulation of Simplicity*, ACM TOG
|
||||
9(1):66–104, 1990; link faces by nearest triangle, C. Ericson, *Real-Time Collision Detection*,
|
||||
Morgan Kaufmann 2005, §5.1.5.
|
||||
- Verification: analytic Poiseuille, Couette and Taylor–Green solutions (C8); the Stokes drag of
|
||||
a simple cubic array of spheres, H. Hasimoto, *On the periodic fundamental solutions of the
|
||||
Stokes equations and their application to viscous flow past a cubic array of spheres*, 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 (DOIs not checked). Benchmark (published numerical reference values, tagged,
|
||||
not part of the analytic gate): Schäfer & Turek 1996, 2D-1 (C8).
|
||||
+2
-1
@@ -43,9 +43,10 @@ pub const TABLE: &[(&str, Class)] = &[
|
||||
("fem", Class::Layer(1)),
|
||||
("decks", Class::Layer(1)),
|
||||
("render", Class::Layer(1)),
|
||||
// L2: solvers (the lattice Boltzmann solver joins here).
|
||||
// L2: solvers.
|
||||
("solver", Class::Layer(2)),
|
||||
("explicit", Class::Layer(2)),
|
||||
("lbm", Class::Layer(2)),
|
||||
// L3: coupling between solvers (`fsi`, later).
|
||||
// L4 and up: the document, files, commands, UI.
|
||||
("doc", Class::Layer(4)),
|
||||
|
||||
Reference in new issue
Block a user