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

+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]>> {