//! 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::()); // 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}"); } } /// The patch test and uniaxial tension on a conforming hex8–pyramid5–tet4 transition mesh /// ([`transition_grid`]), distorted inside: the linear field is reproduced exactly, so the three /// kinds are compatible across their shared faces. #[test] fn hex_pyramid_tet_transition_passes_the_patch_test() { let a = [[1e-3, 2e-4, -3e-4], [5e-4, -2e-3, 1e-4], [-1e-4, 3e-4, 7e-4]]; let field = |x: [f64; 3]| [0, 1, 2].map(|i| (0..3).map(|j| a[i][j] * x[j]).sum::()); let map = |p: [f64; 3]| { let b = (PI * p[0]).sin() * (PI * p[1]).sin() * (PI * p[2]).sin(); [p[0] + 0.08 * b, p[1] - 0.06 * b, p[2] + 0.07 * b] }; let mut g = transition_grid([4, 2, 2], map); let kinds: std::collections::BTreeSet<&str> = g.model.elements().values().map(|e| e.kind.name()).collect(); assert_eq!(kinds.into_iter().collect::>(), ["hex8", "pyramid5", "tet4"]); g.define(iso(1000.0, 0.25), None, full(), 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 u = field(g.model.node(label).unwrap().xyz); 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)); } eprintln!("transition patch: displacement error {worst:.1e}"); assert!(worst < 1e-12, "{worst}"); // Uniaxial tension with symmetry planes: σxx = p and ux = p x / E everywhere. let (young, nu, p) = (2000.0, 0.3, 5.0); let mut g = transition_grid([4, 2, 2], map); g.define(iso(young, nu), None, full(), 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)); g.load("pull", Load::Pressure { set: "end".into(), magnitude: -p }); let r = static_solve(&g); let worst = r.nodes.iter().zip(&r.displacement).map(|(l, u)| (u[0] - p * g.model.node(*l).unwrap().xyz[0] / young).abs()).fold(0.0, f64::max); let s_err = r.stress.iter().map(|s| (s[0] - p).abs()).fold(0.0, f64::max); eprintln!("transition tension: displacement error {worst:.1e}, stress error {s_err:.1e}"); assert!(worst < 1e-11 && s_err < 1e-9, "{worst} {s_err}"); } /// 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::() / 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 { 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 = 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 = 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::() / 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 = 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::() / 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 = 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 = 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"))); }