forked from 1b-agent/fieldcraft-upstream
- model: materials gain solver-neutral plastic (bilinear isotropic/kinematic, hardening table, Johnson-Cook with adiabatic heating), damage (Johnson-Cook D1-D5) and erosion (plastic strain); explicit steps gain nonlinear_geometry (default on) and stress_rate (jaumann | green_naghdi). - fem: rotation (Hughes-Winget, Flanagan-Taylor), plasticity (radial return, capped bracketed Newton for Johnson-Cook, damage; the update takes the mechanical strain increment so thermal strains can be subtracted), closed-form hex8 gradients for deformed configurations. - explicit: updated Lagrangian hex8/tet4, per-element material state, erosion with reported energy, plastic work in the energy account. - solver: warns that the linear solver uses the elastic constants of plastic materials (results hash-identical). - decks: Abaqus *PLASTIC (+ Johnson-Cook keywords), Nastran MATS1/TABLES1, LS-DYNA *MAT_PLASTIC_KINEMATIC / *MAT_PIECEWISE_LINEAR_PLASTICITY; what a format cannot express is reported as Unsupported. - engine: model.material.plasticity, model.material.damage (catalogue ready), explicit element probe, eroded elements in the solve summary. Step commits before the rebase onto the DOF framework: branch explicit-x2-steps. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
578 lines
25 KiB
Rust
578 lines
25 KiB
Rust
//! 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), ..Material::default() }).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());
|
||
}
|
||
}
|