forked from 1b-agent/fieldcraft-upstream
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 <[email protected]>
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
* clippy
Co-Authored-By: Claude Opus 5.5 <[email protected]>
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
---------
Co-authored-by: jyviko <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
37 files changed
+2576
-90
No files matched your search
+97
-11
@@ -9,7 +9,7 @@
|
||||
|
||||
use modelcraft_fem::beam::{Beam, BeamSection};
|
||||
use modelcraft_fem::dense::Matrix;
|
||||
use modelcraft_fem::element::{Formulation, Solid};
|
||||
use modelcraft_fem::element::{Formulation, Solid, ThermalStrain};
|
||||
use modelcraft_fem::plane::{Plane, Theory};
|
||||
use modelcraft_fem::shell::{Shell, ShellSection};
|
||||
use modelcraft_fem::truss::{Truss, young_modulus};
|
||||
@@ -35,6 +35,9 @@ pub struct Elem {
|
||||
pub density: Option<f64>,
|
||||
/// The 2D theory of an element of a 2D section; `None` for solids.
|
||||
pub theory: Option<Theory>,
|
||||
/// The expansion coefficients along X, Y, Z and the reference temperature, when the
|
||||
/// material expands.
|
||||
pub expansion: Option<([f64; 3], f64)>,
|
||||
}
|
||||
|
||||
/// An element's kernel: a solid or a 2D element.
|
||||
@@ -90,11 +93,20 @@ impl Kernel<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Integration-point stresses for nodal displacements given as 3-vectors.
|
||||
pub fn stresses(&self, d: &Elasticity, u: &[[f64; 3]]) -> modelcraft_fem::Result<Vec<PointStress>> {
|
||||
/// Integration-point stresses for nodal displacements given as 3-vectors, with a thermal
|
||||
/// strain when given.
|
||||
pub fn stresses_with(&self, d: &Elasticity, u: &[[f64; 3]], thermal: Option<&ThermalStrain>) -> modelcraft_fem::Result<Vec<PointStress>> {
|
||||
match self {
|
||||
Kernel::Solid(s) => s.stresses(d, u),
|
||||
Kernel::Plane(p) => p.stresses(d, &u.iter().map(|v| [v[0], v[1]]).collect::<Vec<_>>()),
|
||||
Kernel::Solid(s) => s.stresses_with(d, u, thermal),
|
||||
Kernel::Plane(p) => p.stresses_with(d, &u.iter().map(|v| [v[0], v[1]]).collect::<Vec<_>>(), thermal),
|
||||
}
|
||||
}
|
||||
|
||||
/// The equivalent nodal forces of a thermal strain, as 3-vectors (Z zero for 2D).
|
||||
pub fn thermal_load(&self, d: &Elasticity, thermal: &ThermalStrain) -> modelcraft_fem::Result<Vec<[f64; 3]>> {
|
||||
match self {
|
||||
Kernel::Solid(s) => s.thermal_load(d, thermal),
|
||||
Kernel::Plane(p) => Ok(p.thermal_load(d, thermal)?.into_iter().map(|v| [v[0], v[1], 0.0]).collect()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +133,12 @@ impl Elem {
|
||||
};
|
||||
k.map_err(|e| Error::element(self.label, e))
|
||||
}
|
||||
|
||||
/// The element's thermal strain in a temperature field (nodes the field leaves out are at
|
||||
/// the reference temperature); `None` when the material does not expand or nothing changes.
|
||||
pub fn thermal_strain(&self, field: &[Option<f64>]) -> Option<ThermalStrain> {
|
||||
thermal_strain(self.expansion, &self.nodes, field)
|
||||
}
|
||||
}
|
||||
|
||||
/// A truss element (rod2, rod3) ready for the kernel.
|
||||
@@ -134,12 +152,29 @@ pub struct Bar {
|
||||
pub young: f64,
|
||||
pub area: f64,
|
||||
pub density: Option<f64>,
|
||||
/// The expansion coefficients along X, Y, Z and the reference temperature, when the
|
||||
/// material expands.
|
||||
pub expansion: Option<([f64; 3], f64)>,
|
||||
}
|
||||
|
||||
impl Bar {
|
||||
pub fn truss(&self) -> Result<Truss<'_>> {
|
||||
Truss::new(self.kind, &self.xyz).map_err(|e| Error::element(self.label, e))
|
||||
}
|
||||
|
||||
/// The member's thermal strain in a temperature field, as for [`Elem::thermal_strain`].
|
||||
pub fn thermal_strain(&self, field: &[Option<f64>]) -> Option<ThermalStrain> {
|
||||
thermal_strain(self.expansion, &self.nodes, field)
|
||||
}
|
||||
}
|
||||
|
||||
/// The thermal strain of an element of nodes `nodes` in a temperature field (nodes the field
|
||||
/// leaves out are at the reference temperature); `None` when the material does not expand or
|
||||
/// nothing changes.
|
||||
fn thermal_strain(expansion: Option<([f64; 3], f64)>, nodes: &[usize], field: &[Option<f64>]) -> Option<ThermalStrain> {
|
||||
let (alpha, reference) = expansion?;
|
||||
let delta: Vec<f64> = nodes.iter().map(|n| field.get(*n).copied().flatten().map_or(0.0, |t| t - reference)).collect();
|
||||
delta.iter().any(|d| *d != 0.0).then_some(ThermalStrain { alpha, delta })
|
||||
}
|
||||
|
||||
/// What a shell or beam element is made of.
|
||||
@@ -394,7 +429,8 @@ impl Mesh {
|
||||
young_modulus(elastic).map_err(|e| Error::Invalid(format!("material `{}` of section `{name}`: {e}", section.material)))?;
|
||||
for &label in &set.elements {
|
||||
let el = model.element(label).ok_or_else(|| Error::Invalid(format!("element {label} is missing")))?;
|
||||
trusses.push((label, el.kind, el.nodes.clone(), young, area, material.density));
|
||||
let expansion = material.thermal.and_then(|t| t.expansion.map(|a| (a.axes(), t.reference_temperature)));
|
||||
trusses.push((label, el.kind, el.nodes.clone(), young, area, material.density, expansion));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -406,7 +442,8 @@ impl Mesh {
|
||||
Some(_) => plane::formulation(el.kind, §ion.formulation).map(|f| (f, ElementDofs::new(DofSet::PLANE))),
|
||||
}
|
||||
.map_err(|e| Error::element(label, e))?;
|
||||
elements.push((label, el.kind, el.nodes.clone(), formulation, d, material.density, theory, dofs));
|
||||
let expansion = material.thermal.and_then(|t| t.expansion.map(|a| (a.axes(), t.reference_temperature)));
|
||||
elements.push((label, el.kind, el.nodes.clone(), formulation, d, material.density, theory, dofs, expansion));
|
||||
}
|
||||
}
|
||||
if let (Some(_), Some(name)) = (planar, model.couplings().keys().next()) {
|
||||
@@ -472,15 +509,15 @@ impl Mesh {
|
||||
warnings,
|
||||
discrete: crate::discrete::Discrete::default(),
|
||||
};
|
||||
for (label, kind, nodes, formulation, d, density, theory, _) in elements {
|
||||
for (label, kind, nodes, formulation, d, density, theory, _, expansion) in elements {
|
||||
let idx: Vec<usize> = nodes.iter().filter_map(|n| mesh.node(*n)).collect();
|
||||
let exyz: Vec<[f64; 3]> = idx.iter().filter_map(|i| mesh.xyz.get(*i).copied()).collect();
|
||||
mesh.elements.push(Elem { label, kind, nodes: idx, xyz: exyz, formulation, d, density, theory });
|
||||
mesh.elements.push(Elem { label, kind, nodes: idx, xyz: exyz, formulation, d, density, theory, expansion });
|
||||
}
|
||||
for (label, kind, nodes, young, area, density) in trusses {
|
||||
for (label, kind, nodes, young, area, density, expansion) in trusses {
|
||||
let idx: Vec<usize> = nodes.iter().filter_map(|n| mesh.node(*n)).collect();
|
||||
let exyz: Vec<[f64; 3]> = idx.iter().filter_map(|i| mesh.xyz.get(*i).copied()).collect();
|
||||
mesh.trusses.push(Bar { label, kind, nodes: idx, xyz: exyz, young, area, density });
|
||||
mesh.trusses.push(Bar { label, kind, nodes: idx, xyz: exyz, young, area, density, expansion });
|
||||
}
|
||||
for (label, kind, nodes, density, section) in structural {
|
||||
let idx: Vec<usize> = nodes.iter().filter_map(|n| mesh.node(*n)).collect();
|
||||
@@ -713,6 +750,9 @@ pub fn loads(model: &Model, resolved: &Resolved, mesh: &Mesh, step: &Step, activ
|
||||
mesh.discrete.add_gravity(&mut f, *acceleration, mesh);
|
||||
}
|
||||
}
|
||||
// Heat loads act in heat transfer; temperature fields through their thermal strain
|
||||
// (add_thermal_loads).
|
||||
Load::SurfaceFlux { .. } | Load::Convection { .. } | Load::HeatSource { .. } | Load::Temperature { .. } => {}
|
||||
Load::BodyForce { set, vector } => {
|
||||
for label in resolved.sets.get(set).map(|r| r.elements.as_slice()).unwrap_or_default() {
|
||||
if let Some(t) = mesh.truss(*label) {
|
||||
@@ -779,6 +819,52 @@ fn structural_body(f: &mut [f64], mesh: &Mesh, e: &Structural, b: [f64; 3]) -> R
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The temperature field of a static step's temperature loads, per mesh node; `None` when the
|
||||
/// step has none.
|
||||
pub fn temperature_loads(model: &Model, resolved: &Resolved, mesh: &Mesh, step: &Step) -> Result<Option<Vec<Option<f64>>>> {
|
||||
let mut field: Option<Vec<Option<f64>>> = None;
|
||||
for name in &step.loads {
|
||||
let Some(Load::Temperature { target, value }) = model.load(name) else { continue };
|
||||
let field = field.get_or_insert_with(|| vec![None; mesh.labels.len()]);
|
||||
for &label in resolved.target_nodes(target) {
|
||||
let Some(n) = mesh.node(label) else { continue };
|
||||
hold(field.get_mut(n), *value, |old| format!("node {label} is given both {old} and {value} (temperature load `{name}` and another)"))?;
|
||||
}
|
||||
}
|
||||
Ok(field)
|
||||
}
|
||||
|
||||
/// Set a node's value, refusing a different one already there (`conflict` names both).
|
||||
pub(crate) fn hold(slot: Option<&mut Option<f64>>, value: f64, conflict: impl FnOnce(f64) -> String) -> Result<()> {
|
||||
let slot = slot.ok_or_else(|| Error::Internal("node index".into()))?;
|
||||
match *slot {
|
||||
Some(old) if old != value => Err(Error::Invalid(conflict(old))),
|
||||
_ => {
|
||||
*slot = Some(value);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add the equivalent nodal forces of the thermal strains of a temperature field.
|
||||
pub fn add_thermal_loads(f: &mut [f64], mesh: &Mesh, field: &[Option<f64>]) -> Result<()> {
|
||||
for e in &mesh.elements {
|
||||
let Some(th) = e.thermal_strain(field) else { continue };
|
||||
let forces = e.kernel()?.thermal_load(&e.d, &th).map_err(|err| Error::element(e.label, err))?;
|
||||
for (node, v) in e.nodes.iter().zip(forces) {
|
||||
add(f, &translations(mesh, *node)?, v);
|
||||
}
|
||||
}
|
||||
for t in &mesh.trusses {
|
||||
let Some(th) = t.thermal_strain(field) else { continue };
|
||||
let forces = t.truss()?.thermal_load(t.young, t.area, &th).map_err(|err| Error::element(t.label, err))?;
|
||||
for (node, v) in t.nodes.iter().zip(forces) {
|
||||
add(f, &translations(mesh, *node)?, v);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The uniform force per volume a step's gravity and body forces put on each shell and beam
|
||||
/// element ([`Mesh::structural`] order): beam end forces are recovered with it.
|
||||
pub fn structural_body_forces(model: &Model, resolved: &Resolved, mesh: &Mesh, step: &Step) -> Result<Vec<[f64; 3]>> {
|
||||
|
||||
Reference in new issue
Block a user