Files
OneCAD-Simulation/crates/solver/tests/nonlinear.rs
T
jyvikoandClaude Opus 5.5 c09ccc8a59 solver: Newton on indefinite tangents by LDLᵀ
A tangent that is not positive definite is factored by LDLᵀ (Cholesky first, one symbolic
factorization) instead of cutting the increment back; each iteration records the tangent's
negative eigenvalues and the summary warns when a converged state keeps some. A model that can
move freely is still an error at its first tangent, located by the undeformed Cholesky factor;
a singular tangent later is a cutback. Nearly incompressible hybrid hex8, hex20 and tet10 at
ν = 0.4999 under prescribed displacement now converge to the closed form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:42:12 -04:00

548 lines
29 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.
//! Nonlinear static analysis against closed forms: uniaxial stretch of neo-Hookean and
//! St Venant–Kirchhoff blocks (3D, plane strain, axisymmetric), the nonlinear patch test, a
//! large rigid rotation, Newton's quadratic convergence, the linear limit, and what nonlinear
//! steps refuse or stop at. Run with `--nocapture` to see the residuals.
mod common;
use common::*;
use fieldcraft_model::*;
use fieldcraft_solver::{Limits, StaticResult, solve_nonlinear, solve_static};
fn nonlinear() -> Procedure {
Procedure::NonlinearStatic(NonlinearStatic::default())
}
fn neo_hookean(g: &mut Grid, e: f64, nu: f64) {
let m = Material { elastic: Some(iso(e, nu)), hyperelastic: Some(Hyperelastic::NeoHookean {}), ..Material::default() };
g.model.replace_material("Mat", m).unwrap();
}
/// Prescribe every displacement component of each node where `f` holds from its position:
/// one set and one boundary condition per node.
fn prescribe(g: &mut Grid, f: impl Fn([f64; 3]) -> bool, u: impl Fn([f64; 3]) -> [f64; 3]) {
for l in g.nodes_where(f) {
let x = g.model.node(l).unwrap().xyz;
let v = u(x);
let name = format!("n{l}");
g.model.add_set(&name, Set { nodes: vec![l], ..Set::default() }).unwrap();
let values = [Some(v[0]), Some(v[1]), Some(v[2]), None, None, None];
g.bc(&name, BoundaryCondition::Displacement { target: Target::Set(name.clone()), values });
}
}
fn at(r: &StaticResult, label: u32) -> usize {
r.nodes.binary_search(&label).unwrap()
}
fn solve(g: &Grid) -> StaticResult {
solve_nonlinear(&g.model, Some("Step"), &Limits::default()).unwrap()
}
/// The lateral stretch of a neo-Hookean bar stretched by λ in uniaxial stress (σ_yy = σ_zz = 0)
/// and its axial Cauchy stress, by bisection on σ_yy(l) = μ J^(−5/3) (l² − I₁/3) + κ (J − 1).
fn neo_hookean_uniaxial(mu: f64, kappa: f64, lam: f64) -> (f64, f64) {
let stress = |l: f64| {
let (j, i1) = (lam * l * l, lam * lam + 2.0 * l * l);
let c = mu * j.powf(-5.0 / 3.0);
(c * (lam * lam - i1 / 3.0) + kappa * (j - 1.0), c * (l * l - i1 / 3.0) + kappa * (j - 1.0))
};
let (mut lo, mut hi) = (0.1, 1.5);
for _ in 0..200 {
let mid = 0.5 * (lo + hi);
if stress(mid).1 > 0.0 { hi = mid } else { lo = mid }
}
let l = 0.5 * (lo + hi);
(l, stress(l).0)
}
/// A unit block stretched to twice its length (x), free laterally (symmetry planes), in each
/// element kind: the homogeneous closed form at every node, and the logarithmic strain ln 2.
#[test]
fn neo_hookean_block_in_uniaxial_stretch() {
let (e, nu, lam) = (10.0, 0.3, 2.0);
let (mu, kappa) = (e / (2.0 * (1.0 + nu)), e / (3.0 * (1.0 - 2.0 * nu)));
let (l, sigma) = neo_hookean_uniaxial(mu, kappa, lam);
for (kind, formulation, name) in variants().into_iter().filter(|v| v.1 != incompatible()) {
let mut g = grid([2, 2, 2], kind, |x| x);
g.define(iso(e, nu), None, formulation, nonlinear());
neo_hookean(&mut g, e, nu);
g.symmetry("x0", Symmetry::X, |x| near(x[0], 0.0));
g.symmetry("y0", Symmetry::Y, |x| near(x[1], 0.0));
g.symmetry("z0", Symmetry::Z, |x| near(x[2], 0.0));
g.node_set("x1", |x| near(x[0], 1.0));
g.bc("pull", BoundaryCondition::Displacement { target: Target::Set("x1".into()), values: [Some(lam - 1.0), None, None, None, None, None] });
let r = solve(&g);
for (i, label) in r.nodes.iter().enumerate() {
let x = g.model.node(*label).unwrap().xyz;
let want = [(lam - 1.0) * x[0], (l - 1.0) * x[1], (l - 1.0) * x[2]];
for k in 0..3 {
assert!((r.displacement[i][k] - want[k]).abs() < 1e-8, "{name} node {label}: {:?} vs {want:?}", r.displacement[i]);
}
}
for s in &r.stress {
assert!((s[0] - sigma).abs() < 1e-8 * sigma && s[1].abs() < 1e-8 * sigma && s[3].abs() < 1e-8 * sigma, "{name}: {s:?} vs {sigma}");
}
let strain = r.elements[0].points[0].strain;
assert!((strain[0] - lam.ln()).abs() < 1e-9 && (strain[1] - l.ln()).abs() < 1e-9, "{name}: {strain:?}");
// The reactions on the pulled face balance the axial force σ times the deformed area l².
let pulled: f64 = g.nodes_where(|x| near(x[0], 1.0)).iter().map(|n| r.reaction[at(&r, *n)][0]).sum();
assert!((pulled - sigma * l * l).abs() < 1e-8 * sigma, "{name}: {pulled} vs {}", sigma * l * l);
let h = r.nonlinear.as_ref().unwrap();
assert!(h.increments.last().is_some_and(|i| i.converged && i.load_factor == 1.0), "{name}: {:?}", h.increments);
assert_eq!(h.frames.first().map(|f| f.load_factor), Some(0.0));
assert_eq!(h.frames.last().map(|f| f.load_factor), Some(1.0));
eprintln!("{name}: {} increments, {} iterations", h.increments.len(), h.iterations);
}
}
/// St Venant–Kirchhoff in uniaxial stress: S₁₁ = E E₁₁ with E₁₁ = (λ² − 1)/2, lateral
/// E₂₂ = −ν E₁₁; the Cauchy stress σ = λ² S₁₁ / J.
#[test]
fn st_venant_kirchhoff_uniaxial_stretch() {
let (e, nu, lam): (f64, f64, f64) = (1000.0, 0.25, 1.3);
let e11 = 0.5 * (lam * lam - 1.0);
let l = (1.0 - 2.0 * nu * e11).sqrt();
let sigma = lam * lam * e * e11 / (lam * l * l);
let mut g = grid([1, 1, 1], ElementKind::Hex20, |x| x);
g.define(iso(e, nu), None, full(), nonlinear());
g.symmetry("x0", Symmetry::X, |x| near(x[0], 0.0));
g.symmetry("y0", Symmetry::Y, |x| near(x[1], 0.0));
g.symmetry("z0", Symmetry::Z, |x| near(x[2], 0.0));
g.node_set("x1", |x| near(x[0], 1.0));
g.bc("pull", BoundaryCondition::Displacement { target: Target::Set("x1".into()), values: [Some(lam - 1.0), None, None, None, None, None] });
let r = solve(&g);
let corner = g.nodes_where(|x| near(x[0], 1.0) && near(x[1], 1.0) && near(x[2], 1.0))[0];
assert!((r.displacement[at(&r, corner)][1] - (l - 1.0)).abs() < 1e-9, "{:?}", r.displacement[at(&r, corner)]);
assert!(r.stress.iter().all(|s| (s[0] - sigma).abs() < 1e-8 * sigma), "{:?} vs {sigma}", r.stress[0]);
}
/// The nonlinear patch test: a distorted mesh whose boundary nodes follow a homogeneous finite
/// deformation x = F X (stretch, shear and rotation) reproduces it at the inner nodes and gives
/// the uniform stress of F, for every element kind.
#[test]
fn nonlinear_patch_test() {
let f = [[1.3, 0.2, -0.1], [-0.15, 0.9, 0.25], [0.05, -0.2, 1.1]];
let affine = |x: [f64; 3]| -> [f64; 3] { std::array::from_fn(|i| (0..3).map(|j| f[i][j] * x[j]).sum::<f64>() - x[i]) };
let distort = |q: [f64; 3]| {
let bump = (std::f64::consts::PI * q[0]).sin() * (std::f64::consts::PI * q[1]).sin() * (std::f64::consts::PI * q[2]).sin();
[q[0] + 0.12 * bump, q[1] - 0.08 * bump, q[2] + 0.1 * bump]
};
let (e, nu) = (100.0, 0.3);
let neo = fieldcraft_fem::constitutive::MaterialModel::from_model(&iso(e, nu), Some(&Hyperelastic::NeoHookean {})).unwrap();
let tau = neo.response(&f, None, &[], &mut []).unwrap().stress;
let j = fieldcraft_fem::dense::det3(&f);
let hybrid = Formulation { hybrid: true, ..Formulation::default() };
let mut cases: Vec<(ElementKind, Formulation, &str)> = variants().into_iter().filter(|v| v.1 != incompatible()).collect();
cases.extend([
(ElementKind::Hex8, hybrid, "hex8 hybrid"),
(ElementKind::Hex20, hybrid, "hex20 hybrid"),
(ElementKind::Tet10, hybrid, "tet10 hybrid"),
]);
for (kind, formulation, name) in cases {
let mut g = grid([2, 2, 2], kind, distort);
g.define(iso(e, nu), None, formulation, nonlinear());
neo_hookean(&mut g, e, nu);
let boundary = |q: [f64; 3]| q.iter().any(|c| near(*c, 0.0) || near(*c, 1.0));
prescribe(&mut g, boundary, affine);
let r = solve(&g);
for (i, label) in r.nodes.iter().enumerate() {
let want = affine(g.model.node(*label).unwrap().xyz);
assert!((0..3).all(|k| (r.displacement[i][k] - want[k]).abs() < 1e-9), "{name} node {label}: {:?} vs {want:?}", r.displacement[i]);
}
for el in &r.elements {
for p in &el.points {
assert!((0..6).all(|k| (p.stress[k] - tau[k] / j).abs() < 1e-8 * e), "{name}: {:?} vs {:?}", p.stress, tau.map(|t| t / j));
}
}
}
}
/// A block turned rigidly by 90° about an inclined axis, its boundary prescribed: no strain, no
/// stress, no reaction, whatever the path (objectivity of the formulation).
#[test]
fn rigid_rotation_strains_and_stresses_nothing() {
let axis = [1.0 / 3f64.sqrt(); 3];
let k = fieldcraft_fem::rotation::skew(&axis);
let k2 = fieldcraft_fem::rotation::mul(&k, &k);
let rot: [[f64; 3]; 3] = std::array::from_fn(|i| std::array::from_fn(|j| if i == j { 1.0 } else { 0.0 } + k[i][j] + k2[i][j]));
for (kind, formulation, name) in
[(ElementKind::Hex20, full(), "hex20"), (ElementKind::Tet10, full(), "tet10"), (ElementKind::Hex8, full(), "hex8")]
{
let mut g = grid([2, 2, 2], kind, |x| x);
g.define(iso(1000.0, 0.3), None, formulation, nonlinear());
neo_hookean(&mut g, 1000.0, 0.3);
let boundary = |q: [f64; 3]| q.iter().any(|c| near(*c, 0.0) || near(*c, 1.0));
prescribe(&mut g, boundary, |x| std::array::from_fn(|i| (0..3).map(|j| rot[i][j] * x[j]).sum::<f64>() - x[i]));
let r = solve(&g);
let worst = r.elements.iter().flat_map(|e| &e.points).flat_map(|p| p.stress.iter().chain(&p.strain)).fold(0.0f64, |m, v| m.max(v.abs()));
assert!(worst < 1e-10 * 1000.0, "{name}: largest stress or strain {worst:e}");
assert!(r.reaction.iter().flatten().all(|v| v.abs() < 1e-9), "{name}");
}
}
/// Plane strain and axisymmetric 2D elements reproduce the closed forms: a plane strain block
/// stretched in x (σ_yy = 0, λ_z = 1) and a solid cylinder stretched along its axis (the 3D
/// uniaxial case).
#[test]
fn plane_strain_and_axisymmetric_stretch() {
let (e, nu, lam) = (10.0, 0.3, 1.6);
let (mu, kappa) = (e / (2.0 * (1.0 + nu)), e / (3.0 * (1.0 - 2.0 * nu)));
// Plane strain: σ_yy(l) = μ J^(−5/3) (l² − I₁/3) + κ (J − 1) = 0 with J = λ l, I₁ = λ² + l² + 1.
let syy = |l: f64| {
let (j, i1) = (lam * l, lam * lam + l * l + 1.0);
mu * j.powf(-5.0 / 3.0) * (l * l - i1 / 3.0) + kappa * (j - 1.0)
};
let (mut lo, mut hi) = (0.1, 1.5);
for _ in 0..200 {
let mid = 0.5 * (lo + hi);
if syy(mid) > 0.0 { hi = mid } else { lo = mid }
}
let l_plane = 0.5 * (lo + hi);
let (l_axi, sigma_axi) = neo_hookean_uniaxial(mu, kappa, lam);
for (section, kind) in [
(SectionKind::PlaneStrain { thickness: 1.0 }, ElementKind::Quad8),
(SectionKind::PlaneStrain { thickness: 1.0 }, ElementKind::Tri3),
(SectionKind::Axisymmetric {}, ElementKind::Quad8),
(SectionKind::Axisymmetric {}, ElementKind::Quad4),
] {
let mut m = Model::default();
let n = 3usize;
let label = |i: usize, j: usize| (1 + i + (2 * n + 1) * j) as u32;
for j in 0..=2 * n {
for i in 0..=2 * n {
m.set_node(label(i, j), [i as f64 / (2 * n) as f64, j as f64 / (2 * n) as f64, 0.0]).unwrap();
}
}
let mut next = 1;
for cj in 0..n {
for ci in 0..n {
let (i, j) = (2 * ci, 2 * cj);
let c = [label(i, j), label(i + 2, j), label(i + 2, j + 2), label(i, j + 2)];
match kind {
ElementKind::Quad8 => {
let mid = [label(i + 1, j), label(i + 2, j + 1), label(i + 1, j + 2), label(i, j + 1)];
m.set_element(next, kind, c.into_iter().chain(mid).collect()).unwrap();
next += 1;
}
ElementKind::Quad4 => {
m.set_element(next, kind, c.to_vec()).unwrap();
next += 1;
}
_ => {
m.set_element(next, kind, vec![c[0], c[1], c[2]]).unwrap();
m.set_element(next + 1, kind, vec![c[0], c[2], c[3]]).unwrap();
next += 2;
}
}
}
}
let all: Vec<u32> = m.elements().keys().copied().collect();
m.add_set("all", Set { elements: all, ..Set::default() }).unwrap();
let mat = Material { elastic: Some(iso(e, nu)), hyperelastic: Some(Hyperelastic::NeoHookean {}), ..Material::default() };
m.add_material("Mat", mat).unwrap();
m.add_section("s", Section { set: "all".into(), material: "Mat".into(), kind: section, formulation: Formulation::default() }).unwrap();
let axisymmetric = matches!(section, SectionKind::Axisymmetric {});
// Stretch along x (plane strain) or along the axis y (axisymmetric).
let (pulled, axis) = if axisymmetric { (1, 1) } else { (0, 0) };
let on = |k: usize, v: f64| -> Vec<u32> { m.nodes().iter().filter(|(_, n)| near(n.xyz[k], v)).map(|(l, _)| *l).collect() };
let (low, high, side) = (on(pulled, 0.0), on(pulled, 1.0), on(1 - pulled, 0.0));
m.add_set("low", Set { nodes: low, ..Set::default() }).unwrap();
m.add_set("high", Set { nodes: high, ..Set::default() }).unwrap();
m.add_set("side", Set { nodes: side, ..Set::default() }).unwrap();
let hold = |k: usize, v: Option<f64>| {
let mut values = [None; 6];
values[k] = v;
values
};
m.add_boundary_condition("low", BoundaryCondition::Displacement { target: Target::Set("low".into()), values: hold(axis, Some(0.0)) })
.unwrap();
m.add_boundary_condition("high", BoundaryCondition::Displacement { target: Target::Set("high".into()), values: hold(axis, Some(lam - 1.0)) })
.unwrap();
m.add_boundary_condition("side", BoundaryCondition::Displacement { target: Target::Set("side".into()), values: hold(1 - axis, Some(0.0)) })
.unwrap();
let mut step = Step::new("Step", nonlinear());
step.boundary_conditions = ["low", "high", "side"].iter().map(|s| s.to_string()).collect();
m.add_step(step, None).unwrap();
let r = solve_nonlinear(&m, None, &Limits::default()).unwrap();
let lateral = if axisymmetric { l_axi } else { l_plane };
for (i, label) in r.nodes.iter().enumerate() {
let x = m.node(*label).unwrap().xyz;
let want = [(lateral - 1.0) * x[1 - pulled], (lam - 1.0) * x[pulled]];
let (got_axial, got_lateral) = (r.displacement[i][pulled], r.displacement[i][1 - pulled]);
assert!(
(got_axial - want[1]).abs() < 1e-8 && (got_lateral - want[0]).abs() < 1e-8,
"{section:?} {kind:?} node {label}: {:?}",
r.displacement[i]
);
}
if axisymmetric {
assert!(r.stress.iter().all(|s| (s[1] - sigma_axi).abs() < 1e-7 * sigma_axi && s[0].abs() < 1e-7 * sigma_axi), "{:?}", r.stress[0]);
}
}
}
/// Newton converges quadratically near the solution: in the last iterations of each increment
/// the residual falls at least like the square of the previous one.
#[test]
fn newton_converges_quadratically() {
let mut g = grid([8, 1, 2], ElementKind::Hex20, |q| [10.0 * q[0], q[1], q[2]]);
let mut step = NonlinearStatic { initial_increment: 0.5, line_search: false, ..NonlinearStatic::default() };
step.tolerances.force = 1e-12;
step.tolerances.displacement = 1e-12;
step.tolerances.energy = 1e-20;
g.define(iso(1000.0, 0.0), None, full(), Procedure::NonlinearStatic(step));
g.fixed("root", |q| near(q[0], 0.0));
g.face_set("tip", |q| near(q[0], 1.0));
// P L² / (E I) = 1: a tip rotation of about 0.46 rad.
let p = 1000.0 * (2.0f64.powi(3) / 12.0) / 100.0;
g.load("push", Load::Force { target: Target::Set("tip".into()), vector: [0.0, 0.0, -p], distribution: Distribution::Total });
let r = solve(&g);
let h = r.nonlinear.unwrap();
// The order of convergence ln(r₂/r₁) / ln(r₁/r₀) over successive residuals in the
// asymptotic range (below 10⁻³, above round-off): 2 for Newton with the consistent tangent.
let mut best = 0.0f64;
for inc in h.increments.iter().filter(|i| i.converged) {
let res: Vec<f64> = inc.iterations.iter().map(|i| i.force).collect();
let orders: Vec<f64> = res.windows(3).filter(|w| w[0] < 1e-3 && w[2] > 1e-15).map(|w| (w[2] / w[1]).ln() / (w[1] / w[0]).ln()).collect();
eprintln!("increment {} (λ = {}): residuals {res:?}, orders {orders:?}", inc.number, inc.load_factor);
best = orders.iter().fold(best, |m, o| m.max(*o));
}
assert!(best > 1.8, "no quadratic convergence (best order {best})");
}
/// Without nonlinear geometry, and with a small load with it, the step is the linear solution.
#[test]
fn small_loads_and_small_kinematics_give_the_linear_solution() {
let build = |procedure: Procedure, scale: f64| {
let mut g = grid([6, 1, 2], ElementKind::Hex20, |q| [6.0 * q[0], q[1], q[2]]);
g.define(iso(1000.0, 0.3), None, full(), procedure);
g.fixed("root", |q| near(q[0], 0.0));
g.face_set("tip", |q| near(q[0], 1.0));
g.load("push", Load::Force { target: Target::Set("tip".into()), vector: [0.0, 0.0, -scale], distribution: Distribution::Total });
g.face_set("top", |q| near(q[2], 1.0));
g.load("press", Load::Pressure { set: "top".into(), magnitude: 0.1 * scale });
g
};
let linear = solve_static(&build(Procedure::Static {}, 1e-3).model, None, &Limits::default()).unwrap();
let small = NonlinearStatic { nonlinear_geometry: false, ..NonlinearStatic::default() };
let r = solve(&build(Procedure::NonlinearStatic(small), 1e-3));
let tip = linear.displacement.iter().fold(0.0f64, |m, u| m.max(u[2].abs()));
for (a, b) in r.displacement.iter().zip(&linear.displacement) {
assert!((0..3).all(|k| (a[k] - b[k]).abs() < 1e-9 * tip), "{a:?} vs {b:?}");
}
let r = solve(&build(nonlinear(), 1e-3));
for (a, b) in r.displacement.iter().zip(&linear.displacement) {
assert!((0..3).all(|k| (a[k] - b[k]).abs() < 1e-3 * tip), "geometric effects of order u/L: {a:?} vs {b:?}");
}
}
/// Hostile and impossible input: the step's controls are refused when defined; an element
/// that inverts makes the step cut back and stop at the minimum increment with the element
/// named; the caps stop a step; unsupported elements and loads are refused by name.
#[test]
fn nonlinear_steps_fail_cleanly() {
let base = || {
let mut g = grid([2, 1, 1], ElementKind::Hex8, |q| q);
let step = NonlinearStatic { min_increment: 1e-3, ..NonlinearStatic::default() };
g.define(iso(100.0, 0.3), None, full(), Procedure::NonlinearStatic(step));
neo_hookean(&mut g, 100.0, 0.3);
g.fixed("root", |q| near(q[0], 0.0));
g
};
// Squash the bar through itself: past −100 % every element inverts.
let mut g = base();
g.node_set("x1", |q| near(q[0], 1.0));
g.bc("crush", BoundaryCondition::Displacement { target: Target::Set("x1".into()), values: [Some(-1.5), Some(0.0), Some(0.0), None, None, None] });
let err = solve_nonlinear(&g.model, None, &Limits::default()).unwrap_err().to_string();
assert!(err.contains("stopped at load factor") && err.contains("element"), "{err}");
// A tight cap on the Newton iterations.
let err = solve_nonlinear(&g.model, None, &Limits { max_newton_iterations: 3, ..Limits::default() }).unwrap_err().to_string();
assert!(err.contains("Newton iterations"), "{err}");
// The increment cap.
let mut capped = base();
capped.node_set("x1", |q| near(q[0], 1.0));
capped.bc("pull", BoundaryCondition::Displacement { target: Target::Set("x1".into()), values: [Some(0.5), None, None, None, None, None] });
let mut step = capped.model.step("Step").unwrap().clone();
step.set_procedure(Procedure::NonlinearStatic(NonlinearStatic {
initial_increment: 0.01,
max_increment: 0.01,
min_increment: 0.01,
max_increments: 3,
..NonlinearStatic::default()
}));
capped.model.replace_step(step).unwrap();
let err = solve_nonlinear(&capped.model, None, &Limits::default()).unwrap_err().to_string();
assert!(err.contains("increments reached the step's cap"), "{err}");
// Hostile controls never reach the solver.
let d = NonlinearStatic::default();
for bad in [
NonlinearStatic { initial_increment: f64::NAN, ..d.clone() },
NonlinearStatic { initial_increment: 0.0, ..d.clone() },
NonlinearStatic { max_iterations: u32::MAX, ..d.clone() },
NonlinearStatic { max_increments: 0, ..d.clone() },
NonlinearStatic { tolerances: Tolerances { force: -1.0, ..Tolerances::default() }, ..d.clone() },
] {
let mut step = capped.model.step("Step").unwrap().clone();
step.set_procedure(Procedure::NonlinearStatic(bad.clone()));
assert!(capped.model.replace_step(step).is_err(), "{bad:?}");
}
// Refused by name: incompatible modes, a hybrid St Venant–Kirchhoff element, centrifugal loads.
let mut im = grid([2, 1, 1], ElementKind::Hex8, |q| q);
im.define(iso(100.0, 0.3), None, incompatible(), nonlinear());
im.fixed("root", |q| near(q[0], 0.0));
im.face_set("end", |q| near(q[0], 1.0));
im.load("p", Load::Pressure { set: "end".into(), magnitude: 1.0 });
let err = solve_nonlinear(&im.model, None, &Limits::default()).unwrap_err().to_string();
assert!(err.contains("incompatible modes in nonlinear steps"), "{err}");
let mut hy = grid([2, 1, 1], ElementKind::Hex8, |q| q);
hy.define(iso(100.0, 0.3), None, Formulation { hybrid: true, ..Formulation::default() }, nonlinear());
hy.fixed("root", |q| near(q[0], 0.0));
hy.face_set("end", |q| near(q[0], 1.0));
hy.load("p", Load::Pressure { set: "end".into(), magnitude: 1.0 });
let err = solve_nonlinear(&hy.model, None, &Limits::default()).unwrap_err().to_string();
assert!(err.contains("hybrid elements of a St Venant–Kirchhoff material"), "{err}");
hy.load("spin", Load::Centrifugal { angular_velocity: 10.0, point: [0.0; 3], axis: [0.0, 0.0, 1.0], set: None });
neo_hookean(&mut hy, 100.0, 0.3);
let mut m = hy.model.material("Mat").unwrap().clone();
m.density = Some(1.0);
hy.model.replace_material("Mat", m).unwrap();
let err = solve_nonlinear(&hy.model, None, &Limits::default()).unwrap_err().to_string();
assert!(err.contains("centrifugal loads"), "{err}");
// A model that can move freely: the first tangent is singular, named like the linear solver.
let mut free = grid([2, 1, 1], ElementKind::Hex8, |q| q);
free.define(iso(100.0, 0.3), None, full(), nonlinear());
free.face_set("end", |q| near(q[0], 1.0));
free.load("p", Load::Pressure { set: "end".into(), magnitude: 1.0 });
let err = solve_nonlinear(&free.model, None, &Limits::default()).unwrap_err().to_string();
assert!(err.contains("singular at node"), "{err}");
// A linear static step is not a nonlinear one.
let mut lin = grid([1, 1, 1], ElementKind::Hex8, |q| q);
lin.define(iso(100.0, 0.3), None, full(), Procedure::Static {});
assert!(solve_nonlinear(&lin.model, None, &Limits::default()).is_err());
}
/// The same model gives the same numbers bit for bit, whatever the thread count.
#[test]
fn nonlinear_results_are_deterministic() {
let mut g = grid([3, 1, 1], ElementKind::Tet10, |q| [4.0 * q[0], q[1], q[2]]);
g.define(iso(100.0, 0.3), None, full(), nonlinear());
neo_hookean(&mut g, 100.0, 0.3);
g.fixed("root", |q| near(q[0], 0.0));
g.face_set("top", |q| near(q[2], 1.0));
g.load("press", Load::Pressure { set: "top".into(), magnitude: 0.2 });
let a = fieldcraft_parallel::with_threads(1, || solve(&g));
for threads in [2, 7] {
assert_eq!(fieldcraft_parallel::with_threads(threads, || solve(&g)), a, "{threads} threads");
}
}
/// The principal Cauchy stresses of a decoupled polynomial energy at principal stretches `l`:
/// σᵢ = (λᵢ/J) ∂W/∂λᵢ by central differences of W itself (independent of the solver's
/// stress and tangent), W = C₁₀ (Ī₁ − 3) + C₀₁ (Ī₂ − 3) + C₂₀ (Ī₁ − 3)² + C₃₀ (Ī₁ − 3)³ + κ/2 (J − 1)².
fn polynomial_stress(c: [f64; 4], kappa: f64, l: [f64; 3]) -> [f64; 3] {
let w = |l: [f64; 3]| {
let j = l[0] * l[1] * l[2];
let b: Vec<f64> = l.iter().map(|x| x * x * j.powf(-2.0 / 3.0)).collect();
let i1 = b[0] + b[1] + b[2];
let i2 = b[0] * b[1] + b[1] * b[2] + b[2] * b[0];
let x = i1 - 3.0;
c[0] * x + c[1] * (i2 - 3.0) + c[2] * x * x + c[3] * x * x * x + 0.5 * kappa * (j - 1.0) * (j - 1.0)
};
let j = l[0] * l[1] * l[2];
std::array::from_fn(|i| {
let h = 1e-6 * l[i];
let (mut p, mut m) = (l, l);
p[i] += h;
m[i] -= h;
l[i] / j * (w(p) - w(m)) / (2.0 * h)
})
}
/// Mooney–Rivlin and Yeoh blocks in the homogeneous modes of rubber testing (L. R. G. Treloar,
/// *The Physics of Rubber Elasticity*, 3rd ed., Oxford 1975, ch. 10): uniaxial and equibiaxial
/// tension and pure shear, against the closed form of the compressible decoupled energy (the
/// free stretches found by bisection so the free faces carry no stress).
#[test]
fn mooney_rivlin_and_yeoh_in_the_homogeneous_modes() {
let (e, nu) = (3.0, 0.49);
let (mu, kappa) = (e / (2.0 * (1.0 + nu)), e / (3.0 * (1.0 - 2.0 * nu)));
let hybrid = Formulation { hybrid: true, ..Formulation::default() };
let (c01, c20, c30) = (0.2, -0.02, 0.004);
for option in [Hyperelastic::MooneyRivlin { c01 }, Hyperelastic::Yeoh { c20, c30 }] {
let c = option.constants(mu);
for (mode, lam) in [("uniaxial", 2.0), ("equibiaxial", 1.5), ("pure shear", 2.0)] {
// The stretches with the free one(s) l from σ_free(l) = 0.
let stretches = |l: f64| match mode {
"uniaxial" => [lam, l, l],
"equibiaxial" => [lam, lam, l],
_ => [lam, 1.0, l],
};
let (mut lo, mut hi) = (0.05, 1.5);
for _ in 0..200 {
let mid = 0.5 * (lo + hi);
if polynomial_stress(c, kappa, stretches(mid))[2] > 0.0 { hi = mid } else { lo = mid }
}
let want = polynomial_stress(c, kappa, stretches(0.5 * (lo + hi)));
let mut g = grid([1, 1, 1], ElementKind::Hex20, |x| x);
g.define(iso(e, nu), None, hybrid, nonlinear());
g.model.replace_material("Mat", Material { elastic: Some(iso(e, nu)), hyperelastic: Some(option), ..Material::default() }).unwrap();
g.symmetry("x0", Symmetry::X, |x| near(x[0], 0.0));
g.symmetry("y0", Symmetry::Y, |x| near(x[1], 0.0));
g.symmetry("z0", Symmetry::Z, |x| near(x[2], 0.0));
g.node_set("x1", |x| near(x[0], 1.0));
g.bc(
"pull",
BoundaryCondition::Displacement { target: Target::Set("x1".into()), values: [Some(lam - 1.0), None, None, None, None, None] },
);
g.node_set("y1", |x| near(x[1], 1.0));
let y = match mode {
"equibiaxial" => Some(lam - 1.0),
"pure shear" => Some(0.0),
_ => None,
};
if let Some(v) = y {
g.bc("y1", BoundaryCondition::Displacement { target: Target::Set("y1".into()), values: [None, Some(v), None, None, None, None] });
}
let r = solve(&g);
for s in &r.stress {
for k in 0..3 {
assert!((s[k] - want[k]).abs() < 1e-7 * want[0], "{} {mode}: {s:?} vs {want:?}", option.name());
}
}
eprintln!("{} {mode} λ = {lam}: σ = {:.6?} (closed form {want:.6?})", option.name(), &r.stress[0][..3]);
}
}
}
/// Nearly incompressible hybrid blocks (ν = 0.4999) stretched to λ = 2 by a prescribed
/// displacement: the first states of an increment strain the boundary elements alone, so their
/// mixed tangents are indefinite; Newton goes on with the LDLᵀ factor and converges to the
/// compressible neo-Hookean closed form.
#[test]
fn nearly_incompressible_hybrids_under_prescribed_displacement() {
let (e, nu, lam) = (3.0, 0.4999, 2.0);
let (mu, kappa) = (e / (2.0 * (1.0 + nu)), e / (3.0 * (1.0 - 2.0 * nu)));
let (l, sigma) = neo_hookean_uniaxial(mu, kappa, lam);
let mut indefinite = 0;
for (kind, n) in [(ElementKind::Hex8, 3), (ElementKind::Hex20, 2), (ElementKind::Tet10, 2)] {
let mut g = grid([n, n, n], kind, |x| x);
g.define(iso(e, nu), None, Formulation { hybrid: true, ..Formulation::default() }, nonlinear());
neo_hookean(&mut g, e, nu);
g.symmetry("x0", Symmetry::X, |x| near(x[0], 0.0));
g.symmetry("y0", Symmetry::Y, |x| near(x[1], 0.0));
g.symmetry("z0", Symmetry::Z, |x| near(x[2], 0.0));
g.node_set("x1", |x| near(x[0], 1.0));
g.bc("pull", BoundaryCondition::Displacement { target: Target::Set("x1".into()), values: [Some(lam - 1.0), None, None, None, None, None] });
let r = solve(&g);
let corner = g.nodes_where(|x| near(x[0], 1.0) && near(x[1], 1.0) && near(x[2], 1.0))[0];
assert!((r.displacement[at(&r, corner)][1] - (l - 1.0)).abs() < 1e-7, "{kind:?}: {:?}", r.displacement[at(&r, corner)]);
for s in &r.stress {
assert!((s[0] - sigma).abs() < 1e-5 * sigma, "{kind:?}: {s:?} vs {sigma}");
}
let h = r.nonlinear.as_ref().unwrap();
indefinite += h.increments.iter().flat_map(|i| &i.iterations).filter(|i| i.negative_pivots > 0).count();
}
assert!(indefinite > 0, "the LDLᵀ path was exercised");
}