forked from 1b-agent/fieldcraft-upstream
model: heat in a fluid domain
A fluid domain may carry thermal settings: the initial temperature, gravity and the fluid's expansion coefficient for buoyancy, and a thermal condition (adiabatic, held temperature, heat flux) on each box face and on named obstacle faces, adiabatic unless told otherwise. The material then needs an isotropic conductivity and a specific heat. Boundaries are set one at a time like the flow ones. Part of #157.
This commit is contained in:
1 parent
7d419b73f4
commit
ea284caf80
4 files changed
+284
-11
No files matched your search
@@ -21,6 +21,7 @@ pub fn domain(min: [f64; 3], max: [f64; 3], spacing: f64) -> FluidDomain {
|
||||
initial_velocity: [0.0; 3],
|
||||
acceleration: [0.0; 3],
|
||||
tolerance: None,
|
||||
thermal: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+278
-5
@@ -16,7 +16,7 @@ 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};
|
||||
use crate::{Error, Material, 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;
|
||||
@@ -243,6 +243,177 @@ pub struct SurfaceBoundary {
|
||||
pub condition: SurfaceCondition,
|
||||
}
|
||||
|
||||
/// What a boundary does to the fluid's temperature.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum ThermalCondition {
|
||||
/// No heat crosses it (every boundary, unless told otherwise); at an inlet the fluid enters
|
||||
/// at the initial temperature, at an outlet it leaves at the temperature it has.
|
||||
Adiabatic {},
|
||||
/// Held at this temperature (at an inlet, the fluid enters at it).
|
||||
Temperature { value: f64 },
|
||||
/// A heat flux into the fluid (power per area; negative draws heat out).
|
||||
Flux { value: f64 },
|
||||
}
|
||||
|
||||
impl ThermalCondition {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
ThermalCondition::Adiabatic {} => "adiabatic",
|
||||
ThermalCondition::Temperature { .. } => "temperature",
|
||||
ThermalCondition::Flux { .. } => "flux",
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<()> {
|
||||
match self {
|
||||
ThermalCondition::Adiabatic {} => Ok(()),
|
||||
ThermalCondition::Temperature { value } | ThermalCondition::Flux { value } => finite(self.name(), *value).map(|_| ()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ThermalCondition {
|
||||
fn default() -> Self {
|
||||
ThermalCondition::Adiabatic {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The thermal condition of each box face.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct BoxThermalFaces {
|
||||
pub x_min: ThermalCondition,
|
||||
pub x_max: ThermalCondition,
|
||||
pub y_min: ThermalCondition,
|
||||
pub y_max: ThermalCondition,
|
||||
pub z_min: ThermalCondition,
|
||||
pub z_max: ThermalCondition,
|
||||
}
|
||||
|
||||
impl BoxThermalFaces {
|
||||
pub fn get(&self, face: BoxFace) -> &ThermalCondition {
|
||||
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 ThermalCondition {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A thermal condition on one named face of an obstacle body.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SurfaceThermalBoundary {
|
||||
pub face: GeometryRef,
|
||||
pub condition: ThermalCondition,
|
||||
}
|
||||
|
||||
/// Heat in the fluid: a temperature field advected with the flow and diffused with the
|
||||
/// material's diffusivity k / (ρ c), its conditions on the boundaries, and buoyancy.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct FluidThermal {
|
||||
/// The temperature everywhere at the start, and the reference of the buoyancy.
|
||||
pub initial_temperature: f64,
|
||||
/// Gravity for the buoyancy (Boussinesq: a force per mass of −β (T − T₀) g on the fluid);
|
||||
/// zero for none. Separate from the domain's `acceleration`, which is a plain body force.
|
||||
pub gravity: [f64; 3],
|
||||
/// The fluid's volumetric thermal expansion coefficient β (per temperature); needed with
|
||||
/// a gravity.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expansion: Option<f64>,
|
||||
pub faces: BoxThermalFaces,
|
||||
/// Conditions on named faces of the obstacles (adiabatic unless listed).
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub surfaces: Vec<SurfaceThermalBoundary>,
|
||||
}
|
||||
|
||||
impl Default for FluidThermal {
|
||||
fn default() -> Self {
|
||||
FluidThermal { initial_temperature: 0.0, gravity: [0.0; 3], expansion: None, faces: BoxThermalFaces::default(), surfaces: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
impl FluidThermal {
|
||||
fn validate(&self, obstacles: &[String]) -> Result<()> {
|
||||
finite("initial temperature", self.initial_temperature)?;
|
||||
vector("gravity", self.gravity)?;
|
||||
if let Some(b) = self.expansion {
|
||||
finite("expansion coefficient", b)?;
|
||||
if b < 0.0 {
|
||||
return Err(Error::Invalid(format!("the expansion coefficient {b} is negative")));
|
||||
}
|
||||
}
|
||||
if self.gravity != [0.0; 3] && self.expansion.is_none() {
|
||||
return Err(Error::Invalid("buoyancy needs the fluid's expansion coefficient (`expansion`) with its gravity".into()));
|
||||
}
|
||||
for face in BoxFace::ALL {
|
||||
self.faces.get(face).validate().map_err(|e| context("box face", face.name(), e))?;
|
||||
}
|
||||
if self.surfaces.len() > MAX_SURFACE_CONDITIONS {
|
||||
return Err(Error::Limit(format!("a fluid domain holds at most {MAX_SURFACE_CONDITIONS} thermal surface conditions")));
|
||||
}
|
||||
for (i, s) in self.surfaces.iter().enumerate() {
|
||||
s.condition.validate()?;
|
||||
if !matches!(s.face.entity, Entity::Face(_)) {
|
||||
return Err(Error::Invalid(format!("thermal conditions go on faces, not on the {}", s.face)));
|
||||
}
|
||||
if !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 self.surfaces.get(..i).is_some_and(|before| before.iter().any(|b| b.face == s.face)) {
|
||||
return Err(Error::Invalid(format!("the {} has two thermal conditions", s.face)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// What a fluid domain needs of its material: a density and fluid behaviour, and with heat a
|
||||
/// positive isotropic conductivity and specific heat (the diffusivity k / (ρ c)).
|
||||
fn material_fits_fluid_domain(m: &Material, name: &str, d: &FluidDomain) -> Result<()> {
|
||||
if m.fluid.is_none() {
|
||||
return Err(Error::Invalid(format!("material `{name}` has no fluid behaviour (a viscosity)")));
|
||||
}
|
||||
if m.density.is_none() {
|
||||
return Err(Error::Invalid(format!("material `{name}` has no density")));
|
||||
}
|
||||
if d.thermal.is_some() {
|
||||
let thermal = m.thermal.as_ref();
|
||||
match thermal.and_then(|t| t.conductivity) {
|
||||
Some(crate::Directional::Isotropic(k)) if k > 0.0 => {}
|
||||
Some(crate::Directional::Isotropic(_)) | None => {
|
||||
return Err(Error::Invalid(format!("material `{name}` needs a positive isotropic conductivity for heat in the fluid")));
|
||||
}
|
||||
Some(crate::Directional::Orthotropic(_)) => {
|
||||
return Err(Error::Invalid(format!("material `{name}`: a fluid's conductivity is isotropic")));
|
||||
}
|
||||
}
|
||||
if !thermal.and_then(|t| t.specific_heat).is_some_and(|c| c > 0.0) {
|
||||
return Err(Error::Invalid(format!("material `{name}` needs a positive specific heat for heat in the fluid")));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
@@ -279,6 +450,9 @@ pub struct FluidDomain {
|
||||
/// 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>,
|
||||
/// Heat in the fluid (a temperature field, buoyancy); `None` for an isothermal flow.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub thermal: Option<FluidThermal>,
|
||||
}
|
||||
|
||||
fn is_zero(v: &[f64; 3]) -> bool {
|
||||
@@ -346,6 +520,9 @@ impl FluidDomain {
|
||||
return Err(Error::Invalid(format!("obstacle `{b}` is listed twice")));
|
||||
}
|
||||
}
|
||||
if let Some(t) = &self.thermal {
|
||||
t.validate(&self.obstacles).map_err(|e| context("thermal", "settings", e))?;
|
||||
}
|
||||
if self.surfaces.len() > MAX_SURFACE_CONDITIONS {
|
||||
return Err(Error::Limit(format!("a fluid domain holds at most {MAX_SURFACE_CONDITIONS} surface conditions")));
|
||||
}
|
||||
@@ -373,6 +550,25 @@ impl FluidDomain {
|
||||
}
|
||||
|
||||
/// Set the condition on one boundary: a box face, or a named face of an obstacle, which
|
||||
/// Set one boundary's thermal condition (an adiabatic obstacle face removes its entry,
|
||||
/// adiabatic being the default). Needs the domain's thermal settings; the caller validates
|
||||
/// the result ([`Model::set_fluid_thermal_boundary`] does).
|
||||
pub fn set_thermal_boundary(&mut self, at: FluidBoundary, condition: ThermalCondition) -> Result<()> {
|
||||
let Some(t) = &mut self.thermal else {
|
||||
return Err(Error::Invalid("the domain has no thermal settings (`thermal`), so no boundary has a thermal condition".into()));
|
||||
};
|
||||
match at {
|
||||
FluidBoundary::Box(face) => *t.faces.get_mut(face) = condition,
|
||||
FluidBoundary::Surface(face) => {
|
||||
t.surfaces.retain(|s| s.face != face);
|
||||
if condition != (ThermalCondition::Adiabatic {}) {
|
||||
t.surfaces.push(SurfaceThermalBoundary { face, condition });
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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<()> {
|
||||
@@ -407,12 +603,26 @@ impl Model {
|
||||
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(()),
|
||||
Some(m) => material_fits_fluid_domain(m, &d.material, d),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The fluid domains `material` must keep fitting when it is replaced.
|
||||
pub(crate) fn check_material_fits_fluid_domains(&self, name: &str, material: &Material) -> Result<()> {
|
||||
for (domain, d) in self.fluid_domains.iter().filter(|(_, d)| d.material == name) {
|
||||
material_fits_fluid_domain(material, name, d).map_err(|e| context("fluid domain", domain, e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set the thermal condition on one boundary of a domain.
|
||||
pub fn set_fluid_thermal_boundary(&mut self, name: &str, at: FluidBoundary, condition: ThermalCondition) -> Result<()> {
|
||||
let mut d = self.fluid_domains.get(name).cloned().ok_or_else(|| Error::NotFound(format!("fluid domain `{name}`")))?;
|
||||
d.set_thermal_boundary(at, condition).map_err(|e| context("fluid domain", name, e))?;
|
||||
self.replace_fluid_domain(name, d)
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -449,7 +659,7 @@ impl Model {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Fluid, Material, Procedure, Step};
|
||||
use crate::{Directional, Fluid, Material, Procedure, Step};
|
||||
|
||||
fn water() -> Material {
|
||||
Material { density: Some(1.0e-9), fluid: Some(Fluid::Newtonian { viscosity: 1.0e-9 }), ..Material::default() }
|
||||
@@ -473,9 +683,72 @@ mod tests {
|
||||
initial_velocity: [0.0; 3],
|
||||
acceleration: [0.0; 3],
|
||||
tolerance: None,
|
||||
thermal: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thermal_settings_validate_and_need_the_materials_heat_properties() {
|
||||
let mut m = Model::default();
|
||||
m.add_material("Water", water()).unwrap();
|
||||
let thermal = |f: &dyn Fn(&mut FluidThermal)| {
|
||||
let mut d = channel();
|
||||
let mut t = FluidThermal { initial_temperature: 20.0, ..FluidThermal::default() };
|
||||
f(&mut t);
|
||||
d.thermal = Some(t);
|
||||
d
|
||||
};
|
||||
// heat in the fluid needs a conductivity and a specific heat on the material
|
||||
assert!(m.add_fluid_domain("Flow", thermal(&|_| {})).is_err());
|
||||
let mut hot = water();
|
||||
hot.thermal = Some(crate::Thermal { conductivity: Some(Directional::Isotropic(0.6)), specific_heat: Some(4180.0), ..Default::default() });
|
||||
m.replace_material("Water", hot.clone()).unwrap();
|
||||
m.add_fluid_domain("Flow", thermal(&|_| {})).unwrap();
|
||||
m.add_fluid_domain(
|
||||
"Hot",
|
||||
thermal(&|t| {
|
||||
t.faces.y_min = ThermalCondition::Temperature { value: 50.0 };
|
||||
t.gravity = [0.0, -9810.0, 0.0];
|
||||
t.expansion = Some(2e-4);
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
for d in [
|
||||
thermal(&|t| t.initial_temperature = f64::NAN),
|
||||
thermal(&|t| t.gravity = [0.0, -1.0, 0.0]),
|
||||
thermal(&|t| t.expansion = Some(-1.0)),
|
||||
thermal(&|t| t.faces.x_min = ThermalCondition::Flux { value: f64::INFINITY }),
|
||||
thermal(&|t| {
|
||||
t.surfaces = vec![SurfaceThermalBoundary {
|
||||
face: GeometryRef::face("Nope", "F1").unwrap(),
|
||||
condition: ThermalCondition::Temperature { value: 1.0 },
|
||||
}]
|
||||
}),
|
||||
] {
|
||||
assert!(m.add_fluid_domain("Bad", d).is_err());
|
||||
}
|
||||
let mut aniso = hot;
|
||||
aniso.thermal =
|
||||
Some(crate::Thermal { conductivity: Some(Directional::Orthotropic([1.0; 3])), specific_heat: Some(1.0), ..Default::default() });
|
||||
m.add_material("Oil", aniso.clone()).unwrap();
|
||||
let mut d = thermal(&|_| {});
|
||||
d.material = "Oil".into();
|
||||
assert!(m.add_fluid_domain("Aniso", d).is_err(), "a fluid's conductivity is isotropic");
|
||||
assert!(m.replace_material("Water", water()).is_err(), "the domains with heat keep needing the heat properties");
|
||||
assert!(m.replace_material("Water", aniso).is_err());
|
||||
// setting conditions
|
||||
m.set_fluid_thermal_boundary("Flow", FluidBoundary::Box(BoxFace::XMin), ThermalCondition::Temperature { value: 80.0 }).unwrap();
|
||||
assert_eq!(m.fluid_domain("Flow").unwrap().thermal.as_ref().unwrap().faces.x_min, ThermalCondition::Temperature { value: 80.0 });
|
||||
assert!(m.set_fluid_thermal_boundary("Flow", FluidBoundary::Box(BoxFace::XMin), ThermalCondition::Flux { value: f64::NAN }).is_err());
|
||||
let mut plain = channel();
|
||||
plain.thermal = None;
|
||||
m.add_fluid_domain("Plain", plain).unwrap();
|
||||
assert!(
|
||||
m.set_fluid_thermal_boundary("Plain", FluidBoundary::Box(BoxFace::XMin), ThermalCondition::Adiabatic {}).is_err(),
|
||||
"no thermal settings"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domains_validate_and_refer_to_a_fluid_material() {
|
||||
let mut m = Model::default();
|
||||
|
||||
@@ -66,7 +66,10 @@ pub use constraints::{Coupling, CouplingKind, ReferencePoint};
|
||||
pub use contact::{Contact, ContactSurfaces, Friction, InitialPenetration};
|
||||
pub use datums::{Datum, Partition};
|
||||
pub use discrete::{Direction, Mass, PointMass, ResolvedDirection, ResolvedMass, ResolvedSpring, Spring};
|
||||
pub use fluids::{BoxFace, BoxFaces, FaceCondition, FluidBoundary, FluidDomain, Profile, SurfaceBoundary, SurfaceCondition};
|
||||
pub use fluids::{
|
||||
BoxFace, BoxFaces, BoxThermalFaces, FaceCondition, FluidBoundary, FluidDomain, FluidThermal, Profile, SurfaceBoundary, SurfaceCondition,
|
||||
SurfaceThermalBoundary, ThermalCondition,
|
||||
};
|
||||
pub use geometry::{BodyMesh, Classification, Entity, GeometryRef};
|
||||
pub use loads::{Distribution, Load, centrifugal_acceleration};
|
||||
pub use materials::{Damage, Directional, Elastic, Erosion, Fluid, Hardening, HardeningPoint, Hyperelastic, Material, Plastic, Thermal};
|
||||
|
||||
@@ -510,11 +510,7 @@ 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_domains_of_material(name).next()
|
||||
{
|
||||
return Err(Error::Invalid(format!("material `{name}`: fluid domain `{d}` needs its fluid behaviour")));
|
||||
}
|
||||
self.check_material_fits_fluid_domains(name, &material)?;
|
||||
replace_existing(&mut self.materials, "material", name, material)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user