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 <[email protected]>
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* clippy

Co-Authored-By: Claude Opus 5.5 <[email protected]>
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

---------

Co-authored-by: jyviko <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
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

+43 -5
View File
@@ -14,7 +14,10 @@ use crate::constraints::{Budget, Reduction, coupling_rows};
use crate::eigen::{Operator, Wanted, lanczos};
use crate::ordering::nested_dissection;
use crate::sparse::{Csr, NodeGraph, add_block, add_diagonal, add_scalar};
use crate::system::{Mesh, Structural, StructuralKernel, inelastic_warnings, loads, prescribed, structural_body_forces, translation_block};
use crate::system::{
Mesh, Structural, StructuralKernel, add_thermal_loads, inelastic_warnings, loads, prescribed, structural_body_forces, temperature_loads,
translation_block,
};
use crate::{Error, Limits, Result};
/// Size and effort of a solution.
@@ -99,6 +102,8 @@ pub struct StaticResult {
pub reaction_total: [f64; 3],
pub stats: Stats,
pub warnings: Vec<String>,
/// The temperatures of a coupled temperature–displacement step, per node.
pub temperature: Option<Vec<f64>>,
}
/// One natural mode.
@@ -293,17 +298,43 @@ fn budget(limits: &Limits, mesh: &Mesh, graph: &NodeGraph, matrices: usize) -> B
}
/// Solve a linear static step: the step named `step`, or the model's only static step.
///
/// A coupled temperature–displacement step is solved here too: its steady temperatures first
/// ([`crate::heat`]), then the equilibrium with their thermal strains. The coupling is one-way
/// (temperature strains the solid; deformation does not heat it), which is the whole of linear
/// quasi-static thermoelasticity without thermoelastic dissipation, so solving the two in turn
/// is the monolithic solution exactly.
pub fn solve_static(model: &Model, step: Option<&str>, limits: &Limits) -> Result<StaticResult> {
let resolved = model.resolve()?;
let step = model.select_step(step, |p| matches!(p, Procedure::Static {}), "static")?;
let step = model.select_step(
step,
|p| matches!(p, Procedure::Static {} | Procedure::CoupledTemperatureDisplacement {}),
"static or coupled temperature–displacement",
)?;
let mesh = Mesh::new(model, &resolved, limits.max_dofs)?;
let mut warnings: Vec<String> = resolved.warnings.iter().map(ToString::to_string).chain(mesh.warnings.iter().cloned()).collect();
warnings.extend(inelastic_warnings(model));
// The temperature field: the coupled step's own heat solution, or the step's temperature
// loads.
let (field, temperature) = match step.procedure {
Procedure::CoupledTemperatureDisplacement {} => {
let (labels, t) = crate::heat::temperatures(model, &resolved, step, limits)?;
let at = |l: &u32| labels.binary_search(l).ok().and_then(|i| t.get(i)).copied();
(Some(mesh.labels.iter().map(at).collect::<Vec<_>>()), Some(mesh.labels.iter().map(|l| at(l).unwrap_or(0.0)).collect()))
}
_ => (temperature_loads(model, &resolved, &mesh, step)?, None),
};
if field.is_some() && !mesh.structural.is_empty() {
warnings.push("shells and beams take no thermal strain yet; the temperatures strain the solid, 2D and truss elements only".into());
}
let (graph, order) = pattern(&mesh);
ignored_dashpots(&mesh, &mut warnings);
let setup = Setup { model, resolved: &resolved, mesh: &mesh, step };
let red = reduction(&setup, true, &order, budget(limits, &mesh, &graph, 1), &mut warnings)?;
let f = loads(model, &resolved, &mesh, step, &red.active)?;
let mut f = loads(model, &resolved, &mesh, step, &red.active)?;
if let Some(field) = &field {
add_thermal_loads(&mut f, &mesh, field)?;
}
let k = assemble(&mesh, &graph, None)?.k;
if red.neq() == 0 {
return Err(Error::Invalid("every degree of freedom is prescribed; there is nothing to solve".into()));
@@ -343,7 +374,8 @@ pub fn solve_static(model: &Model, step: Option<&str>, limits: &Limits) -> Resul
let kernel = e.kernel()?;
let at = |err| Error::element(e.label, err);
let ue: Vec<[f64; 3]> = e.nodes.iter().map(|n| displacement.get(*n).copied().unwrap_or_default()).collect();
let points = kernel.stresses(&e.d, &ue).map_err(at)?;
let thermal = field.as_deref().and_then(|t| e.thermal_strain(t));
let points = kernel.stresses_with(&e.d, &ue, thermal.as_ref()).map_err(at)?;
let values: Vec<[f64; 6]> = points.iter().map(|p| p.stress).collect();
let (ty, rule) = (kernel.ty(), kernel.rule());
let cached = extrapolations.iter().position(|(t, r, _)| std::ptr::eq(*t, ty) && *r == rule);
@@ -369,7 +401,8 @@ pub fn solve_static(model: &Model, step: Option<&str>, limits: &Limits) -> Resul
let truss = t.truss()?;
let at = |err| Error::element(t.label, err);
let ue: Vec<[f64; 3]> = t.nodes.iter().map(|n| displacement.get(*n).copied().unwrap_or_default()).collect();
let points = truss.stresses(t.young, &ue).map_err(at)?;
let thermal = field.as_deref().and_then(|f| t.thermal_strain(f));
let points = truss.stresses_with(t.young, &ue, thermal.as_ref()).map_err(at)?;
let nodal = truss.extrapolate(&points.iter().map(|p| p.stress).collect::<Vec<_>>()).map_err(at)?;
for (n, s) in t.nodes.iter().zip(&nodal) {
if let (Some(acc), Some(c)) = (sum.get_mut(*n), count.get_mut(*n)) {
@@ -432,6 +465,7 @@ pub fn solve_static(model: &Model, step: Option<&str>, limits: &Limits) -> Resul
reaction_total,
stats: Stats { dofs: mesh.dofs(), equations: red.neq(), factor_entries: chol.entries(), lanczos_vectors: 0 },
warnings,
temperature,
})
}
@@ -585,6 +619,7 @@ pub enum Location {
pub enum StepResult {
Static(StaticResult),
Frequency(FrequencyResult),
Heat(crate::heat::HeatResult),
}
impl StepResult {
@@ -592,6 +627,7 @@ impl StepResult {
match self {
StepResult::Static(r) => &r.step,
StepResult::Frequency(r) => &r.step,
StepResult::Heat(r) => &r.step,
}
}
@@ -599,6 +635,7 @@ impl StepResult {
match self {
StepResult::Static(r) => &r.nodes,
StepResult::Frequency(r) => &r.nodes,
StepResult::Heat(r) => &r.nodes,
}
}
@@ -611,6 +648,7 @@ impl StepResult {
match self {
StepResult::Static(r) => r.reference_points.iter().position(|p| p.label == label),
StepResult::Frequency(r) => r.reference_points.iter().position(|p| p.1 == label),
StepResult::Heat(_) => None,
}
.map(Location::Point)
}
+491
View File
@@ -0,0 +1,491 @@
//! Heat transfer: steady and transient conduction with prescribed temperatures, surface fluxes,
//! convection and internal heat sources.
//!
//! Every node of the solid elements carries one temperature ([`modelcraft_fem::heat::HEAT_DOFS`]).
//! The conductivity K (with the convection films) and capacity C are assembled on the node
//! graph in element label order ([`modelcraft_fem::heat`]); prescribed temperatures are
//! eliminated, the free ones numbered by nested dissection and solved by sparse Cholesky.
//!
//! - **Steady**: K T = f.
//! - **Transient**, the generalized trapezoidal (θ) method with n equal increments Δt over the
//! step (T. J. R. Hughes, *The Finite Element Method*, Dover 2000, §8.2; unconditionally stable
//! for θ ≥ ½, θ = 1 backward Euler, θ = ½ Crank–Nicolson):
//! (C + θΔt K) Tₙ₊₁ = (C − (1 − θ)Δt K) Tₙ + Δt f, from a uniform initial temperature, with
//! the prescribed temperatures held from the first increment on. The matrix is factored once.
//!
//! The heat flux −k∇T is reported at the integration points and averaged at the nodes; the heat
//! flow at prescribed temperatures is the reaction K T − f (C Ṫ included in transient steps).
use modelcraft_fem::element::{Extrapolation, extrapolation};
use modelcraft_fem::heat::{Conduction, PointFlux, face_film, face_weights};
use modelcraft_fem::{DofLayout, DofSet};
use modelcraft_model::{ElementKind, Load, Model, Procedure, Resolved, SectionKind, Step, TEMPERATURE_DOF};
use crate::cholesky::{Factor, FactorError, UpperColumns};
use crate::ordering::nested_dissection;
use crate::sparse::{Csr, NodeGraph, add_block};
use crate::system::hold;
use crate::{Error, Limits, Result, Stats};
/// Most temperature values a transient result keeps (frames × nodes); frames are thinned
/// evenly to stay below it, the last increment always kept.
pub const MAX_TEMPERATURE_VALUES: usize = 50_000_000;
/// A solid element conducting heat.
#[derive(Debug, Clone)]
pub struct HeatElem {
pub label: u32,
pub kind: ElementKind,
/// Indices into [`HeatMesh::labels`].
pub nodes: Vec<usize>,
pub xyz: Vec<[f64; 3]>,
/// Conductivity along X, Y, Z.
pub conductivity: [f64; 3],
/// ρc, when the material has a density and a specific heat.
pub capacity: Option<f64>,
}
impl HeatElem {
fn kernel(&self) -> Result<Conduction<'_>> {
Conduction::new(self.kind, &self.xyz).map_err(|e| Error::element(self.label, e))
}
}
/// The conducting mesh: the nodes of the solid elements by label, and the elements by label.
#[derive(Debug, Clone)]
pub struct HeatMesh {
pub labels: Vec<u32>,
pub xyz: Vec<[f64; 3]>,
pub elements: Vec<HeatElem>,
pub layout: DofLayout,
}
impl HeatMesh {
pub fn new(model: &Model, resolved: &Resolved, max_dofs: usize) -> Result<HeatMesh> {
let mut elements = Vec::new();
for (name, section) in model.sections() {
if !matches!(section.kind, SectionKind::Solid {}) {
return Err(Error::NotSupported(format!(
"heat transfer in {} sections (section `{name}`); it has solid elements so far",
section.kind.name()
)));
}
let material = model
.material(&section.material)
.ok_or_else(|| Error::Invalid(format!("section `{name}`: material `{}` is missing", section.material)))?;
let thermal = material.thermal.unwrap_or_default();
let conductivity =
thermal.conductivity.ok_or_else(|| Error::Invalid(format!("material `{}` has no conductivity", section.material)))?.axes();
let capacity = material.density.zip(thermal.specific_heat).map(|(rho, c)| rho * c);
let set = resolved.sets.get(&section.set).ok_or_else(|| Error::Invalid(format!("set `{}` is not resolved", section.set)))?;
for &label in &set.elements {
let el = model.element(label).ok_or_else(|| Error::Invalid(format!("element {label} is missing")))?;
elements.push((label, el.kind, el.nodes.clone(), conductivity, capacity));
}
}
elements.sort_by_key(|e| e.0);
let layout = DofLayout::uniform(elements.iter().flat_map(|e| e.2.iter().copied()), DofSet::TEMPERATURE);
if layout.dofs() > max_dofs {
return Err(Error::Limit(format!("the model has {} temperatures; the limit is {max_dofs}", layout.dofs())));
}
let labels: Vec<u32> = (0..layout.nodes()).filter_map(|s| layout.label(s)).collect();
let xyz: Vec<[f64; 3]> = labels
.iter()
.map(|l| model.node(*l).map(|n| n.xyz).ok_or_else(|| Error::Invalid(format!("node {l} is missing"))))
.collect::<Result<_>>()?;
let mut mesh = HeatMesh { labels, xyz, elements: Vec::with_capacity(elements.len()), layout };
for (label, kind, nodes, conductivity, capacity) in elements {
let idx: Vec<usize> = nodes.iter().filter_map(|n| mesh.layout.node(*n)).collect();
let xyz = idx.iter().filter_map(|i| mesh.xyz.get(*i).copied()).collect();
mesh.elements.push(HeatElem { label, kind, nodes: idx, xyz, conductivity, capacity });
}
Ok(mesh)
}
fn node_of(&self, what: &str, label: u32) -> Result<usize> {
self.layout.node(label).ok_or_else(|| Error::Invalid(format!("{what}: node {label} belongs to no solid element")))
}
fn element(&self, label: u32) -> Option<&HeatElem> {
self.elements.binary_search_by_key(&label, |e| e.label).ok().and_then(|i| self.elements.get(i))
}
}
/// The heat flux in one element.
#[derive(Debug, Clone, PartialEq)]
pub struct ElementFlux {
pub label: u32,
pub kind: ElementKind,
pub points: Vec<PointFlux>,
}
/// The result of a heat transfer step.
#[derive(Debug, Clone, PartialEq)]
pub struct HeatResult {
pub step: String,
/// Node labels with a temperature, sorted; per-node vectors follow this order.
pub nodes: Vec<u32>,
/// The time of each frame (one frame at time 0 for a steady step).
pub times: Vec<f64>,
/// Nodal temperatures per frame; the last frame is the end of the step.
pub frames: Vec<Vec<f64>>,
/// Heat flux averaged at the nodes, at the end of the step.
pub flux: Vec<[f64; 3]>,
/// Heat flux at the integration points, at the end of the step.
pub elements: Vec<ElementFlux>,
/// The heat each prescribed temperature supplies to the body at the end of the step
/// (negative where it takes heat away; zero at free nodes).
pub reaction: Vec<f64>,
/// Heat supplied by fluxes, convection and sources (at the end temperatures), and the total
/// reaction heat flow; in a steady state they cancel.
pub applied_total: f64,
pub reaction_total: f64,
pub stats: Stats,
pub warnings: Vec<String>,
}
impl HeatResult {
/// The temperatures at the end of the step.
pub fn temperature(&self) -> &[f64] {
self.frames.last().map(Vec::as_slice).unwrap_or_default()
}
}
/// The assembled heat system of a step: conductivity (with films), capacity when transient,
/// and the heat supplied.
struct HeatSystem {
k: Csr,
c: Option<Csr>,
f: Vec<f64>,
}
fn assemble(model: &Model, resolved: &Resolved, mesh: &HeatMesh, graph: &NodeGraph, step: &Step, transient: bool) -> Result<HeatSystem> {
let layout = &mesh.layout;
let mut k = graph.zero_matrix(layout);
let mut c = transient.then(|| graph.zero_matrix(layout));
for e in &mesh.elements {
let kernel = e.kernel()?;
let at = |err| Error::element(e.label, err);
add_block(&mut k, layout, &e.nodes, DofSet::TEMPERATURE, &kernel.conductivity(e.conductivity).map_err(at)?.data)?;
if let Some(c) = c.as_mut() {
let rho_c = e.capacity.ok_or_else(|| Error::Invalid(format!("element {}: its material needs a density and a specific heat", e.label)))?;
add_block(c, layout, &e.nodes, DofSet::TEMPERATURE, &kernel.capacity(rho_c).map_err(at)?.data)?;
}
}
let mut f = vec![0.0; layout.dofs()];
let add = |f: &mut [f64], nodes: &[usize], v: &[f64]| {
for (n, x) in nodes.iter().zip(v) {
if let Some(y) = f.get_mut(*n) {
*y += x;
}
}
};
for name in &step.loads {
let load = model.load(name).ok_or_else(|| Error::Invalid(format!("load `{name}` is missing")))?;
let faces = || -> Result<Vec<(u32, Vec<usize>, Vec<[f64; 3]>)>> {
let set = load.set().unwrap_or_default();
resolved
.sets
.get(set)
.map(|s| s.faces.as_slice())
.unwrap_or_default()
.iter()
.map(|fc| {
let labels = model
.face_nodes(*fc)
.ok_or_else(|| Error::Invalid(format!("load `{name}`: element {} face {} is missing", fc.element, fc.face)))?;
let idx: Vec<usize> = labels.iter().map(|l| mesh.node_of(&format!("load `{name}`"), *l)).collect::<Result<_>>()?;
let xyz = idx.iter().filter_map(|i| mesh.xyz.get(*i).copied()).collect();
Ok((fc.element, idx, xyz))
})
.collect()
};
match load {
Load::SurfaceFlux { flux, .. } => {
for (label, idx, xyz) in faces()? {
let w = face_weights(&xyz).map_err(|e| Error::element(label, e))?;
add(&mut f, &idx, &w.iter().map(|w| w * flux).collect::<Vec<_>>());
}
}
Load::Convection { film, sink, .. } => {
for (label, idx, xyz) in faces()? {
let at = |e| Error::element(label, e);
add_block(&mut k, layout, &idx, DofSet::TEMPERATURE, &face_film(&xyz, *film).map_err(at)?.data)?;
let w = face_weights(&xyz).map_err(at)?;
add(&mut f, &idx, &w.iter().map(|w| w * film * sink).collect::<Vec<_>>());
}
}
Load::HeatSource { set, power } => {
for label in resolved.sets.get(set).map(|s| s.elements.as_slice()).unwrap_or_default() {
let e = mesh.element(*label).ok_or_else(|| Error::Invalid(format!("load `{name}`: element {label} is not a solid element")))?;
let q = e.kernel()?.source(*power).map_err(|err| Error::element(e.label, err))?;
add(&mut f, &e.nodes, &q);
}
}
// Mechanical loads and temperature fields do not act on heat conduction.
_ => {}
}
}
Ok(HeatSystem { k, c, f })
}
/// The prescribed temperature of every node (`None` = free) under the step's boundary
/// conditions.
fn prescribed(model: &Model, resolved: &Resolved, mesh: &HeatMesh, step: &Step) -> Result<Vec<Option<f64>>> {
let mut out = vec![None; mesh.layout.dofs()];
for name in &step.boundary_conditions {
let bc = model.boundary_condition(name).ok_or_else(|| Error::Invalid(format!("boundary condition `{name}` is missing")))?;
let Some(v) = bc.value(TEMPERATURE_DOF) else { continue };
for &label in resolved.target_nodes(bc.target()) {
let Some(n) = mesh.layout.node(label) else { continue };
hold(out.get_mut(n), v, |old| format!("node {label} is held at both {old} and {v} (boundary condition `{name}` and another)"))?;
}
}
Ok(out)
}
/// The free temperatures numbered in nested-dissection order.
struct Equations {
/// Per node, its equation or `usize::MAX` when prescribed.
eq: Vec<usize>,
/// Per equation, its node.
node: Vec<usize>,
}
impl Equations {
fn new(mesh: &HeatMesh, graph: &NodeGraph, fixed: &[Option<f64>]) -> Equations {
let mut eq = vec![usize::MAX; mesh.labels.len()];
let mut node = Vec::new();
for n in nested_dissection(&mesh.xyz, &graph.adjacency) {
if fixed.get(n).is_some_and(Option::is_none)
&& let Some(e) = eq.get_mut(n)
{
*e = node.len();
node.push(n);
}
}
Equations { eq, node }
}
/// The free-free block of a node-level matrix, rows and columns in equation order.
fn reduce(&self, a: &Csr) -> Csr {
let mut ptr = vec![0];
let (mut idx, mut val) = (Vec::new(), Vec::new());
for &n in &self.node {
let (cols, vals) = a.row(n);
let mut row: Vec<(usize, f64)> =
cols.iter().zip(vals).filter_map(|(c, v)| self.eq.get(*c).filter(|e| **e != usize::MAX).map(|e| (*e, *v))).collect();
row.sort_unstable_by_key(|t| t.0);
for (c, v) in row {
idx.push(c);
val.push(v);
}
ptr.push(idx.len());
}
Csr { n: self.node.len(), ptr, idx, val }
}
/// Solve for the free temperatures with right-hand side `rhs` (over every node; its free
/// rows are used) and write them into `t`, whose prescribed entries are kept.
fn solve(&self, chol: &Factor, rhs: &[f64], q: &mut Vec<f64>, t: &mut [f64]) -> Result<()> {
q.clear();
q.extend(self.node.iter().map(|n| rhs.get(*n).copied().unwrap_or(0.0)));
chol.solve(q)?;
for (n, v) in self.node.iter().zip(q.iter()) {
if let Some(x) = t.get_mut(*n) {
*x = *v;
}
}
if t.iter().any(|x| !x.is_finite()) {
return Err(Error::Singular("the temperatures are not finite; the model is ill-conditioned".into()));
}
Ok(())
}
}
fn factor(a: &Csr, limits: &Limits, mesh: &HeatMesh, eqs: &Equations) -> Result<Factor> {
Factor::new(&UpperColumns::from_symmetric(a), limits.max_factor_entries).map_err(|e| match e {
FactorError::TooLarge { entries, limit } => {
Error::Limit(format!("the factorization needs {entries} entries; the limit is {limit} (refine less or raise the limit)"))
}
FactorError::NotPositiveDefinite { column } => {
let label = eqs.node.get(column).and_then(|n| mesh.labels.get(*n)).copied().unwrap_or(0);
Error::Singular(format!(
"the temperature is undetermined at node {label}: no temperature boundary condition or convection reaches its part of the model"
))
}
})
}
/// y = A x.
fn mul(a: &Csr, x: &[f64]) -> Vec<f64> {
let mut y = vec![0.0; a.n];
a.mul(x, &mut y);
y
}
/// The temperature history of a heat transfer or coupled step and what produced it.
struct Solution {
mesh: HeatMesh,
eqs: Equations,
k: Csr,
c: Option<Csr>,
f: Vec<f64>,
fixed: Vec<Option<f64>>,
times: Vec<f64>,
frames: Vec<Vec<f64>>,
/// Ṫ over the last increment (zero when steady).
rate: Vec<f64>,
factor_entries: usize,
}
impl Solution {
fn temperature(&self) -> &[f64] {
self.frames.last().map(Vec::as_slice).unwrap_or_default()
}
}
/// Solve the step's temperatures (steady unless the step is transient).
fn run(model: &Model, resolved: &Resolved, step: &Step, limits: &Limits) -> Result<Solution> {
let transient = match step.procedure {
Procedure::HeatTransfer { transient } => transient,
_ => None,
};
let mesh = HeatMesh::new(model, resolved, limits.max_dofs)?;
let graph = NodeGraph::new(mesh.labels.len(), mesh.elements.iter().map(|e| e.nodes.as_slice()));
let fixed = prescribed(model, resolved, &mesh, step)?;
let eqs = Equations::new(&mesh, &graph, &fixed);
if eqs.node.is_empty() {
return Err(Error::Invalid("every temperature is prescribed; there is nothing to solve".into()));
}
let HeatSystem { k, c, f } = assemble(model, resolved, &mesh, &graph, step, transient.is_some())?;
let n = mesh.labels.len();
let held: Vec<f64> = fixed.iter().map(|v| v.unwrap_or(0.0)).collect();
let mut q = Vec::with_capacity(eqs.node.len());
let (times, frames, rate, factor_entries) = match transient.zip(c.as_ref()) {
Some((tr, c)) => {
let steps = tr.increments();
let dt = tr.end_time / steps as f64;
// (C + θΔt K) Tₙ₊₁ = (C − (1 − θ)Δt K) Tₙ + Δt f, the prescribed values held from the
// first increment on (their part A_fp T_p moves to the right-hand side).
let a = c.add_scaled(tr.theta * dt, &k)?;
let b = c.add_scaled(-(1.0 - tr.theta) * dt, &k)?;
let chol = factor(&eqs.reduce(&a), limits, &mesh, &eqs)?;
let a_held = mul(&a, &held);
let every = (steps.saturating_mul(n) / MAX_TEMPERATURE_VALUES.max(1)).saturating_add(1);
let mut t = vec![tr.initial_temperature; n];
let (mut frames, mut times) = (vec![t.clone()], vec![0.0]);
let mut rhs = vec![0.0; n];
let mut next = held.clone();
for i in 1..=steps {
next.copy_from_slice(&held);
b.mul(&t, &mut rhs);
for ((r, fj), aj) in rhs.iter_mut().zip(&f).zip(&a_held) {
*r += dt * fj - aj;
}
eqs.solve(&chol, &rhs, &mut q, &mut next)?;
std::mem::swap(&mut t, &mut next);
if i % every == 0 || i == steps {
frames.push(t.clone());
times.push(if i == steps { tr.end_time } else { i as f64 * dt });
}
}
// After the swap `next` holds the state before the last increment.
let rate = t.iter().zip(&next).map(|(a, b)| (a - b) / dt).collect();
(times, frames, rate, chol.entries())
}
None => {
let rhs: Vec<f64> = f.iter().zip(mul(&k, &held)).map(|(a, b)| a - b).collect();
let chol = factor(&eqs.reduce(&k), limits, &mesh, &eqs)?;
let mut t = held;
eqs.solve(&chol, &rhs, &mut q, &mut t)?;
(vec![0.0], vec![t], vec![0.0; n], chol.entries())
}
};
Ok(Solution { mesh, eqs, k, c, f, fixed, times, frames, rate, factor_entries })
}
/// The steady temperatures of a coupled temperature–displacement step, by node label (sorted).
pub(crate) fn temperatures(model: &Model, resolved: &Resolved, step: &Step, limits: &Limits) -> Result<(Vec<u32>, Vec<f64>)> {
let mut s = run(model, resolved, step, limits)?;
let t = s.frames.pop().unwrap_or_default();
Ok((s.mesh.labels, t))
}
/// Solve a heat transfer step: the step named `step`, or the model's only one.
pub fn solve_heat(model: &Model, step: Option<&str>, limits: &Limits) -> Result<HeatResult> {
let resolved = model.resolve()?;
let step = model.select_step(step, |p| matches!(p, Procedure::HeatTransfer { .. }), "heat transfer")?;
let mut warnings: Vec<String> = resolved.warnings.iter().map(ToString::to_string).collect();
let s = run(model, &resolved, step, limits)?;
let t = s.temperature();
// Reactions: the heat each prescribed node supplies, K T + C Ṫ − f.
let kt = mul(&s.k, t);
let ct = s.c.as_ref().map(|c| mul(c, &s.rate));
let reaction: Vec<f64> = kt
.iter()
.zip(&s.f)
.zip(&s.fixed)
.enumerate()
.map(|(i, ((kt, f), fixed))| if fixed.is_some() { kt + ct.as_ref().and_then(|c| c.get(i)).copied().unwrap_or(0.0) - f } else { 0.0 })
.collect();
// Heat supplied: fluxes and sources, and convection at the end temperatures, Σ (f − K_film T)
// = Σ (f − K T) since conduction alone has zero row sums.
let applied_total: f64 = kt.iter().zip(&s.f).map(|(a, b)| b - a).sum();
let reaction_total: f64 = reaction.iter().sum();
let (flux, elements) = fluxes(&s.mesh, t)?;
// Measured against the gross heat flows, which cancel in the net sums.
let scale = reaction.iter().chain(&s.f).map(|v| v.abs()).sum::<f64>();
if s.c.is_none() && scale > 0.0 && (applied_total + reaction_total).abs() > 1e-6 * scale {
warnings.push(format!("heat supplied and removed differ by {:.3e}", (applied_total + reaction_total).abs()));
}
let stats = Stats { dofs: s.mesh.layout.dofs(), equations: s.eqs.node.len(), factor_entries: s.factor_entries, lanczos_vectors: 0 };
Ok(HeatResult {
step: step.name.clone(),
nodes: s.mesh.labels,
times: s.times,
frames: s.frames,
flux,
elements,
reaction,
applied_total,
reaction_total,
stats,
warnings,
})
}
/// The heat flux at the integration points and averaged at the nodes (one extrapolation per
/// element kind).
fn fluxes(mesh: &HeatMesh, t: &[f64]) -> Result<(Vec<[f64; 3]>, Vec<ElementFlux>)> {
let mut sum = vec![[0.0; 3]; mesh.labels.len()];
let mut count = vec![0u32; mesh.labels.len()];
let mut out = Vec::with_capacity(mesh.elements.len());
let mut extrapolations: Vec<(ElementKind, Extrapolation)> = Vec::new();
for e in &mesh.elements {
let kernel = e.kernel()?;
let at = |err| Error::element(e.label, err);
let te: Vec<f64> = e.nodes.iter().map(|n| t.get(*n).copied().unwrap_or(0.0)).collect();
let points = kernel.flux(e.conductivity, &te).map_err(at)?;
let padded: Vec<[f64; 6]> = points.iter().map(|p| [p.flux[0], p.flux[1], p.flux[2], 0.0, 0.0, 0.0]).collect();
let index = match extrapolations.iter().position(|(k, _)| *k == e.kind) {
Some(i) => i,
None => {
extrapolations.push((e.kind, extrapolation(kernel.ty, kernel.ty.full).map_err(at)?));
extrapolations.len() - 1
}
};
let nodal = extrapolations.get(index).map(|(_, x)| x.apply(&padded)).unwrap_or_default();
for (n, v) in e.nodes.iter().zip(&nodal) {
if let (Some(s), Some(c)) = (sum.get_mut(*n), count.get_mut(*n)) {
for k in 0..3 {
s[k] += v[k];
}
*c += 1;
}
}
out.push(ElementFlux { label: e.label, kind: e.kind, points });
}
let flux = sum.iter().zip(&count).map(|(s, c)| s.map(|v| if *c > 0 { v / f64::from(*c) } else { 0.0 })).collect();
Ok((flux, out))
}
+2
View File
@@ -27,6 +27,7 @@ pub mod cholesky;
pub mod constraints;
pub mod discrete;
pub mod eigen;
pub mod heat;
pub mod ordering;
pub mod sparse;
pub mod system;
@@ -35,6 +36,7 @@ pub use analysis::{
ElementStress, FrequencyOptions, FrequencyResult, Location, MassKind, Mode, PointResult, StaticResult, Stats, StepResult, solve_frequency,
solve_static,
};
pub use heat::{HeatResult, solve_heat};
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum Error {
+97 -11
View File
@@ -9,7 +9,7 @@
use modelcraft_fem::beam::{Beam, BeamSection};
use modelcraft_fem::dense::Matrix;
use modelcraft_fem::element::{Formulation, Solid};
use modelcraft_fem::element::{Formulation, Solid, ThermalStrain};
use modelcraft_fem::plane::{Plane, Theory};
use modelcraft_fem::shell::{Shell, ShellSection};
use modelcraft_fem::truss::{Truss, young_modulus};
@@ -35,6 +35,9 @@ pub struct Elem {
pub density: Option<f64>,
/// The 2D theory of an element of a 2D section; `None` for solids.
pub theory: Option<Theory>,
/// The expansion coefficients along X, Y, Z and the reference temperature, when the
/// material expands.
pub expansion: Option<([f64; 3], f64)>,
}
/// An element's kernel: a solid or a 2D element.
@@ -90,11 +93,20 @@ impl Kernel<'_> {
}
}
/// Integration-point stresses for nodal displacements given as 3-vectors.
pub fn stresses(&self, d: &Elasticity, u: &[[f64; 3]]) -> modelcraft_fem::Result<Vec<PointStress>> {
/// Integration-point stresses for nodal displacements given as 3-vectors, with a thermal
/// strain when given.
pub fn stresses_with(&self, d: &Elasticity, u: &[[f64; 3]], thermal: Option<&ThermalStrain>) -> modelcraft_fem::Result<Vec<PointStress>> {
match self {
Kernel::Solid(s) => s.stresses(d, u),
Kernel::Plane(p) => p.stresses(d, &u.iter().map(|v| [v[0], v[1]]).collect::<Vec<_>>()),
Kernel::Solid(s) => s.stresses_with(d, u, thermal),
Kernel::Plane(p) => p.stresses_with(d, &u.iter().map(|v| [v[0], v[1]]).collect::<Vec<_>>(), thermal),
}
}
/// The equivalent nodal forces of a thermal strain, as 3-vectors (Z zero for 2D).
pub fn thermal_load(&self, d: &Elasticity, thermal: &ThermalStrain) -> modelcraft_fem::Result<Vec<[f64; 3]>> {
match self {
Kernel::Solid(s) => s.thermal_load(d, thermal),
Kernel::Plane(p) => Ok(p.thermal_load(d, thermal)?.into_iter().map(|v| [v[0], v[1], 0.0]).collect()),
}
}
@@ -121,6 +133,12 @@ impl Elem {
};
k.map_err(|e| Error::element(self.label, e))
}
/// The element's thermal strain in a temperature field (nodes the field leaves out are at
/// the reference temperature); `None` when the material does not expand or nothing changes.
pub fn thermal_strain(&self, field: &[Option<f64>]) -> Option<ThermalStrain> {
thermal_strain(self.expansion, &self.nodes, field)
}
}
/// A truss element (rod2, rod3) ready for the kernel.
@@ -134,12 +152,29 @@ pub struct Bar {
pub young: f64,
pub area: f64,
pub density: Option<f64>,
/// The expansion coefficients along X, Y, Z and the reference temperature, when the
/// material expands.
pub expansion: Option<([f64; 3], f64)>,
}
impl Bar {
pub fn truss(&self) -> Result<Truss<'_>> {
Truss::new(self.kind, &self.xyz).map_err(|e| Error::element(self.label, e))
}
/// The member's thermal strain in a temperature field, as for [`Elem::thermal_strain`].
pub fn thermal_strain(&self, field: &[Option<f64>]) -> Option<ThermalStrain> {
thermal_strain(self.expansion, &self.nodes, field)
}
}
/// The thermal strain of an element of nodes `nodes` in a temperature field (nodes the field
/// leaves out are at the reference temperature); `None` when the material does not expand or
/// nothing changes.
fn thermal_strain(expansion: Option<([f64; 3], f64)>, nodes: &[usize], field: &[Option<f64>]) -> Option<ThermalStrain> {
let (alpha, reference) = expansion?;
let delta: Vec<f64> = nodes.iter().map(|n| field.get(*n).copied().flatten().map_or(0.0, |t| t - reference)).collect();
delta.iter().any(|d| *d != 0.0).then_some(ThermalStrain { alpha, delta })
}
/// What a shell or beam element is made of.
@@ -394,7 +429,8 @@ impl Mesh {
young_modulus(elastic).map_err(|e| Error::Invalid(format!("material `{}` of section `{name}`: {e}", section.material)))?;
for &label in &set.elements {
let el = model.element(label).ok_or_else(|| Error::Invalid(format!("element {label} is missing")))?;
trusses.push((label, el.kind, el.nodes.clone(), young, area, material.density));
let expansion = material.thermal.and_then(|t| t.expansion.map(|a| (a.axes(), t.reference_temperature)));
trusses.push((label, el.kind, el.nodes.clone(), young, area, material.density, expansion));
}
continue;
}
@@ -406,7 +442,8 @@ impl Mesh {
Some(_) => plane::formulation(el.kind, &section.formulation).map(|f| (f, ElementDofs::new(DofSet::PLANE))),
}
.map_err(|e| Error::element(label, e))?;
elements.push((label, el.kind, el.nodes.clone(), formulation, d, material.density, theory, dofs));
let expansion = material.thermal.and_then(|t| t.expansion.map(|a| (a.axes(), t.reference_temperature)));
elements.push((label, el.kind, el.nodes.clone(), formulation, d, material.density, theory, dofs, expansion));
}
}
if let (Some(_), Some(name)) = (planar, model.couplings().keys().next()) {
@@ -472,15 +509,15 @@ impl Mesh {
warnings,
discrete: crate::discrete::Discrete::default(),
};
for (label, kind, nodes, formulation, d, density, theory, _) in elements {
for (label, kind, nodes, formulation, d, density, theory, _, expansion) in elements {
let idx: Vec<usize> = nodes.iter().filter_map(|n| mesh.node(*n)).collect();
let exyz: Vec<[f64; 3]> = idx.iter().filter_map(|i| mesh.xyz.get(*i).copied()).collect();
mesh.elements.push(Elem { label, kind, nodes: idx, xyz: exyz, formulation, d, density, theory });
mesh.elements.push(Elem { label, kind, nodes: idx, xyz: exyz, formulation, d, density, theory, expansion });
}
for (label, kind, nodes, young, area, density) in trusses {
for (label, kind, nodes, young, area, density, expansion) in trusses {
let idx: Vec<usize> = nodes.iter().filter_map(|n| mesh.node(*n)).collect();
let exyz: Vec<[f64; 3]> = idx.iter().filter_map(|i| mesh.xyz.get(*i).copied()).collect();
mesh.trusses.push(Bar { label, kind, nodes: idx, xyz: exyz, young, area, density });
mesh.trusses.push(Bar { label, kind, nodes: idx, xyz: exyz, young, area, density, expansion });
}
for (label, kind, nodes, density, section) in structural {
let idx: Vec<usize> = nodes.iter().filter_map(|n| mesh.node(*n)).collect();
@@ -713,6 +750,9 @@ pub fn loads(model: &Model, resolved: &Resolved, mesh: &Mesh, step: &Step, activ
mesh.discrete.add_gravity(&mut f, *acceleration, mesh);
}
}
// Heat loads act in heat transfer; temperature fields through their thermal strain
// (add_thermal_loads).
Load::SurfaceFlux { .. } | Load::Convection { .. } | Load::HeatSource { .. } | Load::Temperature { .. } => {}
Load::BodyForce { set, vector } => {
for label in resolved.sets.get(set).map(|r| r.elements.as_slice()).unwrap_or_default() {
if let Some(t) = mesh.truss(*label) {
@@ -779,6 +819,52 @@ fn structural_body(f: &mut [f64], mesh: &Mesh, e: &Structural, b: [f64; 3]) -> R
Ok(())
}
/// The temperature field of a static step's temperature loads, per mesh node; `None` when the
/// step has none.
pub fn temperature_loads(model: &Model, resolved: &Resolved, mesh: &Mesh, step: &Step) -> Result<Option<Vec<Option<f64>>>> {
let mut field: Option<Vec<Option<f64>>> = None;
for name in &step.loads {
let Some(Load::Temperature { target, value }) = model.load(name) else { continue };
let field = field.get_or_insert_with(|| vec![None; mesh.labels.len()]);
for &label in resolved.target_nodes(target) {
let Some(n) = mesh.node(label) else { continue };
hold(field.get_mut(n), *value, |old| format!("node {label} is given both {old} and {value} (temperature load `{name}` and another)"))?;
}
}
Ok(field)
}
/// Set a node's value, refusing a different one already there (`conflict` names both).
pub(crate) fn hold(slot: Option<&mut Option<f64>>, value: f64, conflict: impl FnOnce(f64) -> String) -> Result<()> {
let slot = slot.ok_or_else(|| Error::Internal("node index".into()))?;
match *slot {
Some(old) if old != value => Err(Error::Invalid(conflict(old))),
_ => {
*slot = Some(value);
Ok(())
}
}
}
/// Add the equivalent nodal forces of the thermal strains of a temperature field.
pub fn add_thermal_loads(f: &mut [f64], mesh: &Mesh, field: &[Option<f64>]) -> Result<()> {
for e in &mesh.elements {
let Some(th) = e.thermal_strain(field) else { continue };
let forces = e.kernel()?.thermal_load(&e.d, &th).map_err(|err| Error::element(e.label, err))?;
for (node, v) in e.nodes.iter().zip(forces) {
add(f, &translations(mesh, *node)?, v);
}
}
for t in &mesh.trusses {
let Some(th) = t.thermal_strain(field) else { continue };
let forces = t.truss()?.thermal_load(t.young, t.area, &th).map_err(|err| Error::element(t.label, err))?;
for (node, v) in t.nodes.iter().zip(forces) {
add(f, &translations(mesh, *node)?, v);
}
}
Ok(())
}
/// The uniform force per volume a step's gravity and body forces put on each shell and beam
/// element ([`Mesh::structural`] order): beam end forces are recovered with it.
pub fn structural_body_forces(model: &Model, resolved: &Resolved, mesh: &Mesh, step: &Step) -> Result<Vec<[f64; 3]>> {
+73
View File
@@ -567,3 +567,76 @@ fn two_dimensional_results_are_deterministic_and_errors_name_the_problem() {
let e = solve_static(&g.model, None, &Limits::default()).unwrap_err().to_string();
assert!(e.contains("singular") && e.contains("node"), "{e}");
}
/// Give the material an expansion coefficient and heat every node by `dt` above the reference.
fn heat(g: &mut Grid, alpha: f64, dt: f64) {
let mut m = g.model.material("Mat").unwrap().clone();
m.thermal = Some(modelcraft_model::Thermal {
expansion: Some(modelcraft_model::Directional::Isotropic(alpha)),
reference_temperature: 20.0,
..Default::default()
});
g.model.replace_material("Mat", m).unwrap();
let nodes: Vec<u32> = g.param.keys().copied().collect();
g.model.add_set("hot", Set { nodes, ..Set::default() }).unwrap();
g.load("heat", Load::Temperature { target: Target::Set("hot".into()), value: 20.0 + dt });
}
#[test]
fn thermal_expansion_in_every_theory() {
// Uniform ΔT, held only by symmetry: plane stress and axisymmetric expand freely
// (u = αΔT x, no stress; εzz or the hoop strain αΔT), plane strain holds εzz = 0, so
// σzz = −EαΔT and the in-plane strain is (1 + ν)αΔT.
let (e, nu, alpha, dt) = (2e5, 0.3, 1.2e-5, 80.0);
for (kind, f, name) in variants() {
for section in [SectionKind::PlaneStress { thickness: 0.5 }, SectionKind::PlaneStrain { thickness: 0.5 }, SectionKind::Axisymmetric {}] {
let axisymmetric = matches!(section, SectionKind::Axisymmetric {});
let mut g = grid([2, 2], kind, |p| [0.5 + 2.0 * p[0] + 0.2 * p[1], p[1]]);
g.define(section, e, nu, None, f, Procedure::Static {});
heat(&mut g, alpha, dt);
g.symmetry("bottom", Symmetry::Y, |p| near(p[1], 0.0) && near(p[0], 0.0));
if !axisymmetric {
g.symmetry("left", Symmetry::X, |p| near(p[0], 0.0) && near(p[1], 0.0));
let corner = g.nodes_where(|p| near(p[0], 1.0) && near(p[1], 0.0));
g.model.add_set("roller", Set { nodes: corner, ..Set::default() }).unwrap();
g.bc("roller", BoundaryCondition::Symmetry { target: Target::Set("roller".into()), plane: Symmetry::Y });
}
let r = g.solve();
let plane_strain = matches!(section, SectionKind::PlaneStrain { .. });
let strain = if plane_strain { (1.0 + nu) * alpha * dt } else { alpha * dt };
for (l, i) in r.nodes.iter().zip(0..) {
let x = g.model.node(*l).unwrap().xyz;
let x0 = g.model.node(g.nodes_where(|p| near(p[0], 0.0) && near(p[1], 0.0))[0]).unwrap().xyz;
// Axisymmetric: the radial displacement is αΔT r (no rigid radial motion).
let ux = if axisymmetric { strain * x[0] } else { strain * (x[0] - x0[0]) };
assert!((r.displacement[i][0] - ux).abs() < 1e-9, "{name} {section:?} node {l}: {:?}", r.displacement[i]);
let uy = if axisymmetric { alpha * dt * x[1] } else { strain * x[1] };
assert!((r.displacement[i][1] - uy).abs() < 1e-9, "{name} {section:?} node {l}: {:?}", r.displacement[i]);
}
for s in &r.stress {
let zz = if plane_strain { -e * alpha * dt } else { 0.0 };
assert!((s[0].abs() + s[1].abs() + s[3].abs()) < 1e-6 && (s[2] - zz).abs() < 1e-6, "{name} {section:?}: {s:?}");
}
}
}
}
#[test]
fn a_plate_held_on_every_edge_carries_the_biaxial_thermal_stress() {
// Plane stress, normal displacements held on all four edges: σxx = σyy = −EαΔT / (1 − ν).
let (e, nu, alpha, dt) = (2e5, 0.3, 1.2e-5, 50.0);
let want = -e * alpha * dt / (1.0 - nu);
for (kind, f, name) in variants() {
let mut g = grid([2, 2], kind, |p| [p[0], p[1]]);
g.define(SectionKind::PlaneStress { thickness: 1.0 }, e, nu, None, f, Procedure::Static {});
heat(&mut g, alpha, dt);
g.symmetry("x0", Symmetry::X, |p| near(p[0], 0.0));
g.symmetry("x1", Symmetry::X, |p| near(p[0], 1.0));
g.symmetry("y0", Symmetry::Y, |p| near(p[1], 0.0));
g.symmetry("y1", Symmetry::Y, |p| near(p[1], 1.0));
let r = g.solve();
for s in &r.stress {
assert!((s[0] - want).abs() < 1e-8 * want.abs() && (s[1] - want).abs() < 1e-8 * want.abs() && s[3].abs() < 1e-6, "{name}: {s:?}");
}
}
}
+440
View File
@@ -0,0 +1,440 @@
//! Analytic verification of heat transfer and thermal stress (docs/fem.md, docs/elements.md):
//! steady conduction through a slab (prescribed temperatures, a heat source, convection),
//! radial conduction through a thick cylinder, transient conduction into a semi-infinite solid
//! (erfc), free and constrained thermal expansion, a bimetal strip (Timoshenko 1925) and the
//! coupled temperature–displacement step.
mod common;
use common::*;
use modelcraft_model::*;
use modelcraft_solver::{Limits, solve_heat, solve_static};
const K: f64 = 45.0;
/// Replace the grid's material with `elastic` plus thermal properties.
fn thermal(g: &mut Grid, elastic: Elastic, density: Option<f64>, t: Thermal) {
g.model.replace_material("Mat", Material { elastic: Some(elastic), density, thermal: Some(t), ..Material::default() }).unwrap();
}
fn conductor(k: f64) -> Thermal {
Thermal { conductivity: Some(Directional::Isotropic(k)), ..Thermal::default() }
}
fn hold(g: &mut Grid, name: &str, value: f64, f: impl Fn([f64; 3]) -> bool) {
g.node_set(name, f);
g.bc(name, BoundaryCondition::Temperature { target: Target::Set(name.into()), value });
}
fn temperature_at(r: &modelcraft_solver::HeatResult, label: u32) -> f64 {
r.temperature()[r.nodes.binary_search(&label).unwrap()]
}
/// Abramowitz & Stegun 7.1.26 is too coarse here; erfc by its continued fraction for large x and
/// the Taylor series for small x, both to ~1e-15.
fn erfc(x: f64) -> f64 {
if x < 2.0 {
// erf x = 2/√π Σ (−1)ⁿ x^(2n+1) / (n! (2n + 1)).
let mut sum = 0.0;
let mut term = x;
for n in 0..200 {
let t = term / (2 * n + 1) as f64;
sum += t;
term *= -x * x / (n + 1) as f64;
if t.abs() < 1e-17 {
break;
}
}
1.0 - 2.0 / std::f64::consts::PI.sqrt() * sum
} else {
// Lentz evaluation of erfc x = e^(−x²)/√π · 1/(x + ½/(x + 1/(x + 3/2/(x + …)))).
let mut f = x;
for n in (1..200).rev() {
f = x + n as f64 / 2.0 / f;
}
(-x * x).exp() / std::f64::consts::PI.sqrt() / f
}
}
#[test]
fn erfc_matches_reference_values() {
for (x, want) in [(0.0, 1.0), (0.5, 0.479_500_122_186_953_5), (1.0, 0.157_299_207_050_285_1), (2.5, 4.069_520_174_449_59e-4)] {
assert!((erfc(x) - want).abs() < 1e-13 * want.max(1e-3), "{x}: {} vs {want}", erfc(x));
}
}
#[test]
fn slab_steady_conduction_is_linear_with_the_fourier_flux() {
// A 4 × 1 × 1 slab, 20 at x = 0 and 100 at x = 4: T linear, q_x = −k ΔT / L everywhere.
for (kind, f, name) in variants() {
let mut g = grid([4, 1, 1], kind, |p| [4.0 * p[0], p[1], p[2]]);
g.define(iso(2e5, 0.3), None, f, Procedure::HeatTransfer { transient: None });
thermal(&mut g, iso(2e5, 0.3), None, conductor(K));
hold(&mut g, "cold", 20.0, |p| near(p[0], 0.0));
hold(&mut g, "hot", 100.0, |p| near(p[0], 1.0));
let r = solve_heat(&g.model, None, &Limits::default()).unwrap();
for (l, p) in &g.param {
let t = temperature_at(&r, *l);
assert!((t - (20.0 + 80.0 * p[0])).abs() < 1e-9, "{name} node {l}: {t}");
}
for e in &r.elements {
for p in &e.points {
assert!((p.flux[0] + K * 20.0).abs() < 1e-8 && p.flux[1].abs() < 1e-8 && p.flux[2].abs() < 1e-8, "{name}: {:?}", p.flux);
}
}
// The heat entering at the hot face leaves at the cold one: q A = k ΔT / L · 1.
let hot: f64 = g.nodes_where(|p| near(p[0], 1.0)).iter().map(|l| r.reaction[r.nodes.binary_search(l).unwrap()]).sum();
assert!((hot - K * 20.0).abs() < 1e-8, "{name}: {hot}");
assert!(r.reaction_total.abs() < 1e-8 && r.warnings.iter().all(|w| !w.contains("differ")), "{name}");
}
}
#[test]
fn slab_with_a_heat_source_and_convection() {
// Uniform source Q in a slab of length L held at 0 at x = 0, convecting to T∞ at x = L:
// T(x) = −Q x²/(2k) + a x with k a − Q L = h (T∞ − T(L)) from the energy balance at x = L,
// a = (Q L + h T∞ + h Q L²/(2k)) / (k + h L). Quadratic in x: exact at the nodes of the
// quadratic elements.
let (q, h, sink, l) = (300.0, 25.0, 60.0, 2.0);
let a = (q * l + h * sink + h * q * l * l / (2.0 * K)) / (K + h * l);
let exact = |x: f64| -q * x * x / (2.0 * K) + a * x;
for (kind, f, name) in variants().into_iter().filter(|v| matches!(v.0, ElementKind::Hex20 | ElementKind::Tet10)) {
let mut g = grid([4, 1, 1], kind, |p| [l * p[0], 0.5 * p[1], 0.5 * p[2]]);
g.define(iso(2e5, 0.3), None, f, Procedure::HeatTransfer { transient: None });
thermal(&mut g, iso(2e5, 0.3), None, conductor(K));
hold(&mut g, "base", 0.0, |p| near(p[0], 0.0));
g.face_set("end", |p| near(p[0], 1.0));
g.load("film", Load::Convection { set: "end".into(), film: h, sink });
g.load("source", Load::HeatSource { set: "all".into(), power: q });
let r = solve_heat(&g.model, None, &Limits::default()).unwrap();
for (lb, p) in &g.param {
let t = temperature_at(&r, *lb);
assert!((t - exact(l * p[0])).abs() < 1e-9 * exact(l).abs(), "{name} x = {}: {t} vs {}", l * p[0], exact(l * p[0]));
}
// Steady balance: what the source and the film supply leaves through the held face,
// (Q L + h (T∞ − T(L))) A.
let supplied = (q * l + h * (sink - exact(l))) * 0.25;
assert!((r.reaction_total + supplied).abs() < 1e-9 * supplied, "{name}: {} vs {supplied}", -r.reaction_total);
assert!((r.applied_total - supplied).abs() < 1e-9 * supplied, "{name}");
}
// A flux on a face instead of convection: T = −Q x²/(2k) + (Q L + q″) x / k.
let qs = 700.0;
let mut g = grid([4, 1, 1], ElementKind::Hex20, |p| [l * p[0], 0.5 * p[1], 0.5 * p[2]]);
g.define(iso(2e5, 0.3), None, full(), Procedure::HeatTransfer { transient: None });
thermal(&mut g, iso(2e5, 0.3), None, conductor(K));
hold(&mut g, "base", 0.0, |p| near(p[0], 0.0));
g.face_set("end", |p| near(p[0], 1.0));
g.load("flux", Load::SurfaceFlux { set: "end".into(), flux: qs });
g.load("source", Load::HeatSource { set: "all".into(), power: q });
let r = solve_heat(&g.model, None, &Limits::default()).unwrap();
for (lb, p) in &g.param {
let x = l * p[0];
let want = -q * x * x / (2.0 * K) + (q * l + qs) * x / K;
assert!((temperature_at(&r, *lb) - want).abs() < 1e-9 * want.abs().max(1.0));
}
}
#[test]
fn thick_cylinder_radial_conduction() {
// A quarter of a hollow cylinder r = 1..2 held at 100 inside and 0 outside:
// T = 100 (1 − ln r / ln 2). The cut faces are insulated by symmetry.
for (kind, n, tol) in [(ElementKind::Hex20, [6, 6, 1], 2e-4), (ElementKind::Hex8, [16, 16, 1], 2e-3)] {
let mut g = grid(n, kind, |p| {
let (r, th) = (1.0 + p[0], std::f64::consts::FRAC_PI_2 * p[1]);
[r * th.cos(), r * th.sin(), 0.3 * p[2]]
});
g.define(iso(2e5, 0.3), None, full(), Procedure::HeatTransfer { transient: None });
thermal(&mut g, iso(2e5, 0.3), None, conductor(K));
hold(&mut g, "inner", 100.0, |p| near(p[0], 0.0));
hold(&mut g, "outer", 0.0, |p| near(p[0], 1.0));
let r = solve_heat(&g.model, None, &Limits::default()).unwrap();
let mut worst: f64 = 0.0;
for (l, p) in &g.param {
let rad = 1.0 + p[0];
worst = worst.max((temperature_at(&r, *l) - 100.0 * (1.0 - rad.ln() / 2f64.ln())).abs());
}
assert!(worst < tol * 100.0, "{kind:?}: {worst}");
// The heat flow through the quarter: Q = (π/2) k L ΔT / ln 2.
let q_exact = std::f64::consts::FRAC_PI_2 * K * 0.3 * 100.0 / 2f64.ln();
let q_in: f64 = g.nodes_where(|p| near(p[0], 0.0)).iter().map(|l| r.reaction[r.nodes.binary_search(l).unwrap()]).sum::<f64>().abs();
assert!((q_in - q_exact).abs() < 10.0 * tol * q_exact, "{kind:?}: {q_in} vs {q_exact}");
}
}
#[test]
fn transient_conduction_into_a_semi_infinite_solid() {
// A long bar at 0 whose end x = 0 is held at 1 from t = 0: T = erfc(x / (2√(κt))), κ = k/(ρc),
// until the front reaches the far end (here 2√(κt) ≤ L/4).
let (rho, c, length): (f64, f64, f64) = (7.8, 0.46, 10.0);
let kappa = K / (rho * c);
let end = (length / 8.0).powi(2) / kappa;
for (theta, increments) in [(0.5, 200usize), (1.0, 800)] {
let mut g = grid([100, 1, 1], ElementKind::Hex8, |p| [length * p[0], 0.1 * p[1], 0.1 * p[2]]);
let tr = Transient { end_time: end, increment: end / increments as f64, theta, initial_temperature: 0.0 };
g.define(iso(2e5, 0.3), Some(rho), full(), Procedure::HeatTransfer { transient: Some(tr) });
thermal(&mut g, iso(2e5, 0.3), Some(rho), Thermal { specific_heat: Some(c), ..conductor(K) });
hold(&mut g, "end", 1.0, |p| near(p[0], 0.0));
let r = solve_heat(&g.model, None, &Limits::default()).unwrap();
assert_eq!(*r.times.last().unwrap(), end);
let mut worst: f64 = 0.0;
for (l, p) in &g.param {
let x = length * p[0];
worst = worst.max((temperature_at(&r, *l) - erfc(x / (2.0 * (kappa * end).sqrt()))).abs());
}
assert!(worst < 5e-3, "θ = {theta}: {worst}");
// Frames start at the initial temperature.
assert!(r.frames[0].iter().all(|t| *t == 0.0) && r.times[0] == 0.0);
}
}
/// A 4 × 1 × 1 bar of `kind` heated by `dt` through a temperature load, held as `hold` says.
fn heated_bar(kind: ElementKind, f: Formulation, alpha: Directional, dt: f64, poisson: f64) -> Grid {
let mut g = grid([4, 1, 1], kind, |p| [4.0 * p[0], p[1], p[2]]);
g.define(iso(2e5, poisson), None, f, Procedure::Static {});
thermal(&mut g, iso(2e5, poisson), None, Thermal { expansion: Some(alpha), reference_temperature: 20.0, ..Thermal::default() });
g.node_set("everything", |_| true);
g.load("heat", Load::Temperature { target: Target::Set("everything".into()), value: 20.0 + dt });
g
}
#[test]
fn free_thermal_expansion_is_stress_free() {
// Held only against rigid motion (3-2-1 by symmetry planes through a corner): ΔL = α L ΔT
// along each axis, no stress.
let alpha = [1.2e-5, 2.0e-5, 0.8e-5];
for (kind, f, name) in variants() {
let mut g = heated_bar(kind, f, Directional::Orthotropic(alpha), 80.0, 0.3);
g.symmetry("x0", Symmetry::X, |p| near(p[0], 0.0));
g.symmetry("y0", Symmetry::Y, |p| near(p[1], 0.0));
g.symmetry("z0", Symmetry::Z, |p| near(p[2], 0.0));
let r = solve_static(&g.model, None, &Limits::default()).unwrap();
for (i, l) in r.nodes.iter().enumerate() {
let x = g.model.node(*l).unwrap().xyz;
for k in 0..3 {
assert!((r.displacement[i][k] - alpha[k] * 80.0 * x[k]).abs() < 1e-12, "{name} node {l}");
}
assert!(r.stress[i].iter().all(|s| s.abs() < 1e-6), "{name}: {:?}", r.stress[i]);
}
assert!(r.reaction.iter().flatten().all(|v| v.abs() < 1e-6), "{name}");
}
}
#[test]
fn constrained_bar_carries_minus_e_alpha_dt() {
// Both ends held axially, the sides free: σ_xx = −E α ΔT, the others zero, for any ν.
for (kind, f, name) in variants() {
let mut g = heated_bar(kind, f, Directional::Isotropic(1.2e-5), 50.0, 0.3);
g.symmetry("x0", Symmetry::X, |p| near(p[0], 0.0));
g.symmetry("x1", Symmetry::X, |p| near(p[0], 1.0));
g.symmetry("y0", Symmetry::Y, |p| near(p[1], 0.0));
g.symmetry("z0", Symmetry::Z, |p| near(p[2], 0.0));
let r = solve_static(&g.model, None, &Limits::default()).unwrap();
let want = -2e5 * 1.2e-5 * 50.0;
for s in &r.stress {
assert!((s[0] - want).abs() < 1e-8 * want.abs(), "{name}: {s:?}");
assert!(s[1..].iter().all(|v| v.abs() < 1e-8 * want.abs()), "{name}: {s:?}");
}
// The ends push back with σ A.
let rx: f64 = g.nodes_where(|p| near(p[0], 1.0)).iter().map(|l| r.reaction[r.nodes.binary_search(l).unwrap()][0]).sum();
assert!((rx - want).abs() < 1e-8 * want.abs(), "{name}: {rx}");
}
}
#[test]
fn bimetal_strip_bends_as_timoshenko_says() {
// Two equal layers (thickness t each, same E, ν = 0) with α₁, α₂, heated by ΔT, clamped at
// x = 0: curvature κ = 3 Δα ΔT / (2 h) with h = 2t (Timoshenko 1925, m = n = 1), tip rise
// κ L² / 2.
let (len, t, dt, a1, a2) = (20.0, 0.5, 100.0, 1.0e-5, 2.0e-5);
let mut g = grid([20, 1, 4], ElementKind::Hex20, |p| [len * p[0], 1.0 * p[1], 2.0 * t * p[2]]);
let all: Vec<u32> = g.model.elements().keys().copied().collect();
let (bottom, top): (Vec<u32>, Vec<u32>) =
all.into_iter().partition(|e| g.model.element(*e).unwrap().nodes.iter().all(|n| g.param[n][2] <= 0.5 + 1e-12));
g.model.add_set("bottom", Set { elements: bottom, ..Set::default() }).unwrap();
g.model.add_set("top", Set { elements: top, ..Set::default() }).unwrap();
for (name, alpha) in [("Low", a1), ("High", a2)] {
g.model
.add_material(
name,
Material {
elastic: Some(iso(2e5, 0.0)),
density: None,
thermal: Some(Thermal { expansion: Some(Directional::Isotropic(alpha)), ..Thermal::default() }),
..Material::default()
},
)
.unwrap();
}
g.model.add_section("low", Section { set: "bottom".into(), material: "Low".into(), kind: SectionKind::Solid {}, formulation: full() }).unwrap();
g.model.add_section("high", Section { set: "top".into(), material: "High".into(), kind: SectionKind::Solid {}, formulation: full() }).unwrap();
g.model.add_step(Step::new("Step", Procedure::Static {}), None).unwrap();
g.fixed("root", |p| near(p[0], 0.0));
g.node_set("everything", |_| true);
g.load("heat", Load::Temperature { target: Target::Set("everything".into()), value: dt });
let r = solve_static(&g.model, None, &Limits::default()).unwrap();
let kappa = 1.5 * (a2 - a1) * dt / (2.0 * t);
let want = -kappa * len * len / 2.0;
// The tip's mid-height nodes (the high-α layer on top lengthens, so the strip curls down).
let tip: Vec<f64> = g
.param
.iter()
.filter(|(_, p)| near(p[0], 1.0) && near(p[2], 0.5))
.map(|(l, _)| r.displacement[r.nodes.binary_search(l).unwrap()][2])
.collect();
assert!(!tip.is_empty());
for w in tip {
assert!((w - want).abs() < 0.02 * want.abs(), "{w} vs {want}");
}
}
#[test]
fn coupled_step_matches_heat_then_static() {
// A slab held at 20 and 100 at its ends, free to expand (3-2-1): the coupled step gives the
// heat step's temperatures and the static step's displacements for that field.
let (alpha, tref) = (1.2e-5, 20.0);
let build = |procedure: Procedure| {
let mut g = grid([4, 1, 1], ElementKind::Hex20, |p| [4.0 * p[0], p[1], p[2]]);
g.define(iso(2e5, 0.3), None, full(), procedure);
let t = Thermal { expansion: Some(Directional::Isotropic(alpha)), reference_temperature: tref, ..conductor(K) };
thermal(&mut g, iso(2e5, 0.3), None, t);
g
};
let mut c = build(Procedure::CoupledTemperatureDisplacement {});
hold(&mut c, "cold", 20.0, |p| near(p[0], 0.0));
hold(&mut c, "hot", 100.0, |p| near(p[0], 1.0));
c.symmetry("x0", Symmetry::X, |p| near(p[0], 0.0));
c.symmetry("y0", Symmetry::Y, |p| near(p[1], 0.0));
c.symmetry("z0", Symmetry::Z, |p| near(p[2], 0.0));
let r = solve_static(&c.model, None, &Limits::default()).unwrap();
let temps = r.temperature.clone().unwrap();
for (i, l) in r.nodes.iter().enumerate() {
let x = c.model.node(*l).unwrap().xyz;
assert!((temps[i] - (20.0 + 20.0 * x[0])).abs() < 1e-9);
}
// The heat step alone gives the same temperatures.
let mut h = build(Procedure::HeatTransfer { transient: None });
hold(&mut h, "cold", 20.0, |p| near(p[0], 0.0));
hold(&mut h, "hot", 100.0, |p| near(p[0], 1.0));
let hr = solve_heat(&h.model, None, &Limits::default()).unwrap();
assert_eq!(hr.temperature(), temps.as_slice());
// A static step with the same field as temperature loads (one per plane x = const) gives the
// same displacements and stresses.
let mut s = build(Procedure::Static {});
s.symmetry("x0", Symmetry::X, |p| near(p[0], 0.0));
s.symmetry("y0", Symmetry::Y, |p| near(p[1], 0.0));
s.symmetry("z0", Symmetry::Z, |p| near(p[2], 0.0));
for k in 0..=8 {
let name = format!("plane{k}");
s.node_set(&name, |p| near(p[0], k as f64 / 8.0));
s.load(&name, Load::Temperature { target: Target::Set(name.clone()), value: 20.0 + 20.0 * 4.0 * k as f64 / 8.0 });
}
let sr = solve_static(&s.model, None, &Limits::default()).unwrap();
let scale = sr.displacement.iter().flatten().fold(0.0f64, |m, v| m.max(v.abs()));
assert!(scale > 0.0);
for (a, b) in r.displacement.iter().flatten().zip(sr.displacement.iter().flatten()) {
assert!((a - b).abs() < 1e-12 * scale, "{a} vs {b}");
}
for (a, b) in r.stress.iter().flatten().zip(sr.stress.iter().flatten()) {
assert!((a - b).abs() < 1e-6, "{a} vs {b}");
}
assert!(sr.temperature.is_none());
}
#[test]
fn heat_steps_refuse_what_they_cannot_solve() {
// Nothing holds the temperature level.
let mut g = grid([2, 1, 1], ElementKind::Hex8, |p| p);
g.define(iso(2e5, 0.3), None, full(), Procedure::HeatTransfer { transient: None });
thermal(&mut g, iso(2e5, 0.3), None, conductor(K));
g.face_set("end", |p| near(p[0], 1.0));
g.load("flux", Load::SurfaceFlux { set: "end".into(), flux: 10.0 });
let e = solve_heat(&g.model, None, &Limits::default()).unwrap_err().to_string();
assert!(e.contains("undetermined"), "{e}");
// No conductivity: the model check says so.
let mut g = grid([2, 1, 1], ElementKind::Hex8, |p| p);
g.define(iso(2e5, 0.3), None, full(), Procedure::HeatTransfer { transient: None });
hold(&mut g, "end", 1.0, |p| near(p[0], 0.0));
let e = solve_heat(&g.model, None, &Limits::default()).unwrap_err().to_string();
assert!(e.contains("conductivity"), "{e}");
// Transient without a specific heat.
let mut g = grid([2, 1, 1], ElementKind::Hex8, |p| p);
g.define(iso(2e5, 0.3), Some(1.0), full(), Procedure::HeatTransfer { transient: Some(Transient::new(1.0, 0.1)) });
thermal(&mut g, iso(2e5, 0.3), Some(1.0), conductor(K));
hold(&mut g, "end", 1.0, |p| near(p[0], 0.0));
let e = solve_heat(&g.model, None, &Limits::default()).unwrap_err().to_string();
assert!(e.contains("specific heat"), "{e}");
// Conflicting prescribed temperatures.
let mut g = grid([2, 1, 1], ElementKind::Hex8, |p| p);
g.define(iso(2e5, 0.3), None, full(), Procedure::HeatTransfer { transient: None });
thermal(&mut g, iso(2e5, 0.3), None, conductor(K));
hold(&mut g, "a", 1.0, |p| near(p[0], 0.0));
hold(&mut g, "b", 2.0, |p| near(p[0], 0.0));
assert!(solve_heat(&g.model, None, &Limits::default()).is_err());
// A hard cap on the unknowns.
let mut g = grid([2, 1, 1], ElementKind::Hex8, |p| p);
g.define(iso(2e5, 0.3), None, full(), Procedure::HeatTransfer { transient: None });
thermal(&mut g, iso(2e5, 0.3), None, conductor(K));
hold(&mut g, "a", 1.0, |p| near(p[0], 0.0));
let tight = Limits { max_dofs: 3, ..Limits::default() };
assert!(solve_heat(&g.model, None, &tight).unwrap_err().to_string().contains("limit"));
// A static step is not a heat step.
let g = heated_bar(ElementKind::Hex8, full(), Directional::Isotropic(1e-5), 10.0, 0.3);
assert!(solve_heat(&g.model, None, &Limits::default()).is_err());
}
/// One rod3 or two rod2 along X over [0, `len`], heated by `dt`, fixed at x = 0, the other
/// nodes held in Y and Z, and the end held along X too when `held`.
fn heated_rod(kind: ElementKind, len: f64, alpha: [f64; 3], dt: f64, held: bool) -> modelcraft_solver::StaticResult {
let mut m = Model::default();
for (label, x) in [(1, 0.0), (2, len), (3, 0.5 * len)] {
m.set_node(label, [x, 0.0, 0.0]).unwrap();
}
// One rod3, or two rod2 in a row.
let elements = if kind == ElementKind::Rod3 {
m.set_element(1, kind, vec![1, 2, 3]).unwrap();
vec![1]
} else {
m.set_element(1, kind, vec![1, 3]).unwrap();
m.set_element(2, kind, vec![3, 2]).unwrap();
vec![1, 2]
};
m.add_set("rod", Set { elements, nodes: vec![1, 2, 3], ..Set::default() }).unwrap();
m.add_set("a", Set { nodes: vec![1], ..Set::default() }).unwrap();
m.add_set("b", Set { nodes: vec![2], ..Set::default() }).unwrap();
m.add_set("c", Set { nodes: vec![3], ..Set::default() }).unwrap();
let thermal = Thermal { expansion: Some(Directional::Orthotropic(alpha)), ..Thermal::default() };
m.add_material("Mat", Material { elastic: Some(iso(2e5, 0.3)), thermal: Some(thermal), ..Material::default() }).unwrap();
m.add_section("rod", Section { set: "rod".into(), material: "Mat".into(), kind: SectionKind::Rod { area: 4.0 }, formulation: full() }).unwrap();
let mut step = Step::new("Step", Procedure::Static {});
let lateral = [None, Some(0.0), Some(0.0)];
for (name, v) in [("a", [Some(0.0); 3]), ("b", if held { [Some(0.0); 3] } else { lateral }), ("c", lateral)] {
let values = [v[0], v[1], v[2], None, None, None];
m.add_boundary_condition(name, BoundaryCondition::Displacement { target: Target::Set(name.into()), values }).unwrap();
step.boundary_conditions.insert(name.into());
}
m.add_load("warm", Load::Temperature { target: Target::Set("rod".into()), value: dt }).unwrap();
step.loads.insert("warm".into());
m.add_step(step, None).unwrap();
solve_static(&m, None, &Limits::default()).unwrap()
}
#[test]
fn trusses_take_thermal_strain_along_their_axis() {
// Along X the member's thermal strain is αₓ ΔT (the projection Σ αₖ tₖ² is checked on the
// kernel): free it lengthens by αₓ ΔT L stress-free; held at both ends it carries −E αₓ ΔT.
let alpha = [1.0e-5, 2.0e-5, 3.0e-5];
let (e, area, dt, len) = (2e5, 4.0, 50.0, 3.0);
let sigma = -e * alpha[0] * dt;
for kind in [ElementKind::Rod2, ElementKind::Rod3] {
let r = heated_rod(kind, len, alpha, dt, true);
for p in r.elements.iter().flat_map(|el| &el.points) {
assert!((p.stress[0] - sigma).abs() < 1e-9 * sigma.abs(), "{kind:?}: {:?}", p.stress);
}
let reaction = r.reaction[r.nodes.binary_search(&2).unwrap()][0];
assert!((reaction - sigma * area).abs() < 1e-9 * (sigma * area).abs(), "{kind:?}: {reaction}");
let r = heated_rod(kind, len, alpha, dt, false);
let u = r.displacement[r.nodes.binary_search(&2).unwrap()][0];
assert!((u - alpha[0] * dt * len).abs() < 1e-12, "{kind:?}: {u}");
assert!(r.elements.iter().flat_map(|el| &el.points).all(|p| p.stress[0].abs() < 1e-9));
}
}