Files
OneCAD-Simulation/crates/fem/src/elastoplastic.rs
T
jyvikoandClaude Opus 5.5 72940fe78f fem: J2 plasticity at the implicit material point, finite and small strain
The radial return of the explicit update is now J2::radial_return, called by
both solvers, with the consistent tangent J2::tangent (Simo & Hughes 1998,
Box 3.2). The new elastoplastic module gives the implicit material point:
Simo's multiplicative plasticity with the exponential return map in the
principal logarithmic strains (CMAME 99, 1992) and its spatial tangent through
the Daleckii-Krein divided differences of the logarithm, and the additive
radial return of small strain with kinematic hardening. MaterialModel::Plastic
carries it, decoupled (deviatoric return, Hencky volume) so hybrid elements
take it. The element loops of finite.rs gain small-strain kinematics for
inelastic materials and pass history through the mixed path. Every tangent is
checked against central differences, element by element, from a plastic
history.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 06:03:36 -04:00

408 lines
20 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.
//! Von Mises (J2) elastoplasticity at a material point of implicit (Newton) analysis: the
//! stress after an increment and its consistent tangent, at finite strain and at small strain.
//! Both kinematics call the radial return of [`crate::plasticity`], the update explicit
//! dynamics uses, so the yield surface, the hardening (bilinear isotropic or linear kinematic,
//! tabulated, Johnson–Cook) and the plastic strain increment of a trial state are one
//! implementation for both solvers.
//!
//! **Finite strain** (J. C. Simo, *Algorithms for static and dynamic multiplicative plasticity
//! that preserve the classical return mapping schemes of the infinitesimal theory*, CMAME
//! 99:61–112, 1992; Simo & Hughes 1998, ch. 9): the multiplicative split F = Fₑ Fₚ with the
//! elastic left Cauchy–Green tensor bₑ = F Cₚ⁻¹ Fᵀ and a Hencky (quadratic in the logarithmic
//! strain) elastic energy, so the Kirchhoff stress is τ = K ln J I + 2G dev εₑ with
//! εₑ = ½ ln bₑ. The history is Cₚ⁻¹ (stored as Cₚ⁻¹ − I, so the virgin state is zeros) and the
//! equivalent plastic strain. An increment freezes Cₚ⁻¹ at its committed value: the trial
//! bₑᵗʳ = F Cₚ⁻¹ Fᵀ, its principal stretches and directions, the trial logarithmic strains
//! ε_A = ½ ln λ_A², and then the **return map of the infinitesimal theory on the principal
//! values** (the exponential map integrates the flow rule exactly in the principal frame, which
//! the isotropy of the model keeps fixed during the return). The update writes back
//! bₑ = Σ exp(2 εₑ,A) n_A ⊗ n_A and Cₚ⁻¹ = F⁻¹ bₑ F⁻ᵀ, which keeps det Cₚ = 1 (plastic flow
//! is isochoric).
//!
//! **Its tangent.** With Cₚ⁻¹ frozen, the Lie derivative of bₑᵗʳ vanishes, so for a rate of
//! deformation d the trial strain changes by dεᵗʳ = ½ (∂ ln b/∂b) : (d b + b d), and
//! L_v τ = 𝔻ᵉᵖ : dεᵗʳ − (d τ + τ d), with 𝔻ᵉᵖ the algorithmic tangent of the return map
//! ([`crate::plasticity::J2::tangent`]). In the principal frame of b the derivative of the
//! logarithm is the Daleckii–Krein divided difference, so dεᵗʳ_AB = θ_AB d_AB with θ_AA = 1 and
//! θ_AB = ½ (λ²_A + λ²_B) ln(λ²_A/λ²_B)/(λ²_A − λ²_B) = artanh(r)/r, r = (λ²_A − λ²_B)/(λ²_A + λ²_B),
//! smooth through equal stretches (its series 1 + r²/3 + r⁴/5 + … near r = 0), so uniaxial and
//! undeformed states need no special case (Simo 1992, §3.3, gives the same tangent in spectral
//! form; R. Bhatia, *Matrix Analysis*, Springer 1997, §V.3, for the divided differences). The
//! tangent is symmetric for isotropic hardening: 𝔻ᵉᵖ and the θ scaling share the principal
//! frame.
//!
//! **Kinematic hardening at finite strain** is refused: a back stress is not coaxial with bₑ, so
//! the principal-frame return no longer holds, and it needs a formulation of its own (for
//! example the Lagrangian logarithmic strain space of C. Miehe, N. Apel, M. Lambrecht, CMAME
//! 191:5383–5425, 2002). It works with small strain.
//!
//! **Small strain** (Simo & Hughes 1998, §3.3): ε = εₑ + εₚ additively, the history the plastic
//! strain εₚ, the back stress α and the equivalent plastic strain; the trial εₑ = ε − εₚ,ₙ goes
//! through the same return, and εₚ advances by √(3/2) Δε̄ₚ n.
//!
//! **Mixed (hybrid) elements** take the isochoric part (the deviatoric return, without K) and the
//! volumetric energy U(J) = K/2 (ln J)², U′ = K ln J / J (finite strain), or U = K/2 (J − 1)² with
//! J the 1 + tr ε of small strain: J2 plasticity is pressure insensitive and isochoric, so the
//! split is exact.
//!
//! **History layout** per integration point ([`HISTORY`] values): 0–5 Cₚ⁻¹ − I (finite strain,
//! tensor components) or εₚ (small strain, engineering shears), 6–11 the back stress α, 12 the
//! equivalent plastic strain.
use crate::constitutive::{Response, Tangent};
use crate::dense::{inverse3, symmetric_eigen};
use crate::plasticity::{J2, PointState, Return};
use crate::rotation::{IDENTITY, Mat3, mul, tensor, transpose, voigt};
use crate::{Error, Result};
/// History values per integration point.
pub const HISTORY: usize = 13;
/// Where the equivalent plastic strain is in a point's history.
pub const PLASTIC_STRAIN: usize = 12;
/// History value `i` (0 when the slice is short).
pub(crate) fn value(h: &[f64], i: usize) -> f64 {
h.get(i).copied().unwrap_or(0.0)
}
/// Cₚ⁻¹ of a finite-strain history (stored as Cₚ⁻¹ − I).
fn plastic_stretch(h: &[f64]) -> Mat3 {
let stored = tensor(&std::array::from_fn(|k| value(h, k)));
std::array::from_fn(|i| std::array::from_fn(|j| IDENTITY[i][j] + stored[i][j]))
}
/// Store Cₚ⁻¹ as Cₚ⁻¹ − I in the first six history values.
fn store_plastic_stretch(cp: &Mat3, h: &mut [f64]) {
let v = voigt(cp);
for (k, slot) in h.iter_mut().take(6).enumerate() {
*slot = v[k] - if k < 3 { 1.0 } else { 0.0 };
}
}
/// artanh(r)/r, smooth through r = 0.
fn log_divided_difference(r: f64) -> f64 {
if r.abs() < 1e-4 {
let r2 = r * r;
1.0 + r2 * (1.0 / 3.0 + r2 * (1.0 / 5.0 + r2 / 7.0))
} else {
r.atanh() / r
}
}
/// The state a return starts from: the committed back stress and plastic strain, at the point's
/// temperature (Johnson–Cook softening; room temperature without one).
fn start(j2: &J2, committed: &[f64], temperature: Option<f64>) -> PointState {
let mut s = PointState::new(temperature.unwrap_or_else(|| j2.initial_temperature()));
s.back = std::array::from_fn(|k| value(committed, 6 + k));
s.plastic_strain = value(committed, PLASTIC_STRAIN);
s
}
fn singular() -> Error {
Error::Degenerate("the plastic material point has no tangent: its hardening falls faster than −3G (a softening material localises)".into())
}
/// The deviatoric trial stress 2G dev ε of a strain 6-vector (engineering shears), as tensor
/// components.
fn trial_deviator(j2: &J2, e: &[f64; 6]) -> [f64; 6] {
let mean = (e[0] + e[1] + e[2]) / 3.0;
let g2 = 2.0 * j2.shear;
[g2 * (e[0] - mean), g2 * (e[1] - mean), g2 * (e[2] - mean), j2.shear * e[3], j2.shear * e[4], j2.shear * e[5]]
}
/// Finite strain: the isochoric (deviatoric) Kirchhoff stress and its spatial tangent at
/// deformation gradient `f`, from the history `committed` (written, updated, to `updated`). Add
/// the volumetric part with [`volumetric`] (or the mixed element's pressure) for the whole
/// response.
pub fn finite_isochoric(j2: &J2, f: &Mat3, temperature: Option<f64>, committed: &[f64], updated: &mut [f64]) -> Result<Response> {
if let Some(crate::plasticity::Flow::Linear { kinematic: true, .. }) = j2.flow {
return Err(Error::NotSupported(
"kinematic hardening at finite strain (its back stress is not coaxial with the elastic stretch; use nonlinear_geometry false or isotropic hardening)"
.into(),
));
}
let b = mul(&mul(f, &plastic_stretch(committed)), &transpose(f));
let (l, q) = symmetric_eigen(&b)
.filter(|(l, _)| l.iter().all(|x| *x > 0.0))
.ok_or_else(|| Error::Degenerate("the elastic stretch of a plastic material point is not positive (the material is inverted)".into()))?;
let eps = l.map(|x| 0.5 * x.ln());
let mut state = start(j2, committed, temperature);
let mut trial = trial_deviator(j2, &[eps[0], eps[1], eps[2], 0.0, 0.0, 0.0]);
let ret: Option<Return> = j2.radial_return(&mut state, &mut trial, 0.0);
// Back to the global frame: τ = Σ τ_A q_A ⊗ q_A, n likewise.
let spectral = |v: [f64; 3]| -> Mat3 { std::array::from_fn(|i| std::array::from_fn(|j| (0..3).map(|a| v[a] * q[i][a] * q[j][a]).sum())) };
let tau = voigt(&spectral([trial[0], trial[1], trial[2]]));
let global = ret.map(|r| Return { normal: voigt(&spectral([r.normal[0], r.normal[1], r.normal[2]])), ..r });
let d = j2.tangent(global.as_ref(), false).ok_or_else(singular)?;
// θ_AB of the logarithm's derivative.
let theta: Mat3 =
std::array::from_fn(|a| std::array::from_fn(|bb| if a == bb { 1.0 } else { log_divided_difference((l[a] - l[bb]) / (l[a] + l[bb])) }));
let tau_m = tensor(&tau);
let qt = transpose(&q);
let mut c: Tangent = [[0.0; 6]; 6];
for (col, &(i, j)) in crate::constitutive::PAIRS.iter().enumerate() {
// The unit rate of deformation of this column (engineering shear 1: d_ij = d_ji = ½).
let mut dm = [[0.0; 3]; 3];
let half = if i == j { 1.0 } else { 0.5 };
dm[i][j] = half;
dm[j][i] = half;
let mut dp = mul(&mul(&qt, &dm), &q);
for a in 0..3 {
for bb in 0..3 {
dp[a][bb] *= theta[a][bb];
}
}
let de = voigt(&mul(&mul(&q, &dp), &qt));
let de = [de[0], de[1], de[2], 2.0 * de[3], 2.0 * de[4], 2.0 * de[5]];
let spin = voigt(&{
let (x, y) = (mul(&dm, &tau_m), mul(&tau_m, &dm));
std::array::from_fn(|r| std::array::from_fn(|s| x[r][s] + y[r][s]))
});
for row in 0..6 {
c[row][col] = (0..6).map(|k| d[row][k] * de[k]).sum::<f64>() - spin[row];
}
}
if let Some(r) = ret {
// bₑ = Σ exp(2 εₑ,A) q_A ⊗ q_A with εₑ = ε − √(3/2) Δε̄ₚ n, and Cₚ⁻¹ = F⁻¹ bₑ F⁻ᵀ.
let dg = (1.5f64).sqrt() * r.plastic_strain;
let be = spectral(std::array::from_fn(|a| (2.0 * (eps[a] - dg * r.normal[a])).exp()));
let (inv, _) = inverse3(f).ok_or_else(|| Error::Degenerate("the deformation gradient is singular".into()))?;
store_plastic_stretch(&mul(&mul(&inv, &be), &transpose(&inv)), updated);
if let Some(slot) = updated.get_mut(PLASTIC_STRAIN) {
*slot = state.plastic_strain;
}
}
Ok(Response { stress: tau, tangent: c })
}
/// U′(J) and U″(J) of the Hencky volumetric energy K/2 (ln J)² (finite strain), or of
/// K/2 (J − 1)² for the dilatation J = 1 + tr ε of small strain.
pub fn volumetric(j2: &J2, j: f64, small: bool) -> Option<(f64, f64)> {
if !(j > 0.0 && j.is_finite()) {
return None;
}
let k = j2.bulk;
Some(if small { (k * (j - 1.0), k) } else { (k * j.ln() / j, k * (1.0 - j.ln()) / (j * j)) })
}
/// Small strain: the stress and the consistent tangent at total (mechanical) strain `strain`
/// (engineering shears), from the history `committed` (written, updated, to `updated`); with
/// `bulk` false only the deviatoric part (what a mixed element takes).
pub fn small(j2: &J2, strain: &[f64; 6], temperature: Option<f64>, committed: &[f64], updated: &mut [f64], bulk: bool) -> Result<Response> {
let elastic: [f64; 6] = std::array::from_fn(|k| strain[k] - value(committed, k));
let mut state = start(j2, committed, temperature);
let mut stress = trial_deviator(j2, &elastic);
let ret = j2.radial_return(&mut state, &mut stress, 0.0);
if bulk {
let p = j2.bulk * (elastic[0] + elastic[1] + elastic[2]);
for s in stress.iter_mut().take(3) {
*s += p;
}
}
let tangent = j2.tangent(ret.as_ref(), bulk).ok_or_else(singular)?;
if let Some(r) = ret {
let dg = (1.5f64).sqrt() * r.plastic_strain;
for (k, slot) in updated.iter_mut().take(6).enumerate() {
*slot = value(committed, k) + dg * r.normal[k] * if k < 3 { 1.0 } else { 2.0 };
}
for (k, slot) in updated.iter_mut().skip(6).take(6).enumerate() {
*slot = state.back[k];
}
if let Some(slot) = updated.get_mut(PLASTIC_STRAIN) {
*slot = state.plastic_strain;
}
}
Ok(Response { stress, tangent })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::constitutive::{add_pressure, push_forward};
use fieldcraft_model::{Elastic, Hardening, HardeningPoint, Plastic};
const E: f64 = 200_000.0;
const NU: f64 = 0.3;
fn j2(plastic: Plastic) -> J2 {
J2::from_model(&Elastic::Isotropic { young: E, poisson: NU }, Some(&plastic), None).unwrap()
}
fn bilinear(h: f64, hardening: Hardening) -> J2 {
j2(Plastic::Bilinear { yield_stress: 250.0, hardening_modulus: h, hardening })
}
/// The whole finite-strain response, isochoric plus the Hencky volume.
fn finite(m: &J2, f: &Mat3, committed: &[f64], updated: &mut [f64]) -> Response {
let iso = finite_isochoric(m, f, None, committed, updated).unwrap();
let j = crate::dense::det3(f);
let (p, dp) = volumetric(m, j, false).unwrap();
add_pressure(&iso, j, p, j * dp)
}
/// The pulled-back tangent C (dS = C : dE) against central differences of S along several
/// directions of F, as `constitutive::tests::check_tangent` does, from fixed history.
fn check_finite_tangent(m: &J2, f: &Mat3, committed: &[f64]) -> f64 {
let mut scratch = vec![0.0; HISTORY];
let r = finite(m, f, committed, &mut scratch);
let (inv, _) = inverse3(f).unwrap();
let t = push_forward(&inv);
let back: Tangent =
std::array::from_fn(|a| std::array::from_fn(|b| (0..6).map(|k| (0..6).map(|l| t[a][k] * r.tangent[k][l] * t[b][l]).sum::<f64>()).sum()));
let scale = back.iter().flatten().fold(0.0f64, |a, v| a.max(v.abs()));
let h = 1e-7;
let mut worst: f64 = 0.0;
for k in 0..9 {
let mut df = [[0.0; 3]; 3];
df[k / 3][k % 3] = 1.0;
df[(k + 1) % 3][(k + 2) % 3] += 0.3;
let at = |s: f64| {
let fp: Mat3 = std::array::from_fn(|i| std::array::from_fn(|j| f[i][j] + s * df[i][j]));
let mut scratch = vec![0.0; HISTORY];
let tau = finite(m, &fp, committed, &mut scratch).stress;
crate::rotation::rotate(&inverse3(&fp).unwrap().0, &tau)
};
let (sp, sm) = (at(h), at(-h));
let ftdf = mul(&transpose(f), &df);
let de = [ftdf[0][0], ftdf[1][1], ftdf[2][2], ftdf[0][1] + ftdf[1][0], ftdf[0][2] + ftdf[2][0], ftdf[1][2] + ftdf[2][1]];
for a in 0..6 {
let numeric = (sp[a] - sm[a]) / (2.0 * h);
let analytic: f64 = (0..6).map(|b| back[a][b] * de[b]).sum();
worst = worst.max((numeric - analytic).abs() / scale);
}
}
worst
}
#[test]
fn finite_strain_tangent_is_consistent_elastic_and_plastic() {
let deformations: [Mat3; 3] = [
[[1.02, 0.015, -0.005], [-0.01, 0.99, 0.02], [0.007, -0.012, 1.005]],
// Large: well into plasticity.
[[1.3, 0.15, -0.05], [-0.1, 0.85, 0.2], [0.07, -0.12, 1.05]],
// Uniaxial (two equal principal stretches).
[[1.2, 0.0, 0.0], [0.0, 0.92, 0.0], [0.0, 0.0, 0.92]],
];
let tables = Plastic::Table { points: vec![HardeningPoint { strain: 0.0, stress: 250.0 }, HardeningPoint { strain: 0.5, stress: 600.0 }] };
let jc = Plastic::JohnsonCook {
a: 350.0,
b: 275.0,
n: 0.36,
c: 0.0,
m: 1.0,
reference_strain_rate: 1.0,
room_temperature: 293.0,
melt_temperature: 1356.0,
specific_heat: None,
taylor_quinney: 0.9,
};
// A plastic history to start from: a prior stretch committed.
let mut prior = vec![0.0; HISTORY];
finite(&bilinear(2000.0, Hardening::Isotropic), &[[1.1, 0.02, 0.0], [0.0, 0.95, 0.03], [0.0, 0.0, 0.96]], &[0.0; HISTORY], &mut prior);
assert!(prior[PLASTIC_STRAIN] > 0.0);
for m in [bilinear(2000.0, Hardening::Isotropic), bilinear(0.0, Hardening::Isotropic), j2(tables), j2(jc)] {
for f in &deformations {
for committed in [&[0.0; HISTORY][..], &prior[..]] {
let worst = check_finite_tangent(&m, f, committed);
assert!(worst < 1e-7, "{:?} at {f:?}: {worst}", m.flow);
}
}
}
}
#[test]
fn finite_strain_tangent_is_symmetric_and_elastic_at_rest() {
let m = bilinear(2000.0, Hardening::Isotropic);
let mut scratch = vec![0.0; HISTORY];
let r = finite(&m, &[[1.3, 0.15, -0.05], [-0.1, 0.85, 0.2], [0.07, -0.12, 1.05]], &[0.0; HISTORY], &mut scratch);
for a in 0..6 {
for b in 0..6 {
assert!((r.tangent[a][b] - r.tangent[b][a]).abs() < 1e-9 * E, "{a} {b}");
}
}
let rest = finite(&m, &IDENTITY, &[0.0; HISTORY], &mut scratch);
let d = crate::material::isotropic(E, NU).unwrap();
for a in 0..6 {
for b in 0..6 {
assert!((rest.tangent[a][b] - d[a][b]).abs() < 1e-9 * E);
}
}
assert!(rest.stress.iter().all(|v| *v == 0.0));
}
/// Uniaxial stress along x at finite strain, from a virgin point to a logarithmic strain ε
/// in one increment (the lateral stretch found so σ_yy = 0): the Kirchhoff stress is the
/// small-strain curve in the logarithmic strain, τ = σ_y + E_t (ε − σ_y/E), because the
/// exponential map integrates a proportional path exactly.
#[test]
fn finite_uniaxial_matches_the_logarithmic_closed_form() {
let (y0, h) = (250.0, 2000.0);
let m = bilinear(h, Hardening::Isotropic);
let strain: f64 = 0.2;
let lam = strain.exp();
let sigma = |l: f64| {
let f = [[lam, 0.0, 0.0], [0.0, l, 0.0], [0.0, 0.0, l]];
let mut u = vec![0.0; HISTORY];
let r = finite(&m, &f, &[0.0; HISTORY], &mut u);
let j = lam * l * l;
(r.stress[0] / j, r.stress[1] / j, u)
};
// Secant on the lateral stretch.
let (mut a, mut b) = (1.0, 0.9);
for _ in 0..60 {
let (fa, fb) = (sigma(a).1, sigma(b).1);
if fb.abs() < 1e-11 || fb == fa {
break;
}
(a, b) = (b, b - fb * (b - a) / (fb - fa));
}
let (sxx, _, u) = sigma(b);
let j = lam * b * b;
// In Kirchhoff stress τ = J σ: τ = y0 + E_t (ε − y0/E) along the log strain path.
let et = E * h / (E + h);
let exact = y0 + et * (strain - y0 / E);
assert!((sxx * j - exact).abs() < 1e-8 * exact, "{} vs {exact}", sxx * j);
assert!((u[PLASTIC_STRAIN] - (exact - y0) / h).abs() < 1e-10);
// The plastic deformation is isochoric: det Cₚ⁻¹ = 1.
let cp = plastic_stretch(&u);
assert!((crate::dense::det3(&cp) - 1.0).abs() < 1e-12);
}
#[test]
fn small_strain_tangent_is_consistent_and_cyclic_kinematic_shows_bauschinger() {
let m = bilinear(10_000.0, Hardening::Kinematic);
let strain = [3e-3, -1e-3, 5e-4, 2e-3, -1e-3, 7e-4];
let mut prior = vec![0.0; HISTORY];
small(&m, &[2e-3, -1e-3, -1e-3, 0.0, 0.0, 0.0], None, &[0.0; HISTORY], &mut prior, true).unwrap();
for committed in [&[0.0; HISTORY][..], &prior[..]] {
let mut scratch = vec![0.0; HISTORY];
let r = small(&m, &strain, None, committed, &mut scratch, true).unwrap();
let h = 1e-9;
for b in 0..6 {
let at = |s: f64| {
let mut e = strain;
e[b] += s;
small(&m, &e, None, committed, &mut [0.0; HISTORY], true).unwrap().stress
};
let (p, q) = (at(h), at(-h));
for a in 0..6 {
let numeric = (p[a] - q[a]) / (2.0 * h);
assert!((numeric - r.tangent[a][b]).abs() < 1e-5 * E, "{a} {b}: {numeric} vs {}", r.tangent[a][b]);
}
}
}
// Kinematic hardening is refused at finite strain, named.
assert!(matches!(finite_isochoric(&m, &IDENTITY, None, &[0.0; HISTORY], &mut [0.0; HISTORY]), Err(Error::NotSupported(_))));
}
#[test]
fn softening_steeper_than_3g_has_no_tangent() {
let g = E / (2.0 * (1.0 + NU));
let points = vec![HardeningPoint { strain: 0.0, stress: 250.0 }, HardeningPoint { strain: 1e-4, stress: 250.0 - 4.0 * g * 1e-4 }];
let m = j2(Plastic::Table { points });
let r = small(&m, &[5e-3, -2.5e-3, -2.5e-3, 0.0, 0.0, 0.0], None, &[0.0; HISTORY], &mut [0.0; HISTORY], true);
assert!(matches!(r, Err(Error::Degenerate(_))), "{r:?}");
}
}