Files
OneCAD-Simulation/crates/explicit/tests/thermal.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 <[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]>
2026-10-13 22:15:03 -04:00

141 lines
6.4 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.
//! Thermal strain in explicit dynamics: a temperature load reached slowly over the step expands
//! a free bar by α ΔT L without stress; a bar held at both ends carries σ = −E α ΔT, and with
//! perfectly plastic J2 the stress stops at the yield stress.
use modelcraft_explicit::{Limits, solve_explicit};
use modelcraft_model::*;
const YOUNG: f64 = 1.0e4;
const ALPHA: f64 = 1.0e-4;
const DT: f64 = 20.0;
const LENGTH: f64 = 4.0;
/// A 4 × 1 × 1 bar of hex8 (one-point) or Kuhn-split tet4 cells, ν = 0, heated by DT over a
/// step much longer than the wave transit, held at x = 0 (and at x = L when `clamped`) along x
/// and on the planes y = 0, z = 0.
fn bar(kind: ElementKind, clamped: bool, plastic: Option<Plastic>) -> Model {
let mut m = Model::default();
let n = 4usize;
let label = |i: usize, j: usize, k: usize| (1 + i + (n + 1) * (j + 2 * k)) as u32;
for k in 0..2 {
for j in 0..2 {
for i in 0..=n {
m.set_node(label(i, j, k), [i as f64, j as f64, k as f64]).unwrap();
}
}
}
let corners = [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0], [0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]];
let mut next = 1;
for i in 0..n {
let at = |c: [usize; 3]| label(i + c[0], c[1], c[2]);
if kind == ElementKind::Hex8 {
m.set_element(next, kind, corners.iter().map(|c| at(*c)).collect()).unwrap();
next += 1;
continue;
}
for perm in [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]] {
let mut v = [[0usize; 3]; 4];
for s in 0..3 {
v[s + 1] = v[s];
v[s + 1][perm[s]] = 1;
}
let x = v.map(|c| c.map(|k| k as f64));
let e = |a: usize| [0, 1, 2].map(|k| x[a][k] - x[0][k]);
let (a, b, c) = (e(1), e(2), e(3));
let vol = a[0] * (b[1] * c[2] - b[2] * c[1]) - a[1] * (b[0] * c[2] - b[2] * c[0]) + a[2] * (b[0] * c[1] - b[1] * c[0]);
if vol < 0.0 {
v.swap(1, 2);
}
m.set_element(next, kind, v.iter().map(|c| at(*c)).collect()).unwrap();
next += 1;
}
}
let all: Vec<u32> = m.elements().keys().copied().collect();
let nodes: Vec<u32> = m.nodes().keys().copied().collect();
let pick = |f: &dyn Fn([f64; 3]) -> bool| -> Vec<u32> { nodes.iter().copied().filter(|l| f(m.node(*l).unwrap().xyz)).collect() };
let (x0, x1, y0, z0) = (pick(&|p| p[0] == 0.0), pick(&|p| p[0] == LENGTH), pick(&|p| p[1] == 0.0), pick(&|p| p[2] == 0.0));
m.add_set("all", Set { elements: all, nodes: nodes.clone(), ..Set::default() }).unwrap();
for (name, set) in [("x0", x0), ("x1", x1), ("y0", y0), ("z0", z0)] {
m.add_set(name, Set { nodes: set, ..Set::default() }).unwrap();
}
let thermal = Thermal { expansion: Some(Directional::Isotropic(ALPHA)), ..Thermal::default() };
m.add_material(
"Mat",
Material {
elastic: Some(Elastic::Isotropic { young: YOUNG, poisson: 0.0 }),
density: Some(1.0),
thermal: Some(thermal),
plastic,
..Material::default()
},
)
.unwrap();
let integration = if kind == ElementKind::Hex8 { Integration::Reduced } else { Integration::Full };
let formulation = Formulation { integration, ..Formulation::default() };
m.add_section("bar", Section { set: "all".into(), material: "Mat".into(), kind: SectionKind::Solid {}, formulation }).unwrap();
let mut step = Step::new("Heat", Procedure::ExplicitDynamic(ExplicitDynamic::new(20.0)));
let mut bcs = vec![("x0", Symmetry::X), ("y0", Symmetry::Y), ("z0", Symmetry::Z)];
if clamped {
bcs.push(("x1", Symmetry::X));
}
for (set, plane) in bcs {
let name = format!("hold_{set}");
m.add_boundary_condition(&name, BoundaryCondition::Symmetry { target: Target::Set(set.into()), plane }).unwrap();
step.boundary_conditions.insert(name);
}
m.add_load("warm", Load::Temperature { target: Target::Set("all".into()), value: DT }).unwrap();
step.loads.insert("warm".into());
m.add_step(step, None).unwrap();
m
}
#[test]
fn a_free_bar_expands_without_stress() {
for kind in [ElementKind::Hex8, ElementKind::Tet4] {
let r = solve_explicit(&bar(kind, false, None), None, &Limits::default()).unwrap();
let last = r.frames.last().unwrap();
let tip = r.nodes.iter().position(|l| *l == 5).unwrap();
let want = ALPHA * DT * LENGTH;
assert!((last.displacement[tip][0] - want).abs() < 0.02 * want, "{kind:?}: {:?} vs {want}", last.displacement[tip]);
let worst = last.stress.iter().flatten().fold(0.0f64, |m, s| m.max(s.abs()));
assert!(worst < 0.02 * YOUNG * ALPHA * DT, "{kind:?}: {worst}");
}
}
#[test]
fn a_clamped_bar_carries_minus_e_alpha_dt_up_to_yield() {
let sigma = -YOUNG * ALPHA * DT;
for kind in [ElementKind::Hex8, ElementKind::Tet4] {
let r = solve_explicit(&bar(kind, true, None), None, &Limits::default()).unwrap();
for s in &r.frames.last().unwrap().stress {
assert!((s[0] - sigma).abs() < 0.02 * sigma.abs(), "{kind:?}: {s:?}");
}
// Perfectly plastic at half that stress: the bar yields and the stress stays at −σ_y.
let plastic = Plastic::Bilinear { yield_stress: 0.5 * sigma.abs(), hardening_modulus: 0.0, hardening: Hardening::Isotropic };
let r = solve_explicit(&bar(kind, true, Some(plastic)), None, &Limits::default()).unwrap();
let last = r.frames.last().unwrap();
for (s, ep) in last.stress.iter().zip(&last.plastic_strain) {
let mises = modelcraft_fem::von_mises(s);
assert!((mises - 0.5 * sigma.abs()).abs() < 0.02 * sigma.abs(), "{kind:?}: {s:?}");
assert!(*ep > 0.0, "{kind:?}: plastic strain {ep}");
}
}
}
#[test]
fn orthotropic_expansion_and_heat_loads_are_refused() {
let mut m = bar(ElementKind::Hex8, false, None);
let thermal = Thermal { expansion: Some(Directional::Orthotropic([1e-5, 2e-5, 1e-5])), ..Thermal::default() };
m.replace_material(
"Mat",
Material {
elastic: Some(Elastic::Isotropic { young: YOUNG, poisson: 0.0 }),
density: Some(1.0),
thermal: Some(thermal),
..Material::default()
},
)
.unwrap();
assert!(solve_explicit(&m, None, &Limits::default()).unwrap_err().to_string().contains("orthotropic thermal expansion"));
}