Files
OneCAD-Simulation/crates/solver/tests/verification.rs
T

394 lines
19 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.
//! The analytic verification suite: every element and formulation against closed-form
//! solutions. Run with `--nocapture` to see the errors.
mod common;
use std::f64::consts::PI;
use common::*;
use fieldcraft_model::{BoundaryCondition, ElementKind, Load, Procedure, Symmetry, Target};
use fieldcraft_solver::{FrequencyOptions, Limits, MassKind, StaticResult, solve_frequency, solve_static};
fn static_solve(g: &Grid) -> StaticResult {
solve_static(&g.model, Some("Step"), &Limits::default()).unwrap()
}
fn at(r: &StaticResult, label: u32) -> usize {
r.nodes.binary_search(&label).unwrap()
}
/// Displacement patch test (MacNeal & Harder 1985 style, on a distorted 2 × 2 × 2 patch): a
/// linear displacement prescribed on the boundary is reproduced exactly inside, with exactly
/// constant strain and stress in every element.
#[test]
fn patch_test_every_element() {
let a = [[1e-3, 2e-4, -3e-4], [5e-4, -2e-3, 1e-4], [-1e-4, 3e-4, 7e-4]];
let (young, nu) = (1000.0, 0.25);
let lambda = young * nu / ((1.0 + nu) * (1.0 - 2.0 * nu));
let mu = young / (2.0 * (1.0 + nu));
let strain = [a[0][0], a[1][1], a[2][2], a[0][1] + a[1][0], a[0][2] + a[2][0], a[1][2] + a[2][1]];
let tr = strain[0] + strain[1] + strain[2];
let stress = [
lambda * tr + 2.0 * mu * strain[0],
lambda * tr + 2.0 * mu * strain[1],
lambda * tr + 2.0 * mu * strain[2],
mu * strain[3],
mu * strain[4],
mu * strain[5],
];
let field = |x: [f64; 3]| [0, 1, 2].map(|i| (0..3).map(|j| a[i][j] * x[j]).sum::<f64>());
// Interior distortion that leaves the cube's boundary in place.
let map = |p: [f64; 3]| {
let b = (PI * p[0]).sin() * (PI * p[1]).sin() * (PI * p[2]).sin();
[p[0] + 0.12 * b, p[1] - 0.08 * b, p[2] + 0.1 * b]
};
for (kind, formulation, name) in variants() {
let mut g = grid([2, 2, 2], kind, map);
g.define(iso(young, nu), None, formulation, Procedure::Static {});
let on_boundary = |p: [f64; 3]| p.iter().any(|c| near(*c, 0.0) || near(*c, 1.0));
for (i, label) in g.nodes_where(on_boundary).into_iter().enumerate() {
let x = g.model.node(label).unwrap().xyz;
let u = field(x);
let set = format!("b{i}");
g.model.add_set(&set, fieldcraft_model::Set { nodes: vec![label], ..Default::default() }).unwrap();
g.bc(
&set,
BoundaryCondition::Displacement { target: Target::Set(set.clone()), values: [Some(u[0]), Some(u[1]), Some(u[2]), None, None, None] },
);
}
let r = static_solve(&g);
let mut worst: f64 = 0.0;
for (label, u) in r.nodes.iter().zip(&r.displacement) {
let want = field(g.model.node(*label).unwrap().xyz);
worst = worst.max((0..3).map(|k| (u[k] - want[k]).abs()).fold(0.0, f64::max));
}
let mut worst_stress: f64 = 0.0;
for e in &r.elements {
for p in &e.points {
worst_stress = worst_stress.max((0..6).map(|k| (p.stress[k] - stress[k]).abs()).fold(0.0, f64::max));
}
}
for s in &r.stress {
worst_stress = worst_stress.max((0..6).map(|k| (s[k] - stress[k]).abs()).fold(0.0, f64::max));
}
eprintln!("patch {name}: displacement error {worst:.1e}, stress error {worst_stress:.1e}");
assert!(worst < 1e-12, "{name}: {worst}");
assert!(worst_stress < 1e-10, "{name}: {worst_stress}");
}
}
/// Uniaxial tension by a consistent surface load on a distorted block with symmetry planes:
/// σxx = p exactly, ux = p L / E, the lateral contraction −ν p y / E, and the reactions balance
/// the load (the force form of the patch test, and a check of pressure loads).
#[test]
fn uniaxial_tension_by_pressure() {
let (young, nu, p) = (2000.0, 0.3, 5.0);
let (lx, ly, lz) = (3.0, 2.0, 1.5);
for (kind, formulation, name) in variants() {
let mut g = grid([2, 2, 2], kind, |q| {
let b = (PI * q[0]).sin() * (PI * q[1]).sin() * (PI * q[2]).sin();
[lx * q[0] + 0.2 * b, ly * q[1] + 0.1 * b, lz * q[2] - 0.1 * b]
});
g.define(iso(young, nu), None, formulation, Procedure::Static {});
g.symmetry("x0", Symmetry::X, |q| near(q[0], 0.0));
g.symmetry("y0", Symmetry::Y, |q| near(q[1], 0.0));
g.symmetry("z0", Symmetry::Z, |q| near(q[2], 0.0));
g.face_set("end", |q| near(q[0], 1.0));
// A negative pressure pulls.
g.load("pull", Load::Pressure { set: "end".into(), magnitude: -p });
let r = static_solve(&g);
let mut worst: f64 = 0.0;
for (label, u) in r.nodes.iter().zip(&r.displacement) {
let x = g.model.node(*label).unwrap().xyz;
let want = [p * x[0] / young, -nu * p * x[1] / young, -nu * p * x[2] / young];
worst = worst.max((0..3).map(|k| (u[k] - want[k]).abs()).fold(0.0, f64::max));
}
let s_err = r.stress.iter().map(|s| (s[0] - p).abs().max(s[1].abs()).max(s[5].abs())).fold(0.0, f64::max);
eprintln!("tension {name}: displacement error {worst:.1e}, stress error {s_err:.1e}");
assert!(worst < 1e-11, "{name}: {worst}");
assert!(s_err < 1e-9, "{name}: {s_err}");
assert!((r.applied_total[0] - p * ly * lz).abs() < 1e-9 && (r.reaction_total[0] + p * ly * lz).abs() < 1e-9, "{name}");
assert!(r.von_mises.iter().all(|v| (v - p).abs() < 1e-9));
}
}
/// Gravity is ρ g V, held by the reactions.
#[test]
fn gravity_and_body_force_totals() {
let mut g = grid([3, 2, 2], ElementKind::Hex20, |q| [4.0 * q[0], 2.0 * q[1], q[2]]);
g.define(iso(1000.0, 0.3), Some(2.5), full(), Procedure::Static {});
g.fixed("base", |q| near(q[2], 0.0));
g.load("g", Load::Gravity { acceleration: [0.0, 0.0, -9.81], set: None });
g.load("b", Load::BodyForce { set: "all".into(), vector: [1.0, 0.0, 0.0] });
let r = static_solve(&g);
let v = 8.0;
assert!((r.applied_total[2] + 2.5 * 9.81 * v).abs() < 1e-9, "{:?}", r.applied_total);
assert!((r.applied_total[0] - v).abs() < 1e-9);
for k in 0..3 {
assert!((r.applied_total[k] + r.reaction_total[k]).abs() < 1e-8, "{k}");
}
}
/// Cantilever L × b × h, clamped at x = 0, a shear traction on the free end. Timoshenko
/// deflection δ = P L³ / (3 E I) + P L / (κ G A), κ = 5/6, with ν = 0 so the clamp adds no
/// lateral restraint.
fn cantilever_tip(kind: ElementKind, formulation: fieldcraft_model::Formulation, n: [usize; 3]) -> f64 {
let (l, b, h, young, p) = (10.0, 1.0, 1.0, 1000.0, 1.0);
let mut g = grid(n, kind, |q| [l * q[0], b * q[1], h * q[2]]);
g.define(iso(young, 0.0), None, formulation, Procedure::Static {});
g.fixed("root", |q| near(q[0], 0.0));
g.face_set("end", |q| near(q[0], 1.0));
g.load("shear", Load::Traction { set: "end".into(), vector: [0.0, 0.0, -p / (b * h)] });
let r = static_solve(&g);
let end = g.nodes_where(|q| near(q[0], 1.0));
-end.iter().map(|l| r.displacement[at(&r, *l)][2]).sum::<f64>() / end.len() as f64
}
fn timoshenko() -> f64 {
let (l, b, h, young, p) = (10.0f64, 1.0f64, 1.0f64, 1000.0, 1.0);
let i = b * h.powi(3) / 12.0;
p * l.powi(3) / (3.0 * young * i) + p * l / (5.0 / 6.0 * young / 2.0 * b * h)
}
#[test]
#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]
fn cantilever_tip_deflection_and_convergence() {
let exact = timoshenko();
let euler_bernoulli = 4.0;
eprintln!("cantilever: Timoshenko {exact:.5}, Euler–Bernoulli {euler_bernoulli:.5}");
let err = |d: f64| (d - exact).abs() / exact;
let study = |kind, f, meshes: &[[usize; 3]], name: &str| -> Vec<f64> {
meshes
.iter()
.map(|n| {
let d = cantilever_tip(kind, f, *n);
eprintln!(" {name} {n:?}: {d:.5} (error {:.2}%)", 100.0 * err(d));
err(d)
})
.collect()
};
// Quadratic and enhanced elements are accurate on coarse meshes.
let hex20 = study(ElementKind::Hex20, full(), &[[10, 1, 1], [20, 2, 2]], "hex20");
assert!(hex20[1] < 0.01);
let hex20r = study(ElementKind::Hex20, reduced(), &[[10, 2, 2]], "hex20 reduced");
assert!(hex20r[0] < 0.01);
let im = study(ElementKind::Hex8, incompatible(), &[[10, 1, 1], [20, 2, 2]], "hex8 incompatible");
assert!(im[0] < 0.03 && im[1] < 0.015);
let tet10 = study(ElementKind::Tet10, full(), &[[10, 2, 2]], "tet10");
assert!(tet10[0] < 0.02);
// Linear elements lock in bending and converge under refinement.
let meshes = [[5, 1, 1], [10, 2, 2], [20, 4, 4], [40, 8, 8]];
let hex8 = study(ElementKind::Hex8, full(), &meshes, "hex8");
let tet4 = study(ElementKind::Tet4, full(), &meshes, "tet4");
let wedge6 = study(ElementKind::Wedge6, full(), &meshes, "wedge6");
for (name, e) in [("hex8", &hex8), ("tet4", &tet4), ("wedge6", &wedge6)] {
let rates: Vec<f64> = e.windows(2).map(|w| (w[0] / w[1]).log2()).collect();
eprintln!(" {name} convergence rates {rates:.2?}");
assert!(e.windows(2).all(|w| w[1] < w[0]), "{name} converges monotonically");
assert!(*rates.last().unwrap() > 1.5, "{name} approaches second order: {rates:?}");
}
assert!(hex8[3] < 0.04 && tet4[3] < 0.12 && wedge6[3] < 0.04, "{hex8:?} {tet4:?} {wedge6:?}");
}
/// Lamé's thick cylinder under internal pressure, plane strain (u_z = 0 on both ends), quarter
/// model: σ_r = A − B/r², σ_θ = A + B/r², A = p a²/(b² − a²), B = p a² b²/(b² − a²),
/// u_r = (1 + ν)/E ((1 − 2ν) A r + B / r).
#[test]
fn lame_thick_cylinder() {
let (a, b, t, p, young, nu) = (1.0f64, 2.0f64, 0.25, 10.0, 1000.0, 0.3);
let big_a = p * a * a / (b * b - a * a);
let big_b = p * a * a * b * b / (b * b - a * a);
let ur = |r: f64| (1.0 + nu) / young * ((1.0 - 2.0 * nu) * big_a * r + big_b / r);
let sigma_t = |r: f64| big_a + big_b / (r * r);
for (kind, formulation, n, tol_u, tol_s, name) in [
(ElementKind::Hex20, full(), [6, 8, 1], 0.002, 0.01, "hex20"),
(ElementKind::Hex8, incompatible(), [12, 16, 1], 0.01, 0.04, "hex8 incompatible"),
(ElementKind::Tet10, full(), [6, 8, 1], 0.005, 0.03, "tet10"),
] {
let mut g = grid(n, kind, |q| {
let (r, th) = (a + (b - a) * q[0], PI / 2.0 * q[1]);
[r * th.cos(), r * th.sin(), t * q[2]]
});
g.define(iso(young, nu), None, formulation, Procedure::Static {});
g.symmetry("y0", Symmetry::Y, |q| near(q[1], 0.0));
g.symmetry("x0", Symmetry::X, |q| near(q[1], 1.0));
g.node_set("ends", |q| near(q[2], 0.0) || near(q[2], 1.0));
g.bc("ends", BoundaryCondition::Symmetry { target: Target::Set("ends".into()), plane: Symmetry::Z });
g.face_set("bore", |q| near(q[0], 0.0));
g.load("p", Load::Pressure { set: "bore".into(), magnitude: p });
let r = static_solve(&g);
let mut eu: f64 = 0.0;
for (label, u) in r.nodes.iter().zip(&r.displacement) {
let x = g.model.node(*label).unwrap().xyz;
let rad = x[0].hypot(x[1]);
let got = (u[0] * x[0] + u[1] * x[1]) / rad;
eu = eu.max((got - ur(rad)).abs() / ur(a));
}
// Hoop stress at the bore (nodal average), on the 45° line to stay away from the
// symmetry edges.
let bore: Vec<u32> = g.nodes_where(|q| near(q[0], 0.0) && near(q[1], 0.5));
let st: f64 = bore
.iter()
.map(|l| {
let s = r.stress[at(&r, *l)];
let x = g.model.node(*l).unwrap().xyz;
let (c, sn) = (x[0] / x[0].hypot(x[1]), x[1] / x[0].hypot(x[1]));
// σ_θ = s_xx sin² − 2 s_xy sin cos + s_yy cos².
s[0] * sn * sn - 2.0 * s[3] * sn * c + s[1] * c * c
})
.sum::<f64>()
/ bore.len() as f64;
let es = (st - sigma_t(a)).abs() / sigma_t(a);
eprintln!("lamé {name} {n:?}: u_r error {:.3}%, bore hoop stress {st:.4} vs {:.4} ({:.2}%)", 100.0 * eu, sigma_t(a), 100.0 * es);
assert!(eu < tol_u && es < tol_s, "{name}");
}
}
/// Kirsch: a plate with a hole of radius a under remote tension σ₀, plane stress, quarter model
/// with the plate's half-width 20a. The hoop stress at the top of the hole tends to 3σ₀ (the
/// finite width adds well under 1%).
#[test]
fn kirsch_plate_with_hole() {
let (a, w, s0) = (1.0f64, 20.0f64, 1.0);
let ratio: f64 = 40.0;
let map = |q: [f64; 3]| {
let th = PI / 2.0 * q[1];
let outer = if th <= PI / 4.0 { [w, w * th.tan()] } else { [w / th.tan(), w] };
// Geometric grading toward the hole.
let s = (ratio.powf(q[0]) - 1.0) / (ratio - 1.0);
let inner = [a * th.cos(), a * th.sin()];
[inner[0] + s * (outer[0] - inner[0]), inner[1] + s * (outer[1] - inner[1]), 0.1 * q[2]]
};
let mut kt = Vec::new();
for (kind, formulation, n, name) in [
(ElementKind::Hex20, full(), [6, 8, 1], "hex20 coarse"),
(ElementKind::Hex20, full(), [12, 16, 1], "hex20 fine"),
(ElementKind::Hex8, incompatible(), [24, 32, 1], "hex8 incompatible"),
] {
let mut g = grid(n, kind, map);
g.define(iso(1000.0, 0.3), None, formulation, Procedure::Static {});
g.symmetry("x0", Symmetry::X, |q| near(q[1], 1.0));
g.symmetry("y0", Symmetry::Y, |q| near(q[1], 0.0));
g.symmetry("z0", Symmetry::Z, |q| near(q[2], 0.0));
g.face_set("right", |q| near(q[0], 1.0) && q[1] <= 0.5 + 1e-9);
g.load("pull", Load::Traction { set: "right".into(), vector: [s0, 0.0, 0.0] });
let r = static_solve(&g);
let top: Vec<u32> = g.nodes_where(|q| near(q[0], 0.0) && near(q[1], 1.0));
let k = top.iter().map(|l| r.stress[at(&r, *l)][0]).sum::<f64>() / top.len() as f64 / s0;
eprintln!("kirsch {name} {n:?}: Kt = {k:.4} (error vs 3: {:.2}%)", 100.0 * (k - 3.0).abs() / 3.0);
kt.push(k);
}
assert!((kt[1] - 3.0).abs() < (kt[0] - 3.0).abs(), "refinement approaches 3: {kt:?}");
assert!((kt[1] - 3.0).abs() / 3.0 < 0.02, "{kt:?}");
assert!((kt[2] - 3.0).abs() / 3.0 < 0.04, "{kt:?}");
}
/// Euler–Bernoulli frequencies f = (βL)² / (2π L²) √(EI/(ρA)).
fn beam_frequency(beta_l: f64, l: f64, young: f64, rho: f64, side: f64) -> f64 {
let i = side.powi(4) / 12.0;
let area = side * side;
beta_l * beta_l / (2.0 * PI * l * l) * (young * i / (rho * area)).sqrt()
}
fn beam(kind: ElementKind, n: [usize; 3], clamped: bool, modes: u32) -> Grid {
let l = 20.0;
let mut g = grid(n, kind, |q| [l * q[0], q[1], q[2]]);
g.define(iso(1000.0, 0.0), Some(1.0), full(), Procedure::Frequency { modes, min_hz: None, max_hz: None });
if clamped {
g.fixed("root", |q| near(q[0], 0.0));
}
g
}
#[test]
fn cantilever_natural_frequencies() {
let f1 = beam_frequency(1.875_104_07, 20.0, 1000.0, 1.0, 1.0);
let f2 = beam_frequency(4.694_091_13, 20.0, 1000.0, 1.0, 1.0);
for (kind, n, mass, name) in [
(ElementKind::Hex20, [20, 1, 1], MassKind::Consistent, "hex20"),
(ElementKind::Hex20, [20, 1, 1], MassKind::Lumped, "hex20 lumped"),
(ElementKind::Tet10, [20, 2, 2], MassKind::Consistent, "tet10"),
] {
let g = beam(kind, n, true, 4);
let r = solve_frequency(&g.model, Some("Step"), &FrequencyOptions { mass, shift: None }, &Limits::default()).unwrap();
let f: Vec<f64> = r.modes.iter().map(|m| m.frequency).collect();
eprintln!("cantilever modes {name}: {f:.6?} vs EB {f1:.6}, {f2:.6} ({} Lanczos vectors)", r.stats.lanczos_vectors);
assert_eq!(f.len(), 4);
// The square section bends alike in y and z: two pairs.
for (got, want, tol) in [(f[0], f1, 0.01), (f[1], f1, 0.01), (f[2], f2, 0.03), (f[3], f2, 0.03)] {
assert!((got - want).abs() / want < tol, "{name}: {got} vs {want}");
}
assert!((r.total_mass - 20.0).abs() < 1e-9);
}
}
#[test]
fn free_free_beam_has_six_rigid_body_modes() {
let g = beam(ElementKind::Hex20, [20, 1, 1], false, 8);
let r = solve_frequency(&g.model, Some("Step"), &FrequencyOptions::default(), &Limits::default()).unwrap();
let f: Vec<f64> = r.modes.iter().map(|m| m.frequency).collect();
let f1 = beam_frequency(4.730_040_74, 20.0, 1000.0, 1.0, 1.0);
eprintln!("free-free modes: {f:.6?} vs EB {f1:.6}");
assert_eq!(f.len(), 8);
assert!(f[..6].iter().all(|x| *x < 1e-3 * f1), "{f:?}");
assert!(f[6..].iter().all(|x| (x - f1).abs() / f1 < 0.015), "{f:?}");
// The rigid translations carry the whole mass as effective mass.
for d in 0..3 {
let m: f64 = r.modes[..6].iter().map(|m| m.effective_mass[d]).sum();
assert!((m - 20.0).abs() < 1e-6 * 20.0, "direction {d}: {m}");
}
assert!(r.warnings.iter().any(|w| w.contains("rigid-body")));
}
#[test]
fn results_are_deterministic() {
let g = beam(ElementKind::Tet10, [6, 1, 1], true, 3);
let a = solve_frequency(&g.model, Some("Step"), &FrequencyOptions::default(), &Limits::default()).unwrap();
let b = solve_frequency(&g.model, Some("Step"), &FrequencyOptions::default(), &Limits::default()).unwrap();
assert_eq!(a, b);
}
#[test]
fn errors_name_the_problem() {
// No boundary condition in a static step: singular, naming a node.
let mut g = grid([2, 1, 1], ElementKind::Hex8, |q| q);
g.define(iso(1000.0, 0.3), None, full(), Procedure::Static {});
g.face_set("end", |q| near(q[0], 1.0));
g.load("p", Load::Pressure { set: "end".into(), magnitude: 1.0 });
let e = solve_static(&g.model, Some("Step"), &Limits::default()).unwrap_err().to_string();
assert!(e.contains("singular at node"), "{e}");
// Caps.
g.fixed("root", |q| near(q[0], 0.0));
let tight = Limits { max_dofs: 10, ..Limits::default() };
assert!(solve_static(&g.model, Some("Step"), &tight).unwrap_err().to_string().contains("limit"));
let tight = Limits { max_factor_entries: 5, ..Limits::default() };
let e = solve_static(&g.model, Some("Step"), &tight).unwrap_err().to_string();
assert!(e.contains("bytes of factor values alone") && e.contains("limit is 5 entries"), "{e}");
assert!(solve_static(&g.model, Some("Nope"), &Limits::default()).is_err());
assert!(solve_frequency(&g.model, Some("Step"), &FrequencyOptions::default(), &Limits::default()).is_err(), "a static step");
assert!(solve_static(&g.model, Some("Step"), &Limits::default()).is_ok());
}
/// Plasticity is explicit dynamics' business: the linear static solver gives the elastic answer
/// for a plastic material, bit for bit, and says it left the plasticity out.
#[test]
fn plastic_materials_solve_elastically_with_a_warning() {
let solve = |plastic: bool| {
let mut g = grid([4, 1, 1], ElementKind::Hex20, |q| [4.0 * q[0], q[1], q[2]]);
g.define(iso(1000.0, 0.3), None, full(), Procedure::Static {});
if plastic {
let mut m = g.model.material("Mat").unwrap().clone();
m.plastic = Some(fieldcraft_model::Plastic::Bilinear { yield_stress: 1.0, hardening_modulus: 0.0, hardening: Default::default() });
g.model.replace_material("Mat", m).unwrap();
}
g.fixed("root", |q| near(q[0], 0.0));
g.face_set("tip", |q| near(q[0], 1.0));
g.load("p", Load::Pressure { set: "tip".into(), magnitude: -10.0 });
static_solve(&g)
};
let (a, b) = (solve(false), solve(true));
assert_eq!(a.displacement, b.displacement);
assert!(b.warnings.iter().any(|w| w.contains("material `Mat`") && w.contains("elastic constants only")), "{:?}", b.warnings);
assert!(!a.warnings.iter().any(|w| w.contains("elastic constants only")));
}