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

+15 -1
View File
@@ -311,6 +311,17 @@ fn update(mat: &Material, es: &mut ElementState, q: &Mat3, d: &[f64; 6], vol: f6
}
}
/// The mechanical part of a strain increment: less the thermal strain increment `thermal` in
/// every normal direction (isotropic expansion, the same in any frame).
fn mechanical(mut d: [f64; 6], thermal: f64) -> [f64; 6] {
if thermal != 0.0 {
for x in d.iter_mut().take(3) {
*x -= thermal;
}
}
d
}
/// The density and dilatational wave speed of an element of rest volume `rest`, now of volume
/// `volume`.
fn current(mat: &Material, rest: f64, volume: f64) -> (f64, f64) {
@@ -386,6 +397,7 @@ pub fn internal_forces(sys: &System, m: &Motion, state: &mut State, ws: &mut Wor
let g = mid.gradient(&e.du);
let q = rotation_of(sys, mat, es, &g, h.label, m.time)?;
let (d, _) = rotation::split(&g);
let d = mechanical(d, h.thermal_rate * m.dt);
let (rho, c) = current(mat, h.kinematics.volume, now.volume);
update(mat, es, &q, &d, d[0] + d[1] + d[2], m.dt, rho, mid.volume);
if let Some(failure) = es.failed {
@@ -466,8 +478,10 @@ pub fn internal_forces(sys: &System, m: &Motion, state: &mut State, ws: &mut Wor
let averaged = t.nodes.iter().map(|n| ws.theta.get(*n).copied().unwrap_or(0.0)).sum::<f64>() / 4.0;
let q = rotation_of(sys, mat, es, &inc.g, t.label, m.time)?;
let (d, _) = rotation::split(&inc.g);
let thermal = t.thermal_rate * m.dt;
let d = mechanical(d, thermal);
let (rho, c) = current(mat, t.kinematics.volume, inc.now.volume);
update(mat, es, &q, &d, averaged, m.dt, rho, inc.mid.volume);
update(mat, es, &q, &d, averaged - 3.0 * thermal, m.dt, rho, inc.mid.volume);
if let Some(failure) = es.failed {
state.eroded.push((t.index, failure));
continue;
+79 -2
View File
@@ -39,6 +39,9 @@ pub struct Material {
pub j2: Option<J2>,
/// The equivalent plastic strain at which elements erode.
pub erosion: Option<f64>,
/// The (isotropic) expansion coefficient and reference temperature, when the material
/// expands.
pub expansion: Option<(f64, f64)>,
}
/// A one-point hexahedron.
@@ -58,6 +61,8 @@ pub struct Hex {
/// The element's stable length: its critical increment without bulk viscosity is
/// `length / wave_speed`.
pub length: f64,
/// The rate of its thermal strain, α ΔT / end time (every normal direction).
pub thermal_rate: f64,
}
/// A nodal-pressure tetrahedron.
@@ -72,6 +77,8 @@ pub struct Tet {
/// The frequency bound ω² of the undeformed element.
pub omega2: f64,
pub length: f64,
/// The rate of its thermal strain, α ΔT / end time (every normal direction).
pub thermal_rate: f64,
}
/// A fixed, frictionless rigid plane.
@@ -145,8 +152,14 @@ fn material(model: &Model, name: &str) -> Result<Material> {
}
Elastic::EngineeringConstants { .. } => None,
};
let expansion = match m.thermal.and_then(|t| t.expansion.map(|a| (a.axes(), t.reference_temperature))) {
None => None,
Some(([a, b, c], t_ref)) if a == b && b == c => Some((a, t_ref)),
Some(_) => return Err(Error::NotSupported(format!("orthotropic thermal expansion (material `{name}`) in explicit dynamics"))),
};
Ok(Material {
name: name.to_string(),
expansion,
elasticity: d,
density,
wave_speed: (stiffest / density).sqrt(),
@@ -278,7 +291,7 @@ impl System {
let rest = hex_scales(&k, &gamma, &mass, mat, rho, settings.hourglass);
sys.lump(&nodes, &mass);
let length = 2.0 * c / rest.omega2.sqrt();
sys.hexes.push(Hex { label, index, nodes, material: mi, kinematics: k, gamma, mass, rest, length });
sys.hexes.push(Hex { label, index, nodes, material: mi, kinematics: k, gamma, mass, rest, length, thermal_rate: 0.0 });
}
ElementKind::Tet4 => {
let nodes: [usize; 4] = idx.try_into().map_err(|_| invalid(format!("element {label} does not have 4 nodes")))?;
@@ -287,7 +300,17 @@ impl System {
let mass = k.nodal_volume.map(|v| rho * v);
let omega2 = frequency_bound(&k, k.gradient_norm(), mat.modulus, &mass);
sys.lump(&nodes, &mass);
sys.tets.push(Tet { label, index, nodes, material: mi, kinematics: k, mass, omega2, length: 2.0 * c / omega2.sqrt() });
sys.tets.push(Tet {
label,
index,
nodes,
material: mi,
kinematics: k,
mass,
omega2,
length: 2.0 * c / omega2.sqrt(),
thermal_rate: 0.0,
});
}
ElementKind::Rod2 | ElementKind::Rod3 => {
let area = area.ok_or_else(|| Error::Internal("truss area".into()))?;
@@ -319,6 +342,7 @@ impl System {
sys.discrete_increment = sys.discrete.critical_increment(&sys.dof_mass(), sys.nonlinear);
sys.prescribe(model, resolved, step, settings.end_time)?;
sys.load(model, resolved, step)?;
sys.temperatures(model, resolved, step, settings.end_time)?;
sys.conditions(resolved, settings)?;
Ok(sys)
}
@@ -355,6 +379,9 @@ impl System {
if let Target::ReferencePoint(rp) = bc.target() {
return Err(Error::NotSupported(format!("boundary conditions on reference points (`{name}` on `{rp}`)")));
}
if bc.is_thermal() {
return Err(Error::NotSupported(format!("temperature boundary conditions (`{name}`) in explicit dynamics")));
}
for &label in resolved.target_nodes(bc.target()) {
let Some(node) = self.node(label) else { continue };
for kind in self.layout.kinds(node).iter() {
@@ -380,6 +407,51 @@ impl System {
Ok(())
}
/// The thermal strain rates of the step's temperature loads: each element's mean nodal
/// temperature change from its material's reference (nodes no load reaches stay at it) is
/// reached linearly over the step, like a prescribed displacement, so the thermal strain
/// α ΔT grows at the rate α ΔT / end time (no thermal shock at the start).
fn temperatures(&mut self, model: &Model, resolved: &Resolved, step: &Step, end_time: f64) -> Result<()> {
let mut field: Vec<Option<f64>> = vec![None; self.labels.len()];
let mut any = false;
for name in &step.loads {
let Some(Load::Temperature { target, value }) = model.load(name) else { continue };
any = true;
for &label in resolved.target_nodes(target) {
let Some(slot) = self.node(label).and_then(|n| field.get_mut(n)) else { continue };
match *slot {
Some(old) if old != *value => {
return Err(invalid(format!("node {label} is given both {old} and {value} (temperature load `{name}` and another)")));
}
_ => *slot = Some(*value),
}
}
}
if !any {
return Ok(());
}
if !self.rods.is_empty() {
self.warnings
.push("trusses take no thermal strain in explicit dynamics yet; the temperature loads strain the solid elements only".into());
}
let materials = &self.materials;
let rate = |nodes: &[usize], material: usize| -> f64 {
let Some((alpha, reference)) = materials.get(material).and_then(|m| m.expansion) else { return 0.0 };
let dt: f64 = nodes.iter().map(|n| field.get(*n).copied().flatten().map_or(0.0, |t| t - reference)).sum::<f64>() / nodes.len() as f64;
alpha * dt / end_time
};
for h in &mut self.hexes {
h.thermal_rate = rate(&h.nodes, h.material);
}
for t in &mut self.tets {
t.thermal_rate = rate(&t.nodes, t.material);
}
if self.hexes.iter().map(|h| h.thermal_rate).chain(self.tets.iter().map(|t| t.thermal_rate)).any(|r| !r.is_finite()) {
return Err(invalid("the thermal strain rate is not finite"));
}
Ok(())
}
/// The constant load vector: forces and tractions as the model distributes them, consistent
/// pressure loads, gravity and body forces with the lumped (row-sum) weights.
fn load(&mut self, model: &Model, resolved: &Resolved, step: &Step) -> Result<()> {
@@ -394,6 +466,11 @@ impl System {
Load::Moment { .. } => {
return Err(Error::NotSupported(format!("moments (`{name}`): solid nodes have no rotations")));
}
Load::SurfaceFlux { .. } | Load::Convection { .. } | Load::HeatSource { .. } => {
return Err(Error::NotSupported(format!("{} loads (`{name}`) in explicit dynamics", load.kind())));
}
// Thermal strains, set by `temperatures`.
Load::Temperature { .. } => {}
Load::Force { .. } | Load::Traction { .. } => {
for (label, v) in resolved.nodal_loads.get(name).map(Vec::as_slice).unwrap_or_default() {
let node = self.node(*label).ok_or_else(|| off_mesh(*label))?;
+140
View File
@@ -0,0 +1,140 @@
//! Thermal strain in explicit dynamics: a temperature load reached slowly over the step expands
//! a free bar by α ΔT L without stress; a bar held at both ends carries σ = −E α ΔT, and with
//! perfectly plastic J2 the stress stops at the yield stress.
use modelcraft_explicit::{Limits, solve_explicit};
use modelcraft_model::*;
const YOUNG: f64 = 1.0e4;
const ALPHA: f64 = 1.0e-4;
const DT: f64 = 20.0;
const LENGTH: f64 = 4.0;
/// A 4 × 1 × 1 bar of hex8 (one-point) or Kuhn-split tet4 cells, ν = 0, heated by DT over a
/// step much longer than the wave transit, held at x = 0 (and at x = L when `clamped`) along x
/// and on the planes y = 0, z = 0.
fn bar(kind: ElementKind, clamped: bool, plastic: Option<Plastic>) -> Model {
let mut m = Model::default();
let n = 4usize;
let label = |i: usize, j: usize, k: usize| (1 + i + (n + 1) * (j + 2 * k)) as u32;
for k in 0..2 {
for j in 0..2 {
for i in 0..=n {
m.set_node(label(i, j, k), [i as f64, j as f64, k as f64]).unwrap();
}
}
}
let corners = [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0], [0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]];
let mut next = 1;
for i in 0..n {
let at = |c: [usize; 3]| label(i + c[0], c[1], c[2]);
if kind == ElementKind::Hex8 {
m.set_element(next, kind, corners.iter().map(|c| at(*c)).collect()).unwrap();
next += 1;
continue;
}
for perm in [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]] {
let mut v = [[0usize; 3]; 4];
for s in 0..3 {
v[s + 1] = v[s];
v[s + 1][perm[s]] = 1;
}
let x = v.map(|c| c.map(|k| k as f64));
let e = |a: usize| [0, 1, 2].map(|k| x[a][k] - x[0][k]);
let (a, b, c) = (e(1), e(2), e(3));
let vol = a[0] * (b[1] * c[2] - b[2] * c[1]) - a[1] * (b[0] * c[2] - b[2] * c[0]) + a[2] * (b[0] * c[1] - b[1] * c[0]);
if vol < 0.0 {
v.swap(1, 2);
}
m.set_element(next, kind, v.iter().map(|c| at(*c)).collect()).unwrap();
next += 1;
}
}
let all: Vec<u32> = m.elements().keys().copied().collect();
let nodes: Vec<u32> = m.nodes().keys().copied().collect();
let pick = |f: &dyn Fn([f64; 3]) -> bool| -> Vec<u32> { nodes.iter().copied().filter(|l| f(m.node(*l).unwrap().xyz)).collect() };
let (x0, x1, y0, z0) = (pick(&|p| p[0] == 0.0), pick(&|p| p[0] == LENGTH), pick(&|p| p[1] == 0.0), pick(&|p| p[2] == 0.0));
m.add_set("all", Set { elements: all, nodes: nodes.clone(), ..Set::default() }).unwrap();
for (name, set) in [("x0", x0), ("x1", x1), ("y0", y0), ("z0", z0)] {
m.add_set(name, Set { nodes: set, ..Set::default() }).unwrap();
}
let thermal = Thermal { expansion: Some(Directional::Isotropic(ALPHA)), ..Thermal::default() };
m.add_material(
"Mat",
Material {
elastic: Some(Elastic::Isotropic { young: YOUNG, poisson: 0.0 }),
density: Some(1.0),
thermal: Some(thermal),
plastic,
..Material::default()
},
)
.unwrap();
let integration = if kind == ElementKind::Hex8 { Integration::Reduced } else { Integration::Full };
let formulation = Formulation { integration, ..Formulation::default() };
m.add_section("bar", Section { set: "all".into(), material: "Mat".into(), kind: SectionKind::Solid {}, formulation }).unwrap();
let mut step = Step::new("Heat", Procedure::ExplicitDynamic(ExplicitDynamic::new(20.0)));
let mut bcs = vec![("x0", Symmetry::X), ("y0", Symmetry::Y), ("z0", Symmetry::Z)];
if clamped {
bcs.push(("x1", Symmetry::X));
}
for (set, plane) in bcs {
let name = format!("hold_{set}");
m.add_boundary_condition(&name, BoundaryCondition::Symmetry { target: Target::Set(set.into()), plane }).unwrap();
step.boundary_conditions.insert(name);
}
m.add_load("warm", Load::Temperature { target: Target::Set("all".into()), value: DT }).unwrap();
step.loads.insert("warm".into());
m.add_step(step, None).unwrap();
m
}
#[test]
fn a_free_bar_expands_without_stress() {
for kind in [ElementKind::Hex8, ElementKind::Tet4] {
let r = solve_explicit(&bar(kind, false, None), None, &Limits::default()).unwrap();
let last = r.frames.last().unwrap();
let tip = r.nodes.iter().position(|l| *l == 5).unwrap();
let want = ALPHA * DT * LENGTH;
assert!((last.displacement[tip][0] - want).abs() < 0.02 * want, "{kind:?}: {:?} vs {want}", last.displacement[tip]);
let worst = last.stress.iter().flatten().fold(0.0f64, |m, s| m.max(s.abs()));
assert!(worst < 0.02 * YOUNG * ALPHA * DT, "{kind:?}: {worst}");
}
}
#[test]
fn a_clamped_bar_carries_minus_e_alpha_dt_up_to_yield() {
let sigma = -YOUNG * ALPHA * DT;
for kind in [ElementKind::Hex8, ElementKind::Tet4] {
let r = solve_explicit(&bar(kind, true, None), None, &Limits::default()).unwrap();
for s in &r.frames.last().unwrap().stress {
assert!((s[0] - sigma).abs() < 0.02 * sigma.abs(), "{kind:?}: {s:?}");
}
// Perfectly plastic at half that stress: the bar yields and the stress stays at −σ_y.
let plastic = Plastic::Bilinear { yield_stress: 0.5 * sigma.abs(), hardening_modulus: 0.0, hardening: Hardening::Isotropic };
let r = solve_explicit(&bar(kind, true, Some(plastic)), None, &Limits::default()).unwrap();
let last = r.frames.last().unwrap();
for (s, ep) in last.stress.iter().zip(&last.plastic_strain) {
let mises = modelcraft_fem::von_mises(s);
assert!((mises - 0.5 * sigma.abs()).abs() < 0.02 * sigma.abs(), "{kind:?}: {s:?}");
assert!(*ep > 0.0, "{kind:?}: plastic strain {ep}");
}
}
}
#[test]
fn orthotropic_expansion_and_heat_loads_are_refused() {
let mut m = bar(ElementKind::Hex8, false, None);
let thermal = Thermal { expansion: Some(Directional::Orthotropic([1e-5, 2e-5, 1e-5])), ..Thermal::default() };
m.replace_material(
"Mat",
Material {
elastic: Some(Elastic::Isotropic { young: YOUNG, poisson: 0.0 }),
density: Some(1.0),
thermal: Some(thermal),
..Material::default()
},
)
.unwrap();
assert!(solve_explicit(&m, None, &Limits::default()).unwrap_err().to_string().contains("orthotropic thermal expansion"));
}