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

+68 -4
View File
@@ -10,7 +10,7 @@ use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::bcs::Target;
use crate::{Error, Model, Result, context, finite, insert_new, not_in_use, remove_existing, replace_existing, require};
use crate::{Error, Model, Result, context, finite, insert_new, not_in_use, positive, remove_existing, replace_existing, require};
/// How a force or moment on a set is shared among the set's nodes.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
@@ -70,6 +70,17 @@ pub enum Load {
#[serde(default, skip_serializing_if = "Option::is_none")]
set: Option<String>,
},
/// Heat flux per area into the set's element faces (positive heats the body).
SurfaceFlux { set: String, flux: f64 },
/// Convection from the set's element faces to a surrounding medium at `sink` temperature:
/// heat flux h (T_sink − T) into the body, with film coefficient `film` (h).
Convection { set: String, film: f64, sink: f64 },
/// Heat generated per volume in the set's elements.
HeatSource { set: String, power: f64 },
/// A temperature field at the target's nodes in a stress step: it strains the material
/// thermally (ε = α (T − T_ref)); nodes it does not reach stay at their material's reference
/// temperature.
Temperature { target: Target, value: f64 },
}
/// The centrifugal acceleration ω² r at `x` of a rotation at `angular_velocity` about the axis
@@ -94,14 +105,29 @@ impl Load {
Load::Gravity { .. } => "gravity",
Load::BodyForce { .. } => "body_force",
Load::Centrifugal { .. } => "centrifugal",
Load::SurfaceFlux { .. } => "surface_flux",
Load::Convection { .. } => "convection",
Load::HeatSource { .. } => "heat_source",
Load::Temperature { .. } => "temperature",
}
}
/// Whether it is a heat transfer load (flux, convection, heat source), active in heat
/// transfer and coupled temperature–displacement steps.
pub fn is_thermal(&self) -> bool {
matches!(self, Load::SurfaceFlux { .. } | Load::Convection { .. } | Load::HeatSource { .. })
}
/// The set the load acts on, if any.
pub fn set(&self) -> Option<&str> {
match self {
Load::Force { target, .. } | Load::Moment { target, .. } => target.set(),
Load::Pressure { set, .. } | Load::BodyForce { set, .. } | Load::Traction { set, .. } => Some(set),
Load::Force { target, .. } | Load::Moment { target, .. } | Load::Temperature { target, .. } => target.set(),
Load::Pressure { set, .. }
| Load::BodyForce { set, .. }
| Load::Traction { set, .. }
| Load::SurfaceFlux { set, .. }
| Load::Convection { set, .. }
| Load::HeatSource { set, .. } => Some(set),
Load::Gravity { set, .. } | Load::Centrifugal { set, .. } => set.as_deref(),
}
}
@@ -109,7 +135,7 @@ impl Load {
/// The reference point the load acts on, if any.
pub fn reference_point(&self) -> Option<&str> {
match self {
Load::Force { target, .. } | Load::Moment { target, .. } => target.reference_point(),
Load::Force { target, .. } | Load::Moment { target, .. } | Load::Temperature { target, .. } => target.reference_point(),
_ => None,
}
}
@@ -137,6 +163,24 @@ impl Load {
}
return Ok(());
}
Load::SurfaceFlux { flux: v, .. } | Load::HeatSource { power: v, .. } => {
if finite(self.kind(), *v)? == 0.0 {
return Err(Error::Invalid(format!("a {} must not be zero", self.kind())));
}
return Ok(());
}
Load::Convection { film, sink, .. } => {
positive("film coefficient", *film)?;
finite("sink temperature", *sink)?;
return Ok(());
}
Load::Temperature { target, value } => {
finite("temperature", *value)?;
if let Target::ReferencePoint(r) = target {
return Err(Error::Invalid(format!("reference points have no temperature (`{r}`); apply it to a set")));
}
return Ok(());
}
};
for c in vector {
finite(self.kind(), *c)?;
@@ -185,3 +229,23 @@ impl Model {
remove_existing(&mut self.loads, "load", name).map(|_| ())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn heat_loads_validate() {
let set = || "s".to_string();
assert!(Load::SurfaceFlux { set: set(), flux: 2.0 }.validate().is_ok());
assert!(Load::SurfaceFlux { set: set(), flux: 0.0 }.validate().is_err());
assert!(Load::HeatSource { set: set(), power: f64::NAN }.validate().is_err());
assert!(Load::Convection { set: set(), film: 0.0, sink: 1.0 }.validate().is_err());
assert!(Load::Convection { set: set(), film: 1.0, sink: f64::INFINITY }.validate().is_err());
assert!(Load::Temperature { target: Target::ReferencePoint("RP".into()), value: 1.0 }.validate().is_err());
assert!(Load::Temperature { target: Target::Set(set()), value: -40.0 }.validate().is_ok());
let l = Load::Convection { set: set(), film: 10.0, sink: 20.0 };
assert!(l.is_thermal() && l.set() == Some("s") && l.kind() == "convection");
assert!(!Load::Temperature { target: Target::Set(set()), value: 1.0 }.is_thermal());
}
}