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

+90 -4
View File
@@ -44,7 +44,7 @@ use modelcraft_model::{ElementKind, Extent, SectionKind};
use crate::basis::Basis;
use crate::dense::{Cholesky, Matrix};
use crate::dof::{DofSet, ElementDofs, Internal};
use crate::element::{ElementType, Family, Formulation, PointStress, extrapolation, lump, symmetrize};
use crate::element::{ElementType, Family, Formulation, PointStress, ThermalStrain, extrapolation, lump, symmetrize};
use crate::material::Elasticity;
use crate::quadrature::{Point, Rule};
use crate::shape::{MAX_NODES, Shape};
@@ -471,20 +471,98 @@ impl<'a> Plane<'a> {
Ok(f)
}
/// The thermal strain α ΔT at a point with shape values `n` (xx, yy, zz, xy; zz is the hoop
/// direction of an axisymmetric element).
fn thermal_strain(thermal: &ThermalStrain, n: &[f64]) -> [f64; 4] {
let dt: f64 = n.iter().zip(&thermal.delta).map(|(a, b)| a * b).sum();
[thermal.alpha[0] * dt, thermal.alpha[1] * dt, thermal.alpha[2] * dt, 0.0]
}
/// ∫ Bᵀ D ε_th dV on the nodes' (u, v) and on the incompatible modes.
fn thermal_forces(
d2: &Elasticity2,
thermal: &ThermalStrain,
points: &[(Point, At)],
modes: Option<&Vec<[Column; 2]>>,
nodes: usize,
) -> (Vec<f64>, Vec<f64>) {
let mut fu = vec![0.0; 2 * nodes];
let mut fa = vec![0.0; INCOMPATIBLE_DOFS];
for (i, (_, a)) in points.iter().enumerate() {
let eps = Self::thermal_strain(thermal, a.shape.values());
let s: [f64; 4] = std::array::from_fn(|r| (0..4).map(|c| d2[r][c] * eps[c]).sum());
// Bᵀ σ for one strain column g: u takes εxx, εzz and γxy; v takes εyy and γxy.
let add = |out: &mut [f64], k: usize, g: &Column| {
if let Some(x) = out.get_mut(2 * k) {
*x += a.dv * (g[0] * s[0] + g[2] * s[2] + g[1] * s[3]);
}
if let Some(x) = out.get_mut(2 * k + 1) {
*x += a.dv * (g[1] * s[1] + g[0] * s[3]);
}
};
for (k, g) in a.g.iter().take(nodes).enumerate() {
add(&mut fu, k, g);
}
if let Some(cols) = modes.and_then(|m| m.get(i)) {
for (k, g) in cols.iter().enumerate() {
add(&mut fa, k, g);
}
}
}
(fu, fa)
}
/// The equivalent nodal forces (u, v) of a thermal strain, ∫ Bᵀ D ε_th dV with the
/// theory's elasticity (plane stress leaves εzz free, plane strain holds it, axisymmetric
/// strains the hoop direction too); incompatible modes are condensed as in the stiffness.
pub fn thermal_load(&self, d: &Elasticity, thermal: &ThermalStrain) -> Result<Vec<[f64; 2]>> {
thermal.check(self.ty.nodes)?;
let d2 = self.theory.elasticity(d)?;
let n = 2 * self.ty.nodes;
let mut fu = if self.formulation == Formulation::IncompatibleModes {
let (full, points, modes) = self.full_stiffness(&d2)?;
let (mut fu, mut fa) = Self::thermal_forces(&d2, thermal, &points, modes.as_ref(), self.ty.nodes);
Cholesky::new(&sub_block(&full, n, n, INCOMPATIBLE_DOFS))?.solve(&mut fa);
for (i, x) in fu.iter_mut().enumerate() {
*x -= (0..INCOMPATIBLE_DOFS).map(|a| full.get(i, n + a) * fa.get(a).copied().unwrap_or(0.0)).sum::<f64>();
}
fu
} else {
Self::thermal_forces(&d2, thermal, &self.points()?, None, self.ty.nodes).0
};
fu.resize(n, 0.0);
Ok(fu.as_chunks::<2>().0.to_vec())
}
/// Strain and stress at every integration point for the nodal displacements `u` (u, v).
pub fn stresses(&self, d: &Elasticity, u: &[[f64; 2]]) -> Result<Vec<PointStress>> {
self.stresses_with(d, u, None)
}
/// Strain and stress at every integration point for the nodal displacements `u` (u, v) and,
/// if given, a thermal strain: the stress is D (ε − ε_th), the strain reported the total.
pub fn stresses_with(&self, d: &Elasticity, u: &[[f64; 2]], thermal: Option<&ThermalStrain>) -> Result<Vec<PointStress>> {
if u.len() != self.ty.nodes {
return Err(Error::Invalid(format!("{} displacements for a {}-node element", u.len(), self.ty.nodes)));
}
if let Some(t) = thermal {
t.check(self.ty.nodes)?;
}
let d2 = self.theory.elasticity(d)?;
let (full, points, modes) =
if self.formulation == Formulation::IncompatibleModes { self.full_stiffness(&d2)? } else { (Matrix::zeros(0), self.points()?, None) };
// Incompatible modes: α = −K_aa⁻¹ K_au u.
// Incompatible modes: K_aa α = f_a − K_au u (f_a the thermal load on the modes).
let alpha = if modes.is_some() {
let n = 2 * self.ty.nodes;
let flat: Vec<f64> = u.iter().flatten().copied().collect();
let mut rhs: Vec<f64> =
(0..INCOMPATIBLE_DOFS).map(|a| -flat.iter().enumerate().map(|(i, x)| full.get(n + a, i) * x).sum::<f64>()).collect();
if let Some(t) = thermal {
let (_, fa) = Self::thermal_forces(&d2, t, &points, modes.as_ref(), self.ty.nodes);
for (r, f) in rhs.iter_mut().zip(fa) {
*r += f;
}
}
Cholesky::new(&sub_block(&full, n, n, INCOMPATIBLE_DOFS))?.solve(&mut rhs);
rhs
} else {
@@ -507,11 +585,19 @@ impl<'a> Plane<'a> {
add(g, [alpha.get(2 * k).copied().unwrap_or(0.0), alpha.get(2 * k + 1).copied().unwrap_or(0.0)]);
}
}
let eth = thermal.map(|t| Self::thermal_strain(t, a.shape.values()));
if let Theory::PlaneStress { .. } = self.theory {
e[2] = -(d[2][0] * e[0] + d[2][1] * e[1] + d[2][3] * e[3]) / d[2][2];
e[2] = match eth {
// σzz = 0 with the thermal strain: εzz = εth,zz − D₃·(ε − εth)/D₃₃ in plane.
Some(t) => t[2] - (d[2][0] * (e[0] - t[0]) + d[2][1] * (e[1] - t[1]) + d[2][3] * e[3]) / d[2][2],
None => -(d[2][0] * e[0] + d[2][1] * e[1] + d[2][3] * e[3]) / d[2][2],
};
}
// Plane stress: D̃ has no zz row or column, so the εzz just recovered adds nothing.
let s: [f64; 4] = std::array::from_fn(|r| (0..4).map(|c| d2[r][c] * e[c]).sum());
let s: [f64; 4] = match eth {
Some(t) => std::array::from_fn(|r| (0..4).map(|c| d2[r][c] * (e[c] - t[c])).sum()),
None => std::array::from_fn(|r| (0..4).map(|c| d2[r][c] * e[c]).sum()),
};
out.push(PointStress { xyz: a.x, strain: [e[0], e[1], e[2], e[3], 0.0, 0.0], stress: [s[0], s[1], s[2], s[3], 0.0, 0.0] });
}
Ok(out)