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

+5 -2
View File
@@ -294,6 +294,7 @@ fn check_expressible(model: &Model) -> Result<()> {
));
}
}
bad.extend(crate::thermal_items(model));
for (name, c) in model.couplings() {
if c.kind == CouplingKind::Distributing && !(1..=3).all(|d| c.dofs.contains(d)) {
bad.insert(format!("coupling `{name}`: an Abaqus distributing coupling always couples translations 1-3"));
@@ -911,6 +912,8 @@ impl Deck {
};
self.line(&format!("{target}, {s}"));
}
// Refused up front (check_expressible).
BoundaryCondition::Temperature { .. } => {}
BoundaryCondition::Displacement { values, .. } => {
for (dof, v) in (1u8..).zip(values) {
if let Some(v) = v {
@@ -1068,8 +1071,8 @@ impl Deck {
self.comment("loads have no effect on natural frequencies and are not written");
}
}
Procedure::ExplicitDynamic(_) => {
return Err(Error::Unsupported { format: FORMAT, what: format!("step `{}`: explicit dynamic steps", step.name) });
Procedure::ExplicitDynamic(_) | Procedure::HeatTransfer { .. } | Procedure::CoupledTemperatureDisplacement {} => {
return Err(Error::Unsupported { format: FORMAT, what: format!("step `{}`: {} steps", step.name, step.procedure.name()) });
}
}
self.output(step);
+17
View File
@@ -115,6 +115,23 @@ pub(crate) fn unsupported(format: &'static str, items: BTreeSet<String>) -> Resu
if items.is_empty() { Ok(()) } else { Err(Error::Unsupported { format, what: items.into_iter().collect::<Vec<_>>().join("; ") }) }
}
/// Heat transfer in a model, which no writer expresses yet: heat transfer and coupled
/// temperature–displacement steps, temperature boundary conditions, heat loads and temperature
/// fields. Thermal material properties alone are left out of the decks (nothing uses them).
pub(crate) fn thermal_items(model: &Model) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for step in model.steps().iter().filter(|s| s.procedure.is_thermal()) {
out.insert(format!("step `{}`: {} steps (not written to decks yet)", step.name, step.procedure.name()));
}
for name in model.boundary_conditions().iter().filter(|(_, b)| b.is_thermal()).map(|(n, _)| n) {
out.insert(format!("boundary condition `{name}`: prescribed temperatures (not written to decks yet)"));
}
for (name, load) in model.loads().iter().filter(|(_, l)| l.is_thermal() || matches!(l, Load::Temperature { .. })) {
out.insert(format!("load `{name}`: {} loads (not written to decks yet)", load.kind()));
}
out
}
/// The concentrated loads of a set of active loads: the resolved nodal values of every force,
/// traction (degrees of freedom 1–3) and moment (4–6), summed per node and degree of freedom so
/// loads sharing a node add up; zero sums are left out.
+8 -3
View File
@@ -202,7 +202,7 @@ fn plan<'a>(model: &'a Model, r: &Resolved) -> Result<Plan<'a>> {
let used: BTreeSet<&str> = model.sections().values().map(|s| s.material.as_str()).collect();
let materials: BTreeMap<&str, u32> = used.into_iter().enumerate().map(|(i, m)| Ok((m, id(i)?))).collect::<Result<_>>()?;
// Everything LS-DYNA cannot express, reported at once.
let mut refused = BTreeSet::new();
let mut refused = crate::thermal_items(model);
let mut parts = Vec::new();
let mut hourglasses: BTreeMap<Hourglass, u32> = BTreeMap::new();
for (name, sec) in model.sections() {
@@ -311,6 +311,8 @@ fn plan<'a>(model: &'a Model, r: &Resolved) -> Result<Plan<'a>> {
Load::BodyForce { .. } => {
refused.insert(format!("load `{l}`: a body force per unit volume (LS-DYNA's body loads are accelerations; use gravity)"));
}
// In thermal_items.
Load::SurfaceFlux { .. } | Load::Convection { .. } | Load::HeatSource { .. } | Load::Temperature { .. } => {}
}
}
}
@@ -818,8 +820,8 @@ fn write_step(d: &mut Deck, model: &Model, r: &Resolved, plan: &Plan, step: &Ste
d.card([Field::Blank, Field::Blank, Field::Blank, Field::Blank, Field::from(1)]);
}
}
Procedure::ExplicitDynamic(_) => {
return Err(Error::Unsupported { format: FORMAT, what: format!("step `{}`: explicit dynamic steps", step.name) });
Procedure::ExplicitDynamic(_) | Procedure::HeatTransfer { .. } | Procedure::CoupledTemperatureDisplacement {} => {
return Err(Error::Unsupported { format: FORMAT, what: format!("step `{}`: {} steps", step.name, step.procedure.name()) });
}
}
d.line("*CONTROL_TERMINATION");
@@ -872,6 +874,9 @@ fn write_step(d: &mut Deck, model: &Model, r: &Resolved, plan: &Plan, step: &Ste
}
Load::BodyForce { .. } => return Err(broken(format!("load `{name}`: a body force"))),
Load::Centrifugal { .. } => return Err(broken(format!("load `{name}`: a centrifugal load"))),
Load::SurfaceFlux { .. } | Load::Convection { .. } | Load::HeatSource { .. } | Load::Temperature { .. } => {
return Err(broken(format!("load `{name}`: a {} load", load.kind())));
}
}
}
// The other concentrated loads node by node, summed where they share a node.
+4 -1
View File
@@ -327,6 +327,7 @@ impl<'m> Deck<'m> {
self.plan_discrete()?;
self.plan_constraints()?;
self.plan_loads()?;
self.problems.extend(crate::thermal_items(self.model));
for sub in &mut self.subcases {
if matches!(sub.step.procedure, Procedure::Frequency { .. }) {
sub.method = Some(next_id(&mut self.next_sid)?);
@@ -705,6 +706,8 @@ impl<'m> Deck<'m> {
OutputVariable::ReactionForce => "SPCFORCES",
OutputVariable::Stress => "STRESS",
OutputVariable::Strain => "STRAIN",
// Heat transfer steps are refused (thermal_items).
OutputVariable::Temperature | OutputVariable::HeatFlux => continue,
};
let _ = writeln!(out, " {request} = ALL");
}
@@ -928,7 +931,7 @@ impl<'m> Deck<'m> {
let a = angular_velocity / (2.0 * std::f64::consts::PI);
entry(Card::new("RFORCE").int(*sid).blank().blank().real(a).reals(*axis), out, &mut first)?
}
Load::BodyForce { .. } => {}
Load::BodyForce { .. } | Load::SurfaceFlux { .. } | Load::Convection { .. } | Load::HeatSource { .. } | Load::Temperature { .. } => {}
}
}
for (sid, loads) in &self.combinations {