Files
OneCAD-Simulation/crates/fem/src/heat.rs
T
e900f41ce3 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>
2026-10-13 22:15:03 -04:00

234 lines
9.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Heat conduction kernels: conductivity, heat capacity, internal heat sources, surface fluxes and
//! convection, and the heat flux at the integration points.
//!
//! The Galerkin form of transient conduction ρc Ṫ = ∇·(k∇T) + Q with flux and convection on the
//! boundary (Zienkiewicz, Taylor & Zhu 2013, §3.2 and ch. 7 of vol. 1; Hughes 2000, ch. 1, §8.1)
//! gives, element by element, with one temperature per node ([`ElementDofs`] `TEMPERATURE`):
//! - conductivity K = ∫ ∇Nᵀ k ∇N dV, k the diagonal conductivity in the global axes (the
//! material axes until orientations land), integrated with the element's full rule;
//! - capacity C = ∫ ρc N Nᵀ dV with the mass rule (consistent);
//! - source f = ∫ N Q dV;
//! - on faces: a flux q into the body gives f = ∫ N q dA; convection with film coefficient h to a
//! sink at T∞ adds h ∫ N Nᵀ dA to K and h T∞ ∫ N dA to f.
//!
//! Faces are integrated on their own (possibly curved) geometry with the model's face rule
//! (the one pressures use), exact for N Nᵀ on flat faces.
use modelcraft_model::ElementKind;
use crate::basis::Basis;
use crate::dense::Matrix;
use crate::dof::{DofSet, ElementDofs};
use crate::element::{ElementType, element_type};
use crate::quadrature::Rule;
use crate::{Error, Result};
/// What a heat conduction element carries: one temperature per node.
pub const HEAT_DOFS: ElementDofs = ElementDofs::new(DofSet::TEMPERATURE);
/// The heat flux −k∇T at one integration point.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PointFlux {
pub xyz: [f64; 3],
pub flux: [f64; 3],
}
/// A solid element conducting heat.
#[derive(Debug, Clone, Copy)]
pub struct Conduction<'a> {
pub ty: &'static ElementType,
basis: Basis,
xyz: &'a [[f64; 3]],
}
impl<'a> Conduction<'a> {
pub fn new(kind: ElementKind, xyz: &'a [[f64; 3]]) -> Result<Conduction<'a>> {
let ty = element_type(kind)?;
if xyz.len() != ty.nodes {
return Err(Error::Invalid(format!("a {} element has {} nodes, got {}", kind.name(), ty.nodes, xyz.len())));
}
modelcraft_model::check_coordinates(xyz.as_flattened()).map_err(|e| Error::Invalid(e.to_string()))?;
Ok(Conduction { ty, basis: Basis::of(kind), xyz })
}
/// Visit the points of `rule`: shape values, physical gradients and the weight w det J.
fn each(&self, rule: Rule, mut visit: impl FnMut(&[f64], &[[f64; 3]], f64)) -> Result<()> {
for p in rule.points() {
let s = self.basis.eval(p.xi);
let g = self.basis.gradients(&s, self.xyz)?;
visit(s.values(), g.gradients(), g.det * p.weight);
}
Ok(())
}
/// K = ∫ ∇Nᵀ k ∇N dV, n × n.
pub fn conductivity(&self, k: [f64; 3]) -> Result<Matrix> {
let mut m = Matrix::zeros(self.ty.nodes);
self.each(self.ty.full, |_, g, w| {
for (i, gi) in g.iter().enumerate() {
for (j, gj) in g.iter().enumerate() {
m.add(i, j, w * (k[0] * gi[0] * gj[0] + k[1] * gi[1] * gj[1] + k[2] * gi[2] * gj[2]));
}
}
})?;
Ok(m)
}
/// C = ∫ ρc N Nᵀ dV, n × n; `capacity` is ρc (energy per volume per temperature).
pub fn capacity(&self, capacity: f64) -> Result<Matrix> {
let mut m = Matrix::zeros(self.ty.nodes);
self.each(self.ty.mass, |n, _, w| {
for (i, a) in n.iter().enumerate() {
for (j, b) in n.iter().enumerate() {
m.add(i, j, w * capacity * a * b);
}
}
})?;
Ok(m)
}
/// f = ∫ N Q dV for a uniform heat source Q per volume.
pub fn source(&self, q: f64) -> Result<Vec<f64>> {
let mut f = vec![0.0; self.ty.nodes];
self.each(self.ty.mass, |n, _, w| {
for (fi, ni) in f.iter_mut().zip(n) {
*fi += w * q * ni;
}
})?;
Ok(f)
}
/// The heat flux −k∇T at the full rule's points for nodal temperatures `t`.
pub fn flux(&self, k: [f64; 3], t: &[f64]) -> Result<Vec<PointFlux>> {
if t.len() != self.ty.nodes {
return Err(Error::Invalid(format!("{} temperatures for a {}-node element", t.len(), self.ty.nodes)));
}
let mut out = Vec::new();
self.each(self.ty.full, |n, g, _| {
let (mut grad, mut xyz) = ([0.0; 3], [0.0; 3]);
for ((gi, ti), (ni, xi)) in g.iter().zip(t).zip(n.iter().zip(self.xyz)) {
for c in 0..3 {
grad[c] += gi[c] * ti;
xyz[c] += ni * xi[c];
}
}
out.push(PointFlux { xyz, flux: [0, 1, 2].map(|c| -k[c] * grad[c]) });
})?;
Ok(out)
}
}
/// Visit the points of a face (node coordinates in the face's node order): shape values and
/// the area weight, with the model's face rule ([`modelcraft_model::nodal::integrate_face`], the
/// one pressures and tractions use).
fn each_face_point(xyz: &[[f64; 3]], mut visit: impl FnMut(&[f64], f64)) -> Result<()> {
let mut collapsed = false;
modelcraft_model::nodal::integrate_face(xyz, |n, nda| {
let area = (nda[0] * nda[0] + nda[1] * nda[1] + nda[2] * nda[2]).sqrt();
collapsed |= !(area > 0.0 && area.is_finite());
visit(n, area);
})
.map_err(|e| Error::Invalid(e.to_string()))?;
if collapsed {
return Err(Error::Degenerate("a face is collapsed (zero area)".into()));
}
Ok(())
}
/// ∫ N dA for every node of a face: the nodal shares of a uniform flux.
pub fn face_weights(xyz: &[[f64; 3]]) -> Result<Vec<f64>> {
let mut f = vec![0.0; xyz.len()];
each_face_point(xyz, |n, w| {
for (fi, ni) in f.iter_mut().zip(n) {
*fi += w * ni;
}
})?;
Ok(f)
}
/// h ∫ N Nᵀ dA for a face: the conductivity convection adds.
pub fn face_film(xyz: &[[f64; 3]], film: f64) -> Result<Matrix> {
let mut m = Matrix::zeros(xyz.len());
each_face_point(xyz, |n, w| {
for (i, a) in n.iter().enumerate() {
for (j, b) in n.iter().enumerate() {
m.add(i, j, w * film * a * b);
}
}
})?;
Ok(m)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shape::{HEX_CORNERS, TET_CORNERS};
fn box_hex(a: [f64; 3]) -> Vec<[f64; 3]> {
HEX_CORNERS.iter().map(|c| [0, 1, 2].map(|k| (c[k] + 1.0) / 2.0 * a[k])).collect()
}
#[test]
fn conductivity_rows_sum_to_zero_and_reproduce_a_linear_field() {
// A uniform gradient G in a 2 × 1 × 3 box: K T = boundary flux, zero at interior
// balance; here check K·1 = 0 and Tᵀ K T = k |G|² V.
let xyz = box_hex([2.0, 1.0, 3.0]);
for kind in [ElementKind::Hex8] {
let e = Conduction::new(kind, &xyz).unwrap();
let k = [2.0, 3.0, 5.0];
let m = e.conductivity(k).unwrap();
let ones = vec![1.0; 8];
assert!(m.mul_vec(&ones).iter().all(|v| v.abs() < 1e-12));
let g = [0.3, -0.7, 0.2];
let t: Vec<f64> = xyz.iter().map(|x| g[0] * x[0] + g[1] * x[1] + g[2] * x[2]).collect();
let kt = m.mul_vec(&t);
let energy: f64 = kt.iter().zip(&t).map(|(a, b)| a * b).sum();
let exact = (k[0] * g[0] * g[0] + k[1] * g[1] * g[1] + k[2] * g[2] * g[2]) * 6.0;
assert!((energy - exact).abs() < 1e-12 * exact, "{energy} vs {exact}");
let fl = e.flux(k, &t).unwrap();
for p in fl {
for c in 0..3 {
assert!((p.flux[c] + k[c] * g[c]).abs() < 1e-13);
}
}
}
}
#[test]
fn capacity_and_source_integrate_the_volume() {
let tet: Vec<[f64; 3]> = TET_CORNERS.iter().map(|c| [2.0 * c[0], 3.0 * c[1], c[2]]).collect();
let e = Conduction::new(ElementKind::Tet4, &tet).unwrap();
let v = 1.0;
let c = e.capacity(4.0).unwrap();
assert!((c.data.iter().sum::<f64>() - 4.0 * v).abs() < 1e-13);
assert!((c.get(0, 0) - 4.0 * v / 10.0).abs() < 1e-13 && (c.get(0, 1) - 4.0 * v / 20.0).abs() < 1e-13);
let f = e.source(3.0).unwrap();
assert!(f.iter().all(|x| (x - 0.75).abs() < 1e-13), "{f:?}");
assert!(Conduction::new(ElementKind::Tet4, &tet[..3]).is_err());
assert!(e.flux([1.0; 3], &[0.0; 3]).is_err());
}
#[test]
fn face_weights_and_film() {
let quad = [[0., 0., 0.], [2., 0., 0.], [2., 3., 0.], [0., 3., 0.]];
let w = face_weights(&quad).unwrap();
assert!(w.iter().all(|x| (x - 1.5).abs() < 1e-14));
let m = face_film(&quad, 2.0).unwrap();
// h A / 9 on the diagonal of a bilinear rectangle, sum h A.
assert!((m.data.iter().sum::<f64>() - 12.0).abs() < 1e-12);
assert!((m.get(0, 0) - 2.0 * 6.0 / 9.0).abs() < 1e-13);
let mut t6 = vec![[0., 0., 0.], [1., 0., 0.], [0., 1., 0.]];
for (a, b) in [(0, 1), (1, 2), (2, 0)] {
t6.push([0, 1, 2].map(|k| (t6[a][k] + t6[b][k]) / 2.0));
}
let w = face_weights(&t6).unwrap();
for (i, x) in w.iter().enumerate() {
let want = if i < 3 { 0.0 } else { 0.5 / 3.0 };
assert!((x - want).abs() < 1e-14, "{i}: {x}");
}
assert!(face_weights(&quad[..2]).is_err());
assert!(face_weights(&[[0.0; 3]; 4]).is_err());
assert!(face_film(&[[f64::NAN, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], 1.0).is_err());
}
}