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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user