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
@@ -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))?;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user