forked from 1b-agent/fieldcraft-upstream
Temperatures now strain the material in nonlinear steps instead of being refused. At finite strain the deformation gradient splits multiplicatively, F = F_m F_theta with F_theta = diag(1 + alpha_i dT) at each point: the material sees F_m and the loops run on the thermally expanded volume J_theta dV0 (mixed elements project det F_m), so the tangent stays consistent. At small strain the split is additive, and elements on their linear stiffness take the equivalent nodal forces as a dead load. The step's temperatures are its temperature loads, or the steady conduction of its temperature conditions and heat loads (one-way coupling, as the linear coupled step); both at once are refused. They ramp with the load factor from each material's reference temperature. Johnson-Cook plasticity softens at the point's temperature. Verified: free expansion and a fully constrained neo-Hookean block at 20 % thermal stretch, full and hybrid (exact); a bar held at its ends heated past yield (exact); the linear limit and the conducted temperatures against the linear solvers; tangents by central differences with a temperature field. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
556 lines
24 KiB
Rust
556 lines
24 KiB
Rust
//! Hypoelastic–plastic stress update of an isotropic material: von Mises (J2) plasticity with
|
||
//! isotropic, linear kinematic or Johnson–Cook hardening, integrated by radial return, and
|
||
//! Johnson–Cook damage.
|
||
//!
|
||
//! **Radial return** (J. C. Simo, T. J. R. Hughes, *Computational Inelasticity*, Springer 1998,
|
||
//! §3.3, Box 3.1 and 3.2; R. D. Krieg, D. B. Krieg, J. Press. Vessel Technol. 99(4):510–515,
|
||
//! 1977, for its accuracy): with shear modulus G and bulk modulus K, the pressure takes the
|
||
//! volumetric increment elastically, p ← p + K Δθ, and the deviator the elastic trial
|
||
//! s* = s + 2G Δe. With the back stress α (kinematic hardening), ξ* = s* − α and the trial
|
||
//! von Mises stress q* = √(3/2)‖ξ*‖. If q* exceeds the current yield stress, the equivalent
|
||
//! plastic strain increment Δε_p solves the consistency condition
|
||
//! q* − 3G Δε_p − H_k Δε_p = σ_y(ε_p + Δε_p, Δε_p / Δt, T), and with the flow direction
|
||
//! n = ξ*/‖ξ*‖: s = s* − 2G √(3/2) Δε_p n, α ← α + √(2/3) H_k Δε_p n (Prager's linear kinematic
|
||
//! rule; H_k the kinematic modulus, so the uniaxial stress rises by H_k Δε_p either way).
|
||
//! - linear hardening: Δε_p = (q* − σ_y)/(3G + H) in closed form;
|
||
//! - a hardening table: piecewise linear, solved exactly segment by segment;
|
||
//! - Johnson–Cook (rate and temperature dependent): a bracketed scalar Newton iteration with
|
||
//! bisection safeguard on [0, (q* − σ_y)/(3G)] (the root is in it because σ_y only grows with
|
||
//! Δε_p), capped at [`MAX_ITERATIONS`].
|
||
//!
|
||
//! **Johnson–Cook** flow stress σ_y = (A + B ε_pⁿ)(1 + C ln ε̇*)(1 − T*ᵐ) (G. R. Johnson,
|
||
//! W. H. Cook, Proc. 7th Int. Symp. Ballistics, 1983) with ε̇* = ε̇_p / ε̇₀ taken as at least 1
|
||
//! (the logarithm stays ≥ 0, which also removes its singularity at rest) and
|
||
//! T* = (T − T_room)/(T_melt − T_room) clamped to [0, 1]. The temperature is that at the start
|
||
//! of the increment; plastic work heats the material adiabatically, ΔT = χ q Δε_p / (ρ c_p).
|
||
//!
|
||
//! **Johnson–Cook damage** (G. R. Johnson, W. H. Cook, Eng. Fract. Mech. 21(1):31–48, 1985):
|
||
//! D ← D + Δε_p / ε_f with ε_f = (D1 + D2 e^{D3 σ*})(1 + D4 ln ε̇*)(1 + D5 T*), σ* the mean
|
||
//! stress over the von Mises stress after the increment. A fracture strain below
|
||
//! [`MIN_FRACTURE_STRAIN`] counts as that (some published sets give negative values at high
|
||
//! triaxiality, where the point fails at once). The point has failed at D ≥ 1.
|
||
//!
|
||
//! **One return map for both solvers.** [`J2::radial_return`] is the return above; explicit
|
||
//! dynamics calls it through [`J2::update`] on its hypoelastic stress, the implicit material
|
||
//! point ([`crate::elastoplastic`]) on its trial elastic strain, small or logarithmic, and adds
|
||
//! the consistent tangent [`J2::tangent`].
|
||
//!
|
||
//! The strain increment the update takes is the **mechanical** one: a caller with initial or
|
||
//! thermal strains subtracts their increment first.
|
||
//!
|
||
//! Stress 6-vectors hold tensor components (xx, yy, zz, xy, xz, yz), strain increments
|
||
//! engineering shears.
|
||
|
||
use fieldcraft_model::{Damage, Elastic, Hardening, Plastic};
|
||
|
||
use crate::material::von_mises;
|
||
use crate::{Error, Result};
|
||
|
||
/// Most iterations of the rate-dependent return mapping.
|
||
pub const MAX_ITERATIONS: u32 = 100;
|
||
|
||
/// The smallest Johnson–Cook fracture strain.
|
||
pub const MIN_FRACTURE_STRAIN: f64 = 1e-6;
|
||
|
||
/// The flow stress of a plastic material.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub enum Flow {
|
||
/// σ_y = y0 + h ε_p (isotropic) or a surface of radius y0 moving with modulus h (kinematic).
|
||
Linear {
|
||
y0: f64,
|
||
h: f64,
|
||
kinematic: bool,
|
||
},
|
||
/// Isotropic hardening by (plastic strain, yield stress) points, constant after the last.
|
||
Table(Vec<(f64, f64)>),
|
||
JohnsonCook {
|
||
a: f64,
|
||
b: f64,
|
||
n: f64,
|
||
c: f64,
|
||
m: f64,
|
||
rate0: f64,
|
||
room: f64,
|
||
melt: f64,
|
||
},
|
||
}
|
||
|
||
/// A hypoelastic, possibly plastic, isotropic material.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct J2 {
|
||
pub shear: f64,
|
||
pub bulk: f64,
|
||
pub flow: Option<Flow>,
|
||
/// Johnson–Cook damage constants D1–D5.
|
||
pub damage: Option<[f64; 5]>,
|
||
/// χ / c_p: the temperature rise per plastic work per mass (0 without heating).
|
||
pub heating: f64,
|
||
}
|
||
|
||
/// The state of a material point.
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
pub struct PointState {
|
||
pub stress: [f64; 6],
|
||
pub back: [f64; 6],
|
||
pub plastic_strain: f64,
|
||
pub temperature: f64,
|
||
pub damage: f64,
|
||
}
|
||
|
||
impl PointState {
|
||
pub fn new(temperature: f64) -> PointState {
|
||
PointState { stress: [0.0; 6], back: [0.0; 6], plastic_strain: 0.0, temperature, damage: 0.0 }
|
||
}
|
||
}
|
||
|
||
/// What a radial return did: what the consistent tangent needs.
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
pub struct Return {
|
||
/// The equivalent plastic strain increment Δε_p.
|
||
pub plastic_strain: f64,
|
||
/// The trial von Mises stress of ξ* = s* − α, q* = √(3/2)‖ξ*‖.
|
||
pub trial: f64,
|
||
/// The flow direction ξ*/‖ξ*‖ (tensor components).
|
||
pub normal: [f64; 6],
|
||
/// The kinematic hardening modulus H_k.
|
||
pub kinematic: f64,
|
||
/// The slope of the flow stress against the plastic strain at the end of the increment.
|
||
pub slope: f64,
|
||
}
|
||
|
||
/// What one update did.
|
||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||
pub struct Increment {
|
||
/// The equivalent plastic strain increment.
|
||
pub plastic_strain: f64,
|
||
/// Plastic work per volume, q Δε_p.
|
||
pub plastic_work: f64,
|
||
}
|
||
|
||
/// ‖s‖² of a symmetric tensor's 6-vector (tensor components).
|
||
fn norm2(s: &[f64; 6]) -> f64 {
|
||
s[0] * s[0] + s[1] * s[1] + s[2] * s[2] + 2.0 * (s[3] * s[3] + s[4] * s[4] + s[5] * s[5])
|
||
}
|
||
|
||
impl Flow {
|
||
/// The flow stress at plastic strain `e`, plastic strain rate `rate` and temperature `t`, and
|
||
/// its derivative with respect to the plastic strain increment over an increment `dt` (for
|
||
/// the Newton iteration).
|
||
fn stress(&self, e: f64, rate: f64, t: f64, dt: f64) -> (f64, f64) {
|
||
match *self {
|
||
Flow::Linear { y0, h, kinematic } => {
|
||
if kinematic {
|
||
(y0, 0.0)
|
||
} else {
|
||
(y0 + h * e, h)
|
||
}
|
||
}
|
||
Flow::Table(ref points) => table(points, e),
|
||
Flow::JohnsonCook { a, b, n, c, m, rate0, room, melt } => {
|
||
let hardening = a + b * e.max(0.0).powf(n);
|
||
let d_hardening = if e > 0.0 {
|
||
b * n * e.powf(n - 1.0)
|
||
} else if n < 1.0 && b > 0.0 {
|
||
f64::INFINITY
|
||
} else {
|
||
b * n
|
||
};
|
||
let ratio = rate / rate0;
|
||
let (rate_term, d_rate) = if ratio > 1.0 && dt > 0.0 { (1.0 + c * ratio.ln(), c / (rate * dt)) } else { (1.0, 0.0) };
|
||
let homologous = ((t - room) / (melt - room)).clamp(0.0, 1.0);
|
||
let thermal = if homologous > 0.0 { 1.0 - homologous.powf(m) } else { 1.0 };
|
||
(hardening * rate_term * thermal, (d_hardening * rate_term + hardening * d_rate) * thermal)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The kinematic hardening modulus.
|
||
fn kinematic(&self) -> f64 {
|
||
match *self {
|
||
Flow::Linear { h, kinematic: true, .. } => h,
|
||
_ => 0.0,
|
||
}
|
||
}
|
||
|
||
/// The rate sensitivity's reference rate and the homologous temperature (for damage).
|
||
fn rate_and_temperature(&self, t: f64) -> (f64, f64) {
|
||
match *self {
|
||
Flow::JohnsonCook { rate0, room, melt, .. } => (rate0, ((t - room) / (melt - room)).clamp(0.0, 1.0)),
|
||
_ => (1.0, 0.0),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The yield stress of a hardening table at plastic strain `e`, and its slope.
|
||
fn table(points: &[(f64, f64)], e: f64) -> (f64, f64) {
|
||
for w in points.windows(2) {
|
||
if let [(e0, s0), (e1, s1)] = *w
|
||
&& e < e1
|
||
{
|
||
let slope = (s1 - s0) / (e1 - e0);
|
||
return (s0 + slope * (e - e0).max(0.0), slope);
|
||
}
|
||
}
|
||
(points.last().map_or(0.0, |p| p.1), 0.0)
|
||
}
|
||
|
||
impl J2 {
|
||
/// The material of a model's isotropic elastic constants with its plasticity and damage.
|
||
pub fn from_model(elastic: &Elastic, plastic: Option<&Plastic>, damage: Option<&Damage>) -> Result<J2> {
|
||
let Elastic::Isotropic { young, poisson } = *elastic else {
|
||
return Err(Error::NotSupported("plasticity of an orthotropic material".into()));
|
||
};
|
||
let shear = young / (2.0 * (1.0 + poisson));
|
||
let bulk = young / (3.0 * (1.0 - 2.0 * poisson));
|
||
let mut heating = 0.0;
|
||
let flow = plastic.map(|p| match p {
|
||
Plastic::Bilinear { yield_stress, hardening_modulus, hardening } => {
|
||
Flow::Linear { y0: *yield_stress, h: *hardening_modulus, kinematic: *hardening == Hardening::Kinematic }
|
||
}
|
||
Plastic::Table { points } => Flow::Table(points.iter().map(|p| (p.strain, p.stress)).collect()),
|
||
Plastic::JohnsonCook { a, b, n, c, m, reference_strain_rate, room_temperature, melt_temperature, specific_heat, taylor_quinney } => {
|
||
heating = specific_heat.map_or(0.0, |cp| taylor_quinney / cp);
|
||
Flow::JohnsonCook {
|
||
a: *a,
|
||
b: *b,
|
||
n: *n,
|
||
c: *c,
|
||
m: *m,
|
||
rate0: *reference_strain_rate,
|
||
room: *room_temperature,
|
||
melt: *melt_temperature,
|
||
}
|
||
}
|
||
});
|
||
let damage = damage.map(|Damage::JohnsonCook { d1, d2, d3, d4, d5 }| [*d1, *d2, *d3, *d4, *d5]);
|
||
if !(shear > 0.0 && bulk > 0.0 && shear.is_finite() && bulk.is_finite()) {
|
||
return Err(Error::Invalid(format!("E = {young}, ν = {poisson} are not a stable material")));
|
||
}
|
||
Ok(J2 { shear, bulk, flow, damage, heating })
|
||
}
|
||
|
||
/// Whether the flow stress depends on the temperature (Johnson–Cook softening).
|
||
pub fn is_temperature_dependent(&self) -> bool {
|
||
matches!(self.flow, Some(Flow::JohnsonCook { .. }))
|
||
}
|
||
|
||
/// The temperature a point starts at.
|
||
pub fn initial_temperature(&self) -> f64 {
|
||
match self.flow {
|
||
Some(Flow::JohnsonCook { room, .. }) => room,
|
||
_ => 0.0,
|
||
}
|
||
}
|
||
|
||
/// Update `state` over an increment of length `dt` with the deviatoric strain increment
|
||
/// `dev` (engineering shears; its trace is ignored) and the volumetric strain increment
|
||
/// `vol`, at the current density `density` (for heating).
|
||
pub fn update(&self, state: &mut PointState, dev: &[f64; 6], vol: f64, dt: f64, density: f64) -> Increment {
|
||
let s = &mut state.stress;
|
||
let mean = (s[0] + s[1] + s[2]) / 3.0;
|
||
let tr = (dev[0] + dev[1] + dev[2]) / 3.0;
|
||
let g2 = 2.0 * self.shear;
|
||
// Trial deviator.
|
||
let mut trial = [s[0] - mean + g2 * (dev[0] - tr), s[1] - mean + g2 * (dev[1] - tr), s[2] - mean + g2 * (dev[2] - tr), 0.0, 0.0, 0.0];
|
||
for k in 3..6 {
|
||
trial[k] = s[k] + self.shear * dev[k];
|
||
}
|
||
let pressure = mean + self.bulk * vol;
|
||
let mut out = Increment::default();
|
||
if let Some(r) = self.radial_return(state, &mut trial, dt) {
|
||
let q_new = r.trial - (3.0 * self.shear + r.kinematic) * r.plastic_strain;
|
||
out = Increment { plastic_strain: r.plastic_strain, plastic_work: q_new * r.plastic_strain };
|
||
if self.heating > 0.0 && density > 0.0 {
|
||
state.temperature += self.heating * out.plastic_work / density;
|
||
}
|
||
}
|
||
for k in 0..3 {
|
||
trial[k] += pressure;
|
||
}
|
||
state.stress = trial;
|
||
if let (Some(d), Some(flow)) = (self.damage, &self.flow)
|
||
&& out.plastic_strain > 0.0
|
||
{
|
||
let q = von_mises(&state.stress);
|
||
let triaxiality = if q > 0.0 { pressure / q } else { 0.0 };
|
||
let (rate0, homologous) = flow.rate_and_temperature(state.temperature);
|
||
let rate = if dt > 0.0 { out.plastic_strain / dt / rate0 } else { 0.0 };
|
||
let log_rate = if rate > 1.0 { rate.ln() } else { 0.0 };
|
||
let fracture = (d[0] + d[1] * (d[2] * triaxiality).exp()) * (1.0 + d[3] * log_rate) * (1.0 + d[4] * homologous);
|
||
state.damage += out.plastic_strain / fracture.max(MIN_FRACTURE_STRAIN);
|
||
}
|
||
out
|
||
}
|
||
|
||
/// The radial return of a trial deviator `trial` (tensor components) from `state`: when the
|
||
/// trial von Mises stress exceeds the yield stress, `trial` is moved back to the yield
|
||
/// surface, the back stress and the equivalent plastic strain of `state` are advanced, and
|
||
/// what the consistent tangent needs is returned; `None` when the point stays elastic.
|
||
pub fn radial_return(&self, state: &mut PointState, trial: &mut [f64; 6], dt: f64) -> Option<Return> {
|
||
let flow = self.flow.as_ref()?;
|
||
let xi: [f64; 6] = std::array::from_fn(|k| trial[k] - state.back[k]);
|
||
let norm = norm2(&xi).sqrt();
|
||
let q = (1.5f64).sqrt() * norm;
|
||
let (yield_now, _) = flow.stress(state.plastic_strain, 0.0, state.temperature, dt);
|
||
if !(q > yield_now && norm > 0.0) {
|
||
return None;
|
||
}
|
||
let hk = flow.kinematic();
|
||
let de = self.plastic_increment(flow, state, q, yield_now, hk, dt);
|
||
let n: [f64; 6] = xi.map(|x| x / norm);
|
||
let radial = 2.0 * self.shear * (1.5f64).sqrt() * de;
|
||
let shift = (2.0f64 / 3.0).sqrt() * hk * de;
|
||
for k in 0..6 {
|
||
trial[k] -= radial * n[k];
|
||
state.back[k] += shift * n[k];
|
||
}
|
||
let rate = if dt > 0.0 { de / dt } else { 0.0 };
|
||
let (_, slope) = flow.stress(state.plastic_strain + de, rate, state.temperature, dt);
|
||
state.plastic_strain += de;
|
||
Some(Return { plastic_strain: de, trial: q, normal: n, kinematic: hk, slope })
|
||
}
|
||
|
||
/// The consistent (algorithmic) tangent of the return map, dσ/dε of the stress after the
|
||
/// update against the total strain of the increment (engineering-shear columns): the
|
||
/// elasticity K I ⊗ I + 2G ℙ while elastic, and after a return
|
||
/// K I ⊗ I + 2G θ ℙ − 2G θ̄ n ⊗ n with θ = 1 − 3G Δε_p / q*, θ̄ = 1/(1 + H/3G) − (1 − θ) and
|
||
/// H the slope of the flow stress plus the kinematic modulus at the end of the increment
|
||
/// (Simo & Hughes 1998, Box 3.2, eq. 3.3.10, in the equivalent plastic strain). `bulk`
|
||
/// leaves out the volumetric part K I ⊗ I when false (the isochoric part a mixed element
|
||
/// takes). `None` when 3G + H is not positive: the point softens faster than it unloads
|
||
/// elastically and has no tangent.
|
||
pub fn tangent(&self, r: Option<&Return>, bulk: bool) -> Option<[[f64; 6]; 6]> {
|
||
let g = self.shear;
|
||
let (theta, theta_bar, n) = match r {
|
||
None => (1.0, 0.0, [0.0; 6]),
|
||
Some(r) => {
|
||
let stiff = 3.0 * g + r.slope + r.kinematic;
|
||
if !(stiff > 0.0 && stiff.is_finite() && r.trial > 0.0) {
|
||
return None;
|
||
}
|
||
let theta = 1.0 - 3.0 * g * r.plastic_strain / r.trial;
|
||
(theta, 3.0 * g / stiff - (1.0 - theta), r.normal)
|
||
}
|
||
};
|
||
let k = if bulk { self.bulk } else { 0.0 };
|
||
let identity = crate::constitutive::identity4();
|
||
let mut d = [[0.0; 6]; 6];
|
||
for a in 0..6 {
|
||
for b in 0..6 {
|
||
let (ia, ib) = (if a < 3 { 1.0 } else { 0.0 }, if b < 3 { 1.0 } else { 0.0 });
|
||
d[a][b] = k * ia * ib + 2.0 * g * theta * (identity[a][b] - ia * ib / 3.0) - 2.0 * g * theta_bar * n[a] * n[b];
|
||
}
|
||
}
|
||
Some(d)
|
||
}
|
||
|
||
/// The plastic strain increment of a trial state that yields.
|
||
fn plastic_increment(&self, flow: &Flow, state: &PointState, q: f64, yield_now: f64, hk: f64, dt: f64) -> f64 {
|
||
let g3 = 3.0 * self.shear;
|
||
let (e0, t) = (state.plastic_strain, state.temperature);
|
||
match flow {
|
||
Flow::Linear { h, kinematic, .. } => (q - yield_now) / (g3 + if *kinematic { hk } else { *h }),
|
||
Flow::Table(points) => {
|
||
// Walk the segments from the current one: on a segment of slope h starting at
|
||
// (e_a, s_a), q − 3G Δ = s_a + h (e0 + Δ − e_a).
|
||
for w in points.windows(2) {
|
||
let [(ea, sa), (eb, sb)] = *w else { continue };
|
||
if eb <= e0 {
|
||
continue;
|
||
}
|
||
let h = (sb - sa) / (eb - ea);
|
||
let at = sa + h * (e0 - ea);
|
||
let de = (q - at) / (g3 + h);
|
||
if e0 + de <= eb {
|
||
return de.max(0.0);
|
||
}
|
||
}
|
||
// Beyond the table: perfectly plastic at the last stress.
|
||
let last = points.last().map_or(yield_now, |p| p.1);
|
||
((q - last) / g3).max(0.0)
|
||
}
|
||
Flow::JohnsonCook { .. } => {
|
||
let residual = |de: f64| {
|
||
let rate = if dt > 0.0 { de / dt } else { 0.0 };
|
||
let (y, dy) = flow.stress(e0 + de, rate, t, dt);
|
||
(q - g3 * de - y, -g3 - dy)
|
||
};
|
||
let (mut lo, mut hi) = (0.0, ((q - yield_now) / g3).max(0.0));
|
||
let mut de = 0.5 * hi;
|
||
for _ in 0..MAX_ITERATIONS {
|
||
let (r, dr) = residual(de);
|
||
if r > 0.0 {
|
||
lo = de;
|
||
} else {
|
||
hi = de;
|
||
}
|
||
if r.abs() <= 1e-13 * q || hi - lo <= 1e-15 * hi {
|
||
break;
|
||
}
|
||
let newton = de - r / dr;
|
||
de = if newton.is_finite() && newton > lo && newton < hi { newton } else { 0.5 * (lo + hi) };
|
||
}
|
||
de
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use fieldcraft_model::HardeningPoint;
|
||
|
||
const E: f64 = 200_000.0;
|
||
const NU: f64 = 0.3;
|
||
|
||
fn material(plastic: Plastic) -> J2 {
|
||
J2::from_model(&Elastic::Isotropic { young: E, poisson: NU }, Some(&plastic), None).unwrap()
|
||
}
|
||
|
||
/// Uniaxial stress at a constant strain rate: the axial strain increments by `de` while the
|
||
/// lateral strain increment is found by a secant iteration on the point update itself so
|
||
/// the lateral stresses vanish. Returns (axial strain, axial stress, plastic strain) per step.
|
||
fn uniaxial(m: &J2, steps: &[f64], dt: f64) -> Vec<(f64, f64, f64)> {
|
||
let mut s = PointState::new(m.initial_temperature());
|
||
let mut out = Vec::new();
|
||
let mut strain = 0.0;
|
||
for de in steps {
|
||
let run = |lateral: f64| {
|
||
let mut t = s;
|
||
let d = [*de, lateral * de, lateral * de, 0.0, 0.0, 0.0];
|
||
m.update(&mut t, &d, d[0] + d[1] + d[2], dt, 7.8e-9);
|
||
t
|
||
};
|
||
let (mut a, mut b) = (-NU, -0.5);
|
||
let (mut fa, mut fb) = (run(a).stress[1], run(b).stress[1]);
|
||
for _ in 0..100 {
|
||
if fb == fa || fb.abs() < 1e-12 {
|
||
break;
|
||
}
|
||
let c = b - fb * (b - a) / (fb - fa);
|
||
(a, fa) = (b, fb);
|
||
b = c;
|
||
fb = run(b).stress[1];
|
||
}
|
||
s = run(b);
|
||
strain += de;
|
||
out.push((strain, s.stress[0], s.plastic_strain));
|
||
}
|
||
out
|
||
}
|
||
|
||
#[test]
|
||
fn bilinear_uniaxial_curve_and_unloading() {
|
||
let (y0, h) = (250.0, 2000.0);
|
||
for kinematic in [false, true] {
|
||
let hardening = if kinematic { Hardening::Kinematic } else { Hardening::Isotropic };
|
||
let m = material(Plastic::Bilinear { yield_stress: y0, hardening_modulus: h, hardening });
|
||
// Load to 1% strain, unload to −1%.
|
||
let mut steps = vec![1e-4; 100];
|
||
steps.extend(vec![-1e-4; 200]);
|
||
let curve = uniaxial(&m, &steps, 1e-3);
|
||
// Loading: σ = E ε below yield, then σ = y0 + H ε_p with ε = σ/E + ε_p, that is
|
||
// σ = y0 + E_t (ε − y0/E), E_t = E H/(E + H).
|
||
let et = E * h / (E + h);
|
||
let (e1, s1, p1) = curve[99];
|
||
let exact = y0 + et * (e1 - y0 / E);
|
||
assert!((s1 - exact).abs() < 1e-9 * exact, "{kinematic}: {s1} vs {exact}");
|
||
assert!((p1 - (s1 - y0) / h).abs() < 1e-12, "{p1}");
|
||
// Unloading is elastic until the reversed yield: isotropic at −σ1, kinematic at
|
||
// σ1 − 2 y0 (the surface keeps its size and moves).
|
||
let reverse = if kinematic { s1 - 2.0 * y0 } else { -s1 };
|
||
for (e, s, _) in &curve[100..] {
|
||
let elastic = s1 - E * (e1 - e);
|
||
if elastic > reverse + 1.0 {
|
||
assert!((s - elastic).abs() < 1e-8 * s1, "{kinematic}: {s} vs elastic {elastic}");
|
||
}
|
||
}
|
||
let (_, s_end, _) = curve[299];
|
||
assert!(s_end < reverse, "{kinematic}: {s_end} vs {reverse}");
|
||
eprintln!("bilinear kinematic = {kinematic}: σ(1%) = {s1:.6} (exact {exact:.6}); reversed yield at {reverse:.3}; σ(−1%) = {s_end:.4}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn table_and_johnson_cook_follow_their_flow_curves() {
|
||
let points = vec![
|
||
HardeningPoint { strain: 0.0, stress: 200.0 },
|
||
HardeningPoint { strain: 0.01, stress: 300.0 },
|
||
HardeningPoint { strain: 0.05, stress: 340.0 },
|
||
];
|
||
let m = material(Plastic::Table { points });
|
||
let curve = uniaxial(&m, &vec![2e-4; 400], 1e-3);
|
||
for (_, s, p) in &curve {
|
||
if *p > 0.0 {
|
||
let want = table(&[(0.0, 200.0), (0.01, 300.0), (0.05, 340.0)], *p).0;
|
||
assert!((s - want).abs() < 1e-8 * want, "{s} vs {want} at {p}");
|
||
}
|
||
}
|
||
assert!(curve.last().unwrap().2 > 0.05, "past the table's end");
|
||
// Johnson–Cook at a constant plastic rate: the stress is the flow stress at that rate.
|
||
let (a, b, n, c) = (350.0, 275.0, 0.36, 0.022);
|
||
let jc = Plastic::JohnsonCook {
|
||
a,
|
||
b,
|
||
n,
|
||
c,
|
||
m: 1.0,
|
||
reference_strain_rate: 1.0,
|
||
room_temperature: 293.0,
|
||
melt_temperature: 1356.0,
|
||
specific_heat: None,
|
||
taylor_quinney: 0.9,
|
||
};
|
||
let m = material(jc);
|
||
let dt = 1e-6;
|
||
let curve = uniaxial(&m, &vec![1e-3; 200], dt);
|
||
let mut worst: f64 = 0.0;
|
||
for w in curve.windows(2).skip(20) {
|
||
let (_, s, p) = w[1];
|
||
let rate = (p - w[0].2) / dt;
|
||
let want = (a + b * p.powf(n)) * (1.0 + c * rate.ln());
|
||
worst = worst.max((s - want).abs() / want);
|
||
}
|
||
eprintln!("Johnson–Cook uniaxial: largest relative error of the flow stress {worst:.2e}");
|
||
assert!(worst < 1e-9, "{worst}");
|
||
}
|
||
|
||
#[test]
|
||
fn heating_softens_and_damage_accumulates() {
|
||
let jc = |cp: Option<f64>| 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: cp,
|
||
taylor_quinney: 0.9,
|
||
};
|
||
let el = Elastic::Isotropic { young: E, poisson: NU };
|
||
let damage = Damage::JohnsonCook { d1: 0.5, d2: 0.0, d3: 0.0, d4: 0.0, d5: 0.0 };
|
||
let cold = J2::from_model(&el, Some(&jc(None)), Some(&damage)).unwrap();
|
||
let hot = J2::from_model(&el, Some(&jc(Some(3.83e8))), Some(&damage)).unwrap();
|
||
let a = uniaxial(&cold, &vec![1e-3; 300], 1e-3);
|
||
let b = uniaxial(&hot, &vec![1e-3; 300], 1e-3);
|
||
assert!(b.last().unwrap().1 < a.last().unwrap().1);
|
||
// Constant fracture strain 0.5: D = ε_p / 0.5.
|
||
let mut s = PointState::new(293.0);
|
||
for _ in 0..300 {
|
||
cold.update(&mut s, &[1e-3, -5e-4, -5e-4, 0.0, 0.0, 0.0], 0.0, 1e-3, 7.8e-9);
|
||
}
|
||
assert!((s.damage - s.plastic_strain / 0.5).abs() < 1e-12, "{} vs {}", s.damage, s.plastic_strain);
|
||
}
|
||
|
||
#[test]
|
||
fn hostile_increments_stay_finite() {
|
||
let m = material(Plastic::Bilinear { yield_stress: 250.0, hardening_modulus: 0.0, hardening: Hardening::Isotropic });
|
||
let mut s = PointState::new(0.0);
|
||
m.update(&mut s, &[1e3, -1e3, 0.0, 5e2, 0.0, 0.0], 0.0, 0.0, 0.0);
|
||
assert!(s.stress.iter().all(|x| x.is_finite()) && (von_mises(&s.stress) - 250.0).abs() < 1e-9 * 250.0);
|
||
}
|
||
}
|