FEM foundation B: heat transfer and thermal stress (solids, 2D, explicit), coupled temperature–displacement, solve.heat (#45)

* Heat transfer and thermal stress: thermal materials, temperature BCs, heat loads, heat transfer and coupled temperature–displacement steps, solve.heat

Model: Material.thermal (conductivity, specific heat, expansion, isotropic or
per axis; reference temperature), BoundaryCondition::Temperature (dof 11),
loads SurfaceFlux, Convection, HeatSource and Temperature (a field in static
steps), procedures HeatTransfer { transient (θ-method) } and
CoupledTemperatureDisplacement; checks for missing conductivity/specific
heat/expansion. fem: heat conduction kernels (conductivity, capacity,
source, face flux/film, heat flux) on the DofLayout (one T per node), thermal
strain load and stress D(ε − ε_th) for every solid formulation incl.
incompatible modes. Solver: steady and transient heat transfer, thermal loads
in static steps, coupled steps (exact one-way coupling). Engine: solve.heat,
temperature/heat-flux result fields, probes. Decks refuse heat transfer by
name. Analytic verification: slab, source+convection/flux, thick cylinder,
erfc semi-infinite solid, free/constrained expansion, bimetal strip, coupled
step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* thermal: strains in 2D elements and explicit dynamics

Plane::thermal_load and stresses_with for plane stress (εzz free), plane
strain (σzz = −D₃·ε_th) and axisymmetric elements, incompatible modes
included; the solver's 2D kernels take them. Explicit dynamics turns a
step's temperature loads into a per-element thermal strain rate (reached
linearly over the step) subtracted from each strain increment, so it feeds
J2 and Johnson–Cook updates. Verified: free expansion in every 2D theory
and element, a biaxially held plate, an explicit free bar (ΔL = αΔT L,
stress-free) and a clamped bar at −EαΔT, capped at the yield stress with
perfect plasticity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* clippy

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* thermal: trusses take the axial thermal strain; keep the roadmap's M7 line

