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

+109 -2
View File
@@ -76,6 +76,62 @@ pub enum Procedure {
#[serde(default, skip_serializing_if = "Option::is_none")]
magic: Option<f64>,
},
/// Heat conduction: the steady temperature field, or its history over time when `transient`
/// is given. Temperature boundary conditions and heat loads (flux, convection, sources)
/// act; mechanical ones are ignored.
HeatTransfer {
#[serde(default, skip_serializing_if = "Option::is_none")]
transient: Option<Transient>,
},
/// Steady heat conduction and the static equilibrium of the thermal strains it causes,
/// together: every boundary condition and load of the step acts.
CoupledTemperatureDisplacement {},
}
/// Most time increments of a transient heat transfer step.
pub const MAX_HEAT_INCREMENTS: f64 = 100_000.0;
/// Time integration of a transient heat transfer step: the generalized trapezoidal (θ) method
/// with fixed increments, from a uniform initial temperature.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Transient {
pub end_time: f64,
pub increment: f64,
/// 1 is backward Euler (the default), ½ Crank–Nicolson; θ ≥ ½ is unconditionally stable.
#[serde(default = "default_theta")]
pub theta: f64,
#[serde(default)]
pub initial_temperature: f64,
}
fn default_theta() -> f64 {
1.0
}
impl Transient {
/// Backward Euler from 0.
pub fn new(end_time: f64, increment: f64) -> Transient {
Transient { end_time, increment, theta: default_theta(), initial_temperature: 0.0 }
}
/// The number of increments (the last one may be shorter).
pub fn increments(&self) -> usize {
(self.end_time / self.increment - 1e-9).ceil().max(1.0) as usize
}
pub fn validate(&self) -> Result<()> {
let end = positive("end time", self.end_time)?;
let dt = positive("time increment", self.increment)?;
if end / dt > MAX_HEAT_INCREMENTS {
return Err(Error::Invalid(format!("a transient heat step takes at most {MAX_HEAT_INCREMENTS} increments, got {}", end / dt)));
}
if !(0.5..=1.0).contains(&self.theta) {
return Err(Error::Invalid(format!("θ is in [0.5, 1] (unconditionally stable), got {}", self.theta)));
}
finite("initial temperature", self.initial_temperature)?;
Ok(())
}
}
/// The settings of an explicit dynamic step.
@@ -336,6 +392,8 @@ impl Procedure {
Procedure::Static {} => "static",
Procedure::Frequency { .. } => "frequency",
Procedure::ExplicitDynamic(_) => "explicit_dynamic",
Procedure::HeatTransfer { .. } => "heat_transfer",
Procedure::CoupledTemperatureDisplacement {} => "coupled_temperature_displacement",
Procedure::Fluid { .. } => "fluid",
}
}
@@ -344,10 +402,21 @@ impl Procedure {
pub fn fluid_domain(&self) -> Option<&str> {
match self {
Procedure::Fluid { domain, .. } => Some(domain),
Procedure::Static {} | Procedure::Frequency { .. } | Procedure::ExplicitDynamic(_) => None,
_ => None,
}
}
/// Whether the procedure solves for the motion of the solids (not pure heat transfer or
/// fluid flow).
pub fn is_structural(&self) -> bool {
!matches!(self, Procedure::HeatTransfer { .. } | Procedure::Fluid { .. })
}
/// Whether the procedure solves for temperatures.
pub fn is_thermal(&self) -> bool {
matches!(self, Procedure::HeatTransfer { .. } | Procedure::CoupledTemperatureDisplacement {})
}
/// Whether the procedure is transient dynamics (it needs mass; a free body is legitimate).
pub fn is_dynamic(&self) -> bool {
matches!(self, Procedure::ExplicitDynamic(_))
@@ -357,7 +426,7 @@ impl Procedure {
pub fn sets(&self) -> impl Iterator<Item = &str> {
let explicit = match self {
Procedure::ExplicitDynamic(e) => Some(e),
Procedure::Static {} | Procedure::Frequency { .. } | Procedure::Fluid { .. } => None,
_ => None,
};
explicit.into_iter().flat_map(ExplicitDynamic::sets)
}
@@ -387,6 +456,9 @@ impl Procedure {
return Err(Error::Invalid(format!("the two-relaxation-time parameter Λ must be above 0 and at most 1, got {m}")));
}
}
if let Procedure::HeatTransfer { transient: Some(t) } = self {
return t.validate();
}
if let Procedure::Frequency { modes, min_hz, max_hz } = *self {
if !(1..=MAX_MODES).contains(&modes) {
return Err(Error::Invalid(format!("a frequency step extracts 1 to {MAX_MODES} modes, got {modes}")));
@@ -413,6 +485,10 @@ impl Procedure {
Procedure::ExplicitDynamic(_) => [OutputVariable::Displacement, OutputVariable::Stress].into_iter().collect(),
// Velocity, pressure and wall forces, always.
Procedure::Fluid { .. } => BTreeSet::new(),
Procedure::HeatTransfer { .. } => [OutputVariable::Temperature, OutputVariable::HeatFlux].into_iter().collect(),
Procedure::CoupledTemperatureDisplacement {} => {
OutputVariable::ALL.into_iter().chain([OutputVariable::Temperature, OutputVariable::HeatFlux]).collect()
}
}
}
}
@@ -427,9 +503,14 @@ pub enum OutputVariable {
ReactionForce,
Stress,
Strain,
/// Nodal temperatures (heat transfer).
Temperature,
/// Heat flux at the integration points (heat transfer).
HeatFlux,
}
impl OutputVariable {
/// The structural variables.
pub const ALL: [OutputVariable; 4] =
[OutputVariable::Displacement, OutputVariable::ReactionForce, OutputVariable::Stress, OutputVariable::Strain];
}
@@ -671,4 +752,30 @@ mod tests {
}
assert!(serde_json::from_value::<Procedure>(serde_json::json!({"type": "explicit_dynamic", "end_time": 1, "dt": 2})).is_err());
}
#[test]
fn heat_transfer_procedures() {
let steady: Procedure = serde_json::from_value(serde_json::json!({"type": "heat_transfer"})).unwrap();
assert_eq!(steady, Procedure::HeatTransfer { transient: None });
assert!(steady.is_thermal() && steady.validate().is_ok());
assert_eq!(steady.default_output(), [OutputVariable::Temperature, OutputVariable::HeatFlux].into_iter().collect());
let t: Procedure =
serde_json::from_value(serde_json::json!({"type": "heat_transfer", "transient": {"end_time": 10, "increment": 0.3}})).unwrap();
let Procedure::HeatTransfer { transient: Some(tr) } = t else { panic!() };
assert_eq!((tr.theta, tr.initial_temperature, tr.increments()), (1.0, 0.0, 34));
assert_eq!(Transient::new(1.0, 0.25).increments(), 4);
for bad in [
Transient { theta: 0.4, ..Transient::new(1.0, 0.1) },
Transient { theta: f64::NAN, ..Transient::new(1.0, 0.1) },
Transient::new(1.0, 0.0),
Transient::new(-1.0, 0.1),
Transient::new(1e300, 1e-300),
Transient { initial_temperature: f64::INFINITY, ..Transient::new(1.0, 0.1) },
] {
assert!(Procedure::HeatTransfer { transient: Some(bad) }.validate().is_err(), "{bad:?}");
}
let coupled: Procedure = serde_json::from_value(serde_json::json!({"type": "coupled_temperature_displacement"})).unwrap();
assert!(coupled.is_thermal() && coupled.default_output().contains(&OutputVariable::Stress));
assert_eq!(coupled.name(), "coupled_temperature_displacement");
}
}