Truss::thermal_load and stresses_with project the thermal strain on the
member's tangent (Σ αₖ tₖ² ΔT); the static solver applies them to rod2/rod3
(#38). Explicit trusses take none yet and a warning says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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:
authored and GitHub committed 2026-10-13 22:15:03 -04:00
1 parent bf325bb234
commit e900f41ce3
37 files changed
+2576 -90

No files matched your search

+31 -5
View File
@@ -142,20 +142,28 @@ pub enum BoundaryCondition {
Displacement { target: Target, values: [Option<f64>; 6] },
/// Mirror symmetry about a plane through the nodes.
Symmetry { target: Target, plane: Symmetry },
/// A prescribed temperature (degree of freedom [`TEMPERATURE_DOF`]) at the target's nodes,
/// in heat transfer and coupled temperature–displacement steps.
Temperature { target: Target, value: f64 },
}
/// The model's number of the temperature degree of freedom (the deck formats' convention).
pub const TEMPERATURE_DOF: u8 = 11;
impl BoundaryCondition {
pub fn target(&self) -> &Target {
match self {
BoundaryCondition::Fixed { target } | BoundaryCondition::Displacement { target, .. } | BoundaryCondition::Symmetry { target, .. } => {
target
}
BoundaryCondition::Fixed { target }
| BoundaryCondition::Displacement { target, .. }
| BoundaryCondition::Symmetry { target, .. }
| BoundaryCondition::Temperature { target, .. } => target,
}
}
/// The constrained degrees of freedom.
/// The constrained structural degrees of freedom (1–6); none for a temperature.
pub fn dofs(&self) -> Dofs {
match self {
BoundaryCondition::Temperature { .. } => Dofs::none(),
BoundaryCondition::Fixed { .. } => Dofs::all(),
BoundaryCondition::Displacement { values, .. } => {
Dofs(values.iter().enumerate().filter(|(_, v)| v.is_some()).fold(0, |m, (i, _)| m | (1 << i)))
@@ -164,15 +172,28 @@ impl BoundaryCondition {
}
}
/// The prescribed value of a constrained degree of freedom (1–6); `None` when it is free.
/// Whether it prescribes temperatures (rather than motion).
pub fn is_thermal(&self) -> bool {
matches!(self, BoundaryCondition::Temperature { .. })
}
/// The prescribed value of a constrained degree of freedom (1–6, or [`TEMPERATURE_DOF`]);
/// `None` when it is free.
pub fn value(&self, dof: u8) -> Option<f64> {
match self {
BoundaryCondition::Temperature { value, .. } => (dof == TEMPERATURE_DOF).then_some(*value),
BoundaryCondition::Displacement { values, .. } => values.get(usize::from(dof).checked_sub(1)?).copied().flatten(),
_ => self.dofs().contains(dof).then_some(0.0),
}
}
pub fn validate(&self) -> Result<()> {
if let BoundaryCondition::Temperature { target, value } = self {
finite("temperature", *value)?;
if let Target::ReferencePoint(r) = target {
return Err(Error::Invalid(format!("reference points have no temperature (`{r}`); prescribe it on a set")));
}
}
if let BoundaryCondition::Displacement { values, .. } = self {
for (i, v) in values.iter().enumerate() {
if let Some(v) = v {
@@ -241,6 +262,11 @@ mod tests {
assert_eq!(BoundaryCondition::Fixed { target: t.clone() }.value(6), Some(0.0));
assert!(BoundaryCondition::Displacement { target: t.clone(), values: [None; 6] }.validate().is_err());
assert!(BoundaryCondition::Displacement { target: t, values: [Some(f64::NAN), None, None, None, None, None] }.validate().is_err());
let hot = BoundaryCondition::Temperature { target: Target::Set("s".into()), value: 80.0 };
assert_eq!((hot.value(TEMPERATURE_DOF), hot.value(1), hot.dofs().list()), (Some(80.0), None, vec![]));
assert!(hot.is_thermal());
assert!(BoundaryCondition::Temperature { target: Target::Set("s".into()), value: f64::INFINITY }.validate().is_err());
assert!(BoundaryCondition::Temperature { target: Target::ReferencePoint("RP".into()), value: 1.0 }.validate().is_err());
let json = serde_json::json!({"type": "symmetry", "target": {"reference_point": "RP"}, "plane": "z"});
let parsed: BoundaryCondition = serde_json::from_value(json.clone()).unwrap();
assert_eq!(serde_json::to_value(&parsed).unwrap(), json);
+56 -4
View File
@@ -169,6 +169,8 @@ impl Model {
let mut d = Diagnostics::default();
let mut resolved = Resolved::default();
let units = self.units;
// Whether a step needs elasticity (every step but pure heat transfer).
let structural = self.steps.is_empty() || self.steps.iter().any(|s| s.procedure.is_structural());
// A model of fluid steps alone needs no mesh: its domains carry their own grid; one of
// point masses, springs and dashpots alone is its own mesh.
@@ -289,7 +291,7 @@ impl Model {
for (other, labels) in taken {
d.error(&subject, format!("elements {} are also in section `{other}`", first_labels(&labels)));
}
if self.materials.get(&sec.material).is_some_and(|m| m.elastic.is_none()) {
if structural && self.materials.get(&sec.material).is_some_and(|m| m.elastic.is_none()) {
d.error(&subject, format!("material `{}` has no elastic constants", sec.material));
}
}
@@ -307,7 +309,13 @@ impl Model {
let why = match s.procedure {
Procedure::Frequency { .. } => "extracts frequencies",
Procedure::ExplicitDynamic(_) => "is dynamic",
Procedure::Static {} | Procedure::Fluid { .. } => return None,
Procedure::HeatTransfer { transient: Some(_) } => "is transient heat transfer",
Procedure::Static {}
| Procedure::Fluid { .. }
| Procedure::HeatTransfer { transient: None }
| Procedure::CoupledTemperatureDisplacement {} => {
return None;
}
};
Some(format!("step `{}` {why}", s.name))
})
@@ -327,6 +335,33 @@ impl Model {
}
}
// Heat transfer needs conductivities (and heat capacities when transient); thermal strain
// needs expansion coefficients.
let used: BTreeSet<&str> = self.sections.values().map(|s| s.material.as_str()).collect();
let thermal = |m: &str| self.materials.get(m).and_then(|m| m.thermal);
for s in self.steps.iter().filter(|s| s.procedure.is_thermal()) {
for &m in &used {
let t = thermal(m);
if t.and_then(|t| t.conductivity).is_none() {
d.error(format!("material `{m}`"), format!("has no conductivity, and step `{}` conducts heat", s.name));
}
if matches!(s.procedure, Procedure::HeatTransfer { transient: Some(_) }) && t.and_then(|t| t.specific_heat).is_none() {
d.error(format!("material `{m}`"), format!("has no specific heat, and step `{}` is transient heat transfer", s.name));
}
}
}
let strains_thermally = self.steps.iter().any(|s| {
matches!(s.procedure, Procedure::CoupledTemperatureDisplacement {})
|| s.loads.iter().any(|l| matches!(self.loads.get(l), Some(Load::Temperature { .. })))
});
if strains_thermally {
for &m in &used {
if thermal(m).and_then(|t| t.expansion).is_none() {
d.warning(format!("material `{m}`"), "has no expansion coefficient, so temperatures do not strain it");
}
}
}
// Reference points and couplings.
resolved.reference_point_nodes = self.reference_point_nodes();
for (name, label) in &resolved.reference_point_nodes {
@@ -447,9 +482,24 @@ impl Model {
}
// The fluid domain holds its own conditions; nothing structural applies.
Procedure::Fluid { .. } => continue,
Procedure::HeatTransfer { .. } | Procedure::CoupledTemperatureDisplacement {} => {
let bcs = s.boundary_conditions.iter().filter_map(|b| self.boundary_conditions.get(b));
let loads: Vec<&Load> = s.loads.iter().filter_map(|l| self.loads.get(l)).collect();
let held = bcs.clone().any(BoundaryCondition::is_thermal) || loads.iter().any(|l| matches!(l, Load::Convection { .. }));
if !held && matches!(s.procedure, Procedure::HeatTransfer { transient: None } | Procedure::CoupledTemperatureDisplacement {}) {
d.warning(&subject, "no temperature boundary condition or convection is active, so the temperature level is undetermined");
}
}
}
// A free body is a natural case of transient dynamics (flight, impact).
if s.boundary_conditions.is_empty() && !s.procedure.is_dynamic() {
for l in s.loads.iter().filter(|l| self.loads.get(*l).is_some_and(|l| l.is_thermal() && !s.procedure.is_thermal())) {
d.warning(&subject, format!("heat load `{l}` has no effect in a {} step", s.procedure.name()));
}
// A free body is a natural case of transient dynamics (flight, impact); heat transfer
// has no rigid motion.
if s.procedure.is_structural()
&& s.boundary_conditions.iter().filter_map(|b| self.boundary_conditions.get(b)).all(BoundaryCondition::is_thermal)
&& !s.procedure.is_dynamic()
{
d.warning(&subject, "no boundary condition is active, so the model is free to move as a rigid body");
}
}
@@ -708,6 +758,8 @@ impl Model {
Load::Force { vector, .. } | Load::Traction { vector, .. } | Load::BodyForce { vector, .. } => vector[2] == 0.0,
Load::Gravity { acceleration, .. } => acceleration[2] == 0.0,
Load::Moment { .. } | Load::Pressure { .. } => true,
// Scalars.
Load::SurfaceFlux { .. } | Load::Convection { .. } | Load::HeatSource { .. } | Load::Temperature { .. } => true,
Load::Centrifugal { point, axis, .. } => {
let ok = if extent == Some(Extent::Revolved) {
axis[0] == 0.0 && axis[2] == 0.0 && point[0] == 0.0 && point[2] == 0.0
+5 -3
View File
@@ -50,18 +50,20 @@ use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
pub use bcs::{BoundaryCondition, Dofs, Symmetry, Target};
pub use bcs::{BoundaryCondition, Dofs, Symmetry, TEMPERATURE_DOF, Target};
pub use check::{Diagnostic, Resolved, ResolvedSet, Severity};
pub use constraints::{Coupling, CouplingKind, ReferencePoint};
pub use discrete::{Direction, Mass, PointMass, ResolvedDirection, ResolvedMass, ResolvedSpring, Spring};
pub use fluids::{BoxFace, BoxFaces, FaceCondition, FluidBoundary, FluidDomain, Profile, SurfaceBoundary, SurfaceCondition};
pub use geometry::{Classification, Entity, GeometryRef};
pub use loads::{Distribution, Load, centrifugal_acceleration};
pub use materials::{Damage, Elastic, Erosion, Fluid, Hardening, HardeningPoint, Material, Plastic};
pub use materials::{Damage, Directional, Elastic, Erosion, Fluid, Hardening, HardeningPoint, Material, Plastic, Thermal};
pub use mesh::{Element, ElementEdge, ElementFace, ElementKind, Node, check_coordinates};
pub use sections::{BeamProfile, Extent, Formulation, Integration, Section, SectionKind};
pub use sets::Set;
pub use steps::{BulkViscosity, ExplicitDynamic, Hourglass, HourglassKind, InitialVelocity, OutputVariable, Procedure, RigidWall, Step, StressRate};
pub use steps::{
BulkViscosity, ExplicitDynamic, Hourglass, HourglassKind, InitialVelocity, OutputVariable, Procedure, RigidWall, Step, StressRate, Transient,
};
pub use units::UnitSystem;
/// The largest node or element label. Nastran's GRID and element ids stop at 99 999 999 (eight
+68 -4
View File
@@ -10,7 +10,7 @@ use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::bcs::Target;
use crate::{Error, Model, Result, context, finite, insert_new, not_in_use, remove_existing, replace_existing, require};
use crate::{Error, Model, Result, context, finite, insert_new, not_in_use, positive, remove_existing, replace_existing, require};
/// How a force or moment on a set is shared among the set's nodes.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
@@ -70,6 +70,17 @@ pub enum Load {
#[serde(default, skip_serializing_if = "Option::is_none")]
set: Option<String>,
},
/// Heat flux per area into the set's element faces (positive heats the body).
SurfaceFlux { set: String, flux: f64 },
/// Convection from the set's element faces to a surrounding medium at `sink` temperature:
/// heat flux h (T_sink − T) into the body, with film coefficient `film` (h).
Convection { set: String, film: f64, sink: f64 },
/// Heat generated per volume in the set's elements.
HeatSource { set: String, power: f64 },
/// A temperature field at the target's nodes in a stress step: it strains the material
/// thermally (ε = α (T − T_ref)); nodes it does not reach stay at their material's reference
/// temperature.
Temperature { target: Target, value: f64 },
}
/// The centrifugal acceleration ω² r at `x` of a rotation at `angular_velocity` about the axis
@@ -94,14 +105,29 @@ impl Load {
Load::Gravity { .. } => "gravity",
Load::BodyForce { .. } => "body_force",
Load::Centrifugal { .. } => "centrifugal",
Load::SurfaceFlux { .. } => "surface_flux",
Load::Convection { .. } => "convection",
Load::HeatSource { .. } => "heat_source",
Load::Temperature { .. } => "temperature",
}
}
/// Whether it is a heat transfer load (flux, convection, heat source), active in heat
/// transfer and coupled temperature–displacement steps.
pub fn is_thermal(&self) -> bool {
matches!(self, Load::SurfaceFlux { .. } | Load::Convection { .. } | Load::HeatSource { .. })
}
/// The set the load acts on, if any.
pub fn set(&self) -> Option<&str> {
match self {
Load::Force { target, .. } | Load::Moment { target, .. } => target.set(),
Load::Pressure { set, .. } | Load::BodyForce { set, .. } | Load::Traction { set, .. } => Some(set),
Load::Force { target, .. } | Load::Moment { target, .. } | Load::Temperature { target, .. } => target.set(),
Load::Pressure { set, .. }
| Load::BodyForce { set, .. }
| Load::Traction { set, .. }
| Load::SurfaceFlux { set, .. }
| Load::Convection { set, .. }
| Load::HeatSource { set, .. } => Some(set),
Load::Gravity { set, .. } | Load::Centrifugal { set, .. } => set.as_deref(),
}
}
@@ -109,7 +135,7 @@ impl Load {
/// The reference point the load acts on, if any.
pub fn reference_point(&self) -> Option<&str> {
match self {
Load::Force { target, .. } | Load::Moment { target, .. } => target.reference_point(),
Load::Force { target, .. } | Load::Moment { target, .. } | Load::Temperature { target, .. } => target.reference_point(),
_ => None,
}
}
@@ -137,6 +163,24 @@ impl Load {
}
return Ok(());
}
Load::SurfaceFlux { flux: v, .. } | Load::HeatSource { power: v, .. } => {
if finite(self.kind(), *v)? == 0.0 {
return Err(Error::Invalid(format!("a {} must not be zero", self.kind())));
}
return Ok(());
}
Load::Convection { film, sink, .. } => {
positive("film coefficient", *film)?;
finite("sink temperature", *sink)?;
return Ok(());
}
Load::Temperature { target, value } => {
finite("temperature", *value)?;
if let Target::ReferencePoint(r) = target {
return Err(Error::Invalid(format!("reference points have no temperature (`{r}`); apply it to a set")));
}
return Ok(());
}
};
for c in vector {
finite(self.kind(), *c)?;
@@ -185,3 +229,23 @@ impl Model {
remove_existing(&mut self.loads, "load", name).map(|_| ())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn heat_loads_validate() {
let set = || "s".to_string();
assert!(Load::SurfaceFlux { set: set(), flux: 2.0 }.validate().is_ok());
assert!(Load::SurfaceFlux { set: set(), flux: 0.0 }.validate().is_err());
assert!(Load::HeatSource { set: set(), power: f64::NAN }.validate().is_err());
assert!(Load::Convection { set: set(), film: 0.0, sink: 1.0 }.validate().is_err());
assert!(Load::Convection { set: set(), film: 1.0, sink: f64::INFINITY }.validate().is_err());
assert!(Load::Temperature { target: Target::ReferencePoint("RP".into()), value: 1.0 }.validate().is_err());
assert!(Load::Temperature { target: Target::Set(set()), value: -40.0 }.validate().is_ok());
let l = Load::Convection { set: set(), film: 10.0, sink: 20.0 };
assert!(l.is_thermal() && l.set() == Some("s") && l.kind() == "convection");
assert!(!Load::Temperature { target: Target::Set(set()), value: 1.0 }.is_thermal());
}
}
+99 -4
View File
@@ -1,4 +1,5 @@
//! Materials: linear elasticity, mass density, fluid behaviour, and the inelastic behaviours of
//! Materials: linear elasticity, mass density, fluid behaviour, thermal properties (conductivity,
//! specific heat, expansion), and the inelastic behaviours of
//! explicit dynamics: von Mises (J2) plasticity with a bilinear, tabulated or Johnson–Cook flow stress,
//! Johnson–Cook damage, and element erosion criteria.
//!
@@ -237,6 +238,67 @@ impl Plastic {
}
}
/// A property given per material axis: one value for every direction, or three along the
/// material axes 1, 2, 3 (the global X, Y, Z until material orientations land).
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Directional {
Isotropic(f64),
Orthotropic([f64; 3]),
}
impl Directional {
/// The values along the three axes.
pub fn axes(self) -> [f64; 3] {
match self {
Directional::Isotropic(v) => [v; 3],
Directional::Orthotropic(v) => v,
}
}
}
/// Heat conduction and thermal expansion.
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Thermal {
/// Conductivity k (power per length per temperature): q = −k ∇T.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conductivity: Option<Directional>,
/// Specific heat c (energy per mass per temperature); with the density, the heat capacity
/// of transient heat transfer.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub specific_heat: Option<f64>,
/// Linear expansion coefficient α (strain per temperature): ε_th = α (T − T_ref).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expansion: Option<Directional>,
/// The temperature at which thermal strain is zero.
#[serde(default)]
pub reference_temperature: f64,
}
impl Thermal {
pub fn validate(&self) -> Result<()> {
if let Some(k) = self.conductivity {
for v in k.axes() {
positive("conductivity", v)?;
}
}
if let Some(c) = self.specific_heat {
positive("specific heat", c)?;
}
if let Some(a) = self.expansion {
for v in a.axes() {
finite("expansion coefficient", v)?;
}
}
finite("reference temperature", self.reference_temperature)?;
if self.conductivity.is_none() && self.specific_heat.is_none() && self.expansion.is_none() {
return Err(Error::Invalid("thermal properties need a conductivity, a specific heat or an expansion coefficient".into()));
}
Ok(())
}
}
/// Damage that accumulates with plastic strain; a material point fails when it reaches 1.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
@@ -270,7 +332,7 @@ pub struct Erosion {
pub struct Material {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub elastic: Option<Elastic>,
/// Mass per volume, needed by frequency steps and gravity.
/// Mass per volume, needed by frequency steps, gravity and transient heat transfer.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub density: Option<f64>,
/// How it flows, for fluid domains (needs a density).
@@ -286,12 +348,14 @@ pub struct Material {
/// Erosion criteria (explicit dynamics).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub erosion: Option<Erosion>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thermal: Option<Thermal>,
}
impl Material {
pub fn validate(&self) -> Result<()> {
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 self.elastic.is_none() && self.density.is_none() && self.fluid.is_none() && self.thermal.is_none() {
return Err(Error::Invalid("a material needs elastic constants, a density, fluid behaviour or thermal properties".into()));
}
if let Some(f) = &self.fluid {
positive("viscosity", f.viscosity())?;
@@ -332,6 +396,9 @@ impl Material {
return Err(Error::Invalid("a plastic-strain erosion criterion needs plasticity".into()));
}
}
if let Some(t) = &self.thermal {
t.validate()?;
}
Ok(())
}
}
@@ -476,4 +543,32 @@ mod tests {
let text = serde_json::to_string(&Material { plastic: Some(jc()), damage: Some(damage), ..steel() }).unwrap();
assert_eq!(serde_json::from_str::<Material>(&text).unwrap(), Material { plastic: Some(jc()), damage: Some(damage), ..steel() });
}
#[test]
fn thermal_properties() {
let m: Material = serde_json::from_value(serde_json::json!({
"thermal": {"conductivity": [1, 2, 3], "specific_heat": 460, "expansion": 1.2e-5, "reference_temperature": 20}
}))
.unwrap();
let t = m.thermal.unwrap();
assert_eq!(t.conductivity.unwrap().axes(), [1.0, 2.0, 3.0]);
assert_eq!(t.expansion.unwrap().axes(), [1.2e-5; 3]);
assert!(m.validate().is_ok(), "a heat-only material needs no elasticity");
let back: Material = serde_json::from_value(serde_json::to_value(&m).unwrap()).unwrap();
assert_eq!(back, m);
for bad in [
Thermal::default(),
Thermal { conductivity: Some(Directional::Isotropic(0.0)), ..Thermal::default() },
Thermal { conductivity: Some(Directional::Orthotropic([1.0, -1.0, 1.0])), ..Thermal::default() },
Thermal { specific_heat: Some(f64::NAN), ..Thermal::default() },
Thermal { expansion: Some(Directional::Isotropic(f64::INFINITY)), ..Thermal::default() },
Thermal { expansion: Some(Directional::Isotropic(1e-5)), reference_temperature: f64::NAN, ..Thermal::default() },
] {
assert!(bad.validate().is_err(), "{bad:?}");
}
assert!(serde_json::from_value::<Material>(serde_json::json!({"thermal": {"conductivity": [1, 2]}})).is_err());
assert!(serde_json::from_value::<Material>(serde_json::json!({"thermal": {"heat": 1}})).is_err());
// Negative expansion (invar-like behaviour of some composites) is allowed.
assert!(Thermal { expansion: Some(Directional::Isotropic(-1e-6)), ..Thermal::default() }.validate().is_ok());
}
}
+3 -2
View File
@@ -97,8 +97,9 @@ fn rule(corners: usize) -> Vec<(f64, f64, f64)> {
/// Visit the quadrature points of a face (node coordinates in the face's node order): the shape
/// function values and the weighted normal (∂x/∂ξ × ∂x/∂η) w, whose length is the point's share of
/// the area and whose direction is the face's right-hand normal.
fn integrate_face(xyz: &[[f64; 3]], mut visit: impl FnMut(&[f64], [f64; 3])) -> Result<()> {
/// the area and whose direction is the face's right-hand normal. Every surface load (pressure,
/// traction, heat flux, convection) integrates with it, so they share one rule.
pub fn integrate_face(xyz: &[[f64; 3]], mut visit: impl FnMut(&[f64], [f64; 3])) -> Result<()> {
if xyz.iter().flatten().any(|c| !c.is_finite()) {
return Err(Error::Invalid("face coordinates must be finite".into()));
}
+109 -2
View File
@@ -76,6 +76,62 @@ pub enum Procedure {
#[serde(default, skip_serializing_if = "Option::is_none")]
magic: Option<f64>,
},
/// Heat conduction: the steady temperature field, or its history over time when `transient`
/// is given. Temperature boundary conditions and heat loads (flux, convection, sources)
/// act; mechanical ones are ignored.
HeatTransfer {
#[serde(default, skip_serializing_if = "Option::is_none")]
transient: Option<Transient>,
},
/// Steady heat conduction and the static equilibrium of the thermal strains it causes,
/// together: every boundary condition and load of the step acts.
CoupledTemperatureDisplacement {},
}
/// Most time increments of a transient heat transfer step.
pub const MAX_HEAT_INCREMENTS: f64 = 100_000.0;
/// Time integration of a transient heat transfer step: the generalized trapezoidal (θ) method
/// with fixed increments, from a uniform initial temperature.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Transient {
pub end_time: f64,
pub increment: f64,
/// 1 is backward Euler (the default), ½ Crank–Nicolson; θ ≥ ½ is unconditionally stable.
#[serde(default = "default_theta")]
pub theta: f64,
#[serde(default)]
pub initial_temperature: f64,
}
fn default_theta() -> f64 {
1.0
}
impl Transient {
/// Backward Euler from 0.
pub fn new(end_time: f64, increment: f64) -> Transient {
Transient { end_time, increment, theta: default_theta(), initial_temperature: 0.0 }
}
/// The number of increments (the last one may be shorter).
pub fn increments(&self) -> usize {
(self.end_time / self.increment - 1e-9).ceil().max(1.0) as usize
}
pub fn validate(&self) -> Result<()> {
let end = positive("end time", self.end_time)?;
let dt = positive("time increment", self.increment)?;
if end / dt > MAX_HEAT_INCREMENTS {
return Err(Error::Invalid(format!("a transient heat step takes at most {MAX_HEAT_INCREMENTS} increments, got {}", end / dt)));
}
if !(0.5..=1.0).contains(&self.theta) {
return Err(Error::Invalid(format!("θ is in [0.5, 1] (unconditionally stable), got {}", self.theta)));
}
finite("initial temperature", self.initial_temperature)?;
Ok(())
}
}
/// The settings of an explicit dynamic step.
@@ -336,6 +392,8 @@ impl Procedure {
Procedure::Static {} => "static",
Procedure::Frequency { .. } => "frequency",
Procedure::ExplicitDynamic(_) => "explicit_dynamic",
Procedure::HeatTransfer { .. } => "heat_transfer",
Procedure::CoupledTemperatureDisplacement {} => "coupled_temperature_displacement",
Procedure::Fluid { .. } => "fluid",
}
}
@@ -344,10 +402,21 @@ impl Procedure {
pub fn fluid_domain(&self) -> Option<&str> {
match self {
Procedure::Fluid { domain, .. } => Some(domain),
Procedure::Static {} | Procedure::Frequency { .. } | Procedure::ExplicitDynamic(_) => None,
_ => None,
}
}
/// Whether the procedure solves for the motion of the solids (not pure heat transfer or
/// fluid flow).
pub fn is_structural(&self) -> bool {
!matches!(self, Procedure::HeatTransfer { .. } | Procedure::Fluid { .. })
}
/// Whether the procedure solves for temperatures.
pub fn is_thermal(&self) -> bool {
matches!(self, Procedure::HeatTransfer { .. } | Procedure::CoupledTemperatureDisplacement {})
}
/// Whether the procedure is transient dynamics (it needs mass; a free body is legitimate).
pub fn is_dynamic(&self) -> bool {
matches!(self, Procedure::ExplicitDynamic(_))
@@ -357,7 +426,7 @@ impl Procedure {
pub fn sets(&self) -> impl Iterator<Item = &str> {
let explicit = match self {
Procedure::ExplicitDynamic(e) => Some(e),
Procedure::Static {} | Procedure::Frequency { .. } | Procedure::Fluid { .. } => None,
_ => None,
};
explicit.into_iter().flat_map(ExplicitDynamic::sets)
}
@@ -387,6 +456,9 @@ impl Procedure {
return Err(Error::Invalid(format!("the two-relaxation-time parameter Λ must be above 0 and at most 1, got {m}")));
}
}
if let Procedure::HeatTransfer { transient: Some(t) } = self {
return t.validate();
}
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}")));
@@ -413,6 +485,10 @@ impl Procedure {
Procedure::ExplicitDynamic(_) => [OutputVariable::Displacement, OutputVariable::Stress].into_iter().collect(),
// Velocity, pressure and wall forces, always.
Procedure::Fluid { .. } => BTreeSet::new(),
Procedure::HeatTransfer { .. } => [OutputVariable::Temperature, OutputVariable::HeatFlux].into_iter().collect(),
Procedure::CoupledTemperatureDisplacement {} => {
OutputVariable::ALL.into_iter().chain([OutputVariable::Temperature, OutputVariable::HeatFlux]).collect()
}
}
}
}
@@ -427,9 +503,14 @@ pub enum OutputVariable {
ReactionForce,
Stress,
Strain,
/// Nodal temperatures (heat transfer).
Temperature,
/// Heat flux at the integration points (heat transfer).
HeatFlux,
}
impl OutputVariable {
/// The structural variables.
pub const ALL: [OutputVariable; 4] =
[OutputVariable::Displacement, OutputVariable::ReactionForce, OutputVariable::Stress, OutputVariable::Strain];
}
@@ -671,4 +752,30 @@ mod tests {
}
assert!(serde_json::from_value::<Procedure>(serde_json::json!({"type": "explicit_dynamic", "end_time": 1, "dt": 2})).is_err());
}
#[test]
fn heat_transfer_procedures() {
let steady: Procedure = serde_json::from_value(serde_json::json!({"type": "heat_transfer"})).unwrap();
assert_eq!(steady, Procedure::HeatTransfer { transient: None });
assert!(steady.is_thermal() && steady.validate().is_ok());
assert_eq!(steady.default_output(), [OutputVariable::Temperature, OutputVariable::HeatFlux].into_iter().collect());
let t: Procedure =
serde_json::from_value(serde_json::json!({"type": "heat_transfer", "transient": {"end_time": 10, "increment": 0.3}})).unwrap();
let Procedure::HeatTransfer { transient: Some(tr) } = t else { panic!() };
assert_eq!((tr.theta, tr.initial_temperature, tr.increments()), (1.0, 0.0, 34));
assert_eq!(Transient::new(1.0, 0.25).increments(), 4);
for bad in [
Transient { theta: 0.4, ..Transient::new(1.0, 0.1) },
Transient { theta: f64::NAN, ..Transient::new(1.0, 0.1) },
Transient::new(1.0, 0.0),
Transient::new(-1.0, 0.1),
Transient::new(1e300, 1e-300),
Transient { initial_temperature: f64::INFINITY, ..Transient::new(1.0, 0.1) },
] {
assert!(Procedure::HeatTransfer { transient: Some(bad) }.validate().is_err(), "{bad:?}");
}
let coupled: Procedure = serde_json::from_value(serde_json::json!({"type": "coupled_temperature_displacement"})).unwrap();
assert!(coupled.is_thermal() && coupled.default_output().contains(&OutputVariable::Stress));
assert_eq!(coupled.name(), "coupled_temperature_displacement");
}
}