Files
OneCAD-Simulation/crates/decks/tests/abaqus.rs
T
jyvikoandClaude Opus 5.5 50764def18 Explicit X2: large deformation, J2 and Johnson-Cook plasticity, damage and erosion (M10)
- model: materials gain solver-neutral plastic (bilinear isotropic/kinematic,
  hardening table, Johnson-Cook with adiabatic heating), damage (Johnson-Cook
  D1-D5) and erosion (plastic strain); explicit steps gain nonlinear_geometry
  (default on) and stress_rate (jaumann | green_naghdi).
- fem: rotation (Hughes-Winget, Flanagan-Taylor), plasticity (radial return,
  capped bracketed Newton for Johnson-Cook, damage; the update takes the
  mechanical strain increment so thermal strains can be subtracted), closed-form
  hex8 gradients for deformed configurations.
- explicit: updated Lagrangian hex8/tet4, per-element material state, erosion
  with reported energy, plastic work in the energy account.
- solver: warns that the linear solver uses the elastic constants of plastic
  materials (results hash-identical).
- decks: Abaqus *PLASTIC (+ Johnson-Cook keywords), Nastran MATS1/TABLES1,
  LS-DYNA *MAT_PLASTIC_KINEMATIC / *MAT_PIECEWISE_LINEAR_PLASTICITY; what a
  format cannot express is reported as Unsupported.
- engine: model.material.plasticity, model.material.damage (catalogue ready),
  explicit element probe, eroded elements in the solve summary.

Step commits before the rebase onto the DOF framework: branch explicit-x2-steps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-13 20:52:57 -04:00

756 lines
38 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.
//! Abaqus writer tests: golden decks of our own small models, a re-read of the written mesh and
//! sets by a minimal test-only reader, element orientation (Jacobian sign) from the written
//! connectivity, number round trips, line limits and everything reported as unsupported.
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use modelcraft_decks::{Error, abaqus};
use modelcraft_model::{
BeamProfile, BoundaryCondition, Coupling, CouplingKind, Distribution, Dofs, Elastic, ElementFace, ElementKind, Formulation, Integration, Load,
Material, Model, OutputVariable, Procedure, ReferencePoint, Section, SectionKind, Set, Step, Symmetry, Target, UnitSystem,
};
// ---------------------------------------------------------------------------------------------
// Models
fn steel() -> Material {
Material { elastic: Some(Elastic::Isotropic { young: 210_000.0, poisson: 0.3 }), density: Some(7.85e-9), ..Material::default() }
}
fn faces(list: &[(u32, u8)]) -> Set {
Set { faces: list.iter().map(|&(element, face)| ElementFace { element, face }).collect(), ..Set::default() }
}
fn elements(list: impl IntoIterator<Item = u32>) -> Set {
Set { elements: list.into_iter().collect(), ..Set::default() }
}
fn step(name: &str, procedure: Procedure, bcs: &[&str], loads: &[&str]) -> Step {
let mut s = Step::new(name, procedure);
s.boundary_conditions = bcs.iter().map(|s| s.to_string()).collect();
s.loads = loads.iter().map(|s| s.to_string()).collect();
s
}
/// A 100 × 10 × 10 cantilever of four hex8 elements along X: node (i, j, k) at
/// (25 i, 10 j, 10 k) is labelled 1 + 4 i + 2 j + k.
fn cantilever_mesh() -> Model {
let mut m = Model::default();
m.set_units(UnitSystem::MmTonneS);
let n = |i: u32, j: u32, k: u32| 1 + 4 * i + 2 * j + k;
for i in 0..=4 {
for j in 0..=1 {
for k in 0..=1 {
m.set_node(n(i, j, k), [25.0 * f64::from(i), 10.0 * f64::from(j), 10.0 * f64::from(k)]).unwrap();
}
}
}
for i in 0..4 {
let nodes = vec![n(i, 0, 0), n(i + 1, 0, 0), n(i + 1, 1, 0), n(i, 1, 0), n(i, 0, 1), n(i + 1, 0, 1), n(i + 1, 1, 1), n(i, 1, 1)];
m.set_element(i + 1, ElementKind::Hex8, nodes).unwrap();
}
m.add_set("all", elements(1..=4)).unwrap();
// Face 6 of element 1 lies on x = 0, face 4 of element 4 on x = 100.
m.add_set("root", faces(&[(1, 6)])).unwrap();
m.add_set("tip", faces(&[(4, 4)])).unwrap();
m.add_material("Steel", steel()).unwrap();
let formulation = Formulation { incompatible_modes: true, ..Formulation::default() };
m.add_section("beam", Section { set: "all".into(), material: "Steel".into(), kind: SectionKind::Solid {}, formulation }).unwrap();
m.add_boundary_condition("clamp", BoundaryCondition::Fixed { target: Target::Set("root".into()) }).unwrap();
m
}
/// The cantilever with a total end force of 100 down.
fn cantilever() -> Model {
let mut m = cantilever_mesh();
m.add_load("end", Load::Force { target: Target::Set("tip".into()), vector: [0.0, 0.0, -100.0], distribution: Distribution::Total }).unwrap();
m.add_step(step("Bend", Procedure::Static {}, &["clamp"], &["end"]), None).unwrap();
m
}
/// A 200 × 200 plate of 2 × 2 quad4 shells in z = 0, clamped along x = 0, under pressure on its
/// top face and its own weight, with a symmetry plane along x = 100.
fn plate() -> Model {
let mut m = Model::default();
m.set_units(UnitSystem::MmTonneS);
let n = |i: u32, j: u32| 1 + i + 3 * j;
for j in 0..=2 {
for i in 0..=2 {
m.set_node(n(i, j), [100.0 * f64::from(i), 100.0 * f64::from(j), 0.0]).unwrap();
}
}
let mut e = 1;
for j in 0..2 {
for i in 0..2 {
m.set_element(e, ElementKind::Quad4, vec![n(i, j), n(i + 1, j), n(i + 1, j + 1), n(i, j + 1)]).unwrap();
e += 1;
}
}
m.add_set("skin", elements(1..=4)).unwrap();
m.add_set("top", faces(&[(1, 1), (2, 1), (3, 1), (4, 1)])).unwrap();
m.add_set("edge", Set { nodes: vec![1, 4, 7], ..Set::default() }).unwrap();
m.add_set("mid", Set { nodes: vec![2, 5, 8], ..Set::default() }).unwrap();
let aluminium = Material { elastic: Some(Elastic::Isotropic { young: 70_000.0, poisson: 0.33 }), density: Some(2.7e-9), ..Material::default() };
m.add_material("Aluminium", aluminium).unwrap();
let kind = SectionKind::Shell { thickness: 2.0, offset: 0.5, points: 5 };
let formulation = Formulation { integration: Integration::Reduced, ..Formulation::default() };
m.add_section("plate", Section { set: "skin".into(), material: "Aluminium".into(), kind, formulation }).unwrap();
m.add_boundary_condition("clamp", BoundaryCondition::Fixed { target: Target::Set("edge".into()) }).unwrap();
m.add_boundary_condition("sym", BoundaryCondition::Symmetry { target: Target::Set("mid".into()), plane: Symmetry::X }).unwrap();
m.add_load("wind", Load::Pressure { set: "top".into(), magnitude: 0.01 }).unwrap();
m.add_load("weight", Load::Gravity { acceleration: [0.0, 0.0, -9810.0], set: None }).unwrap();
m.add_step(step("Load", Procedure::Static {}, &["clamp", "sym"], &["wind", "weight"]), None).unwrap();
m
}
/// The cantilever with a reference point coupled to its tip, three static steps that change the
/// active boundary conditions and loads, and a frequency step in between.
fn frequency() -> Model {
let mut m = cantilever_mesh();
m.add_reference_point("Tip", ReferencePoint { xyz: [110.0, 5.0, 5.0], label: 0 }).unwrap();
m.add_coupling("tie", Coupling { reference_point: "Tip".into(), set: "tip".into(), kind: CouplingKind::Kinematic, dofs: Dofs::all() }).unwrap();
let rp = || Target::ReferencePoint("Tip".into());
m.add_load("twist", Load::Moment { target: rp(), vector: [5000.0, 0.0, 0.0], distribution: Distribution::Total }).unwrap();
m.add_load("swell", Load::BodyForce { set: "all".into(), vector: [0.0, 1e-3, 0.0] }).unwrap();
m.add_boundary_condition("lift", BoundaryCondition::Displacement { target: rp(), values: [None, None, Some(0.5), None, None, None] }).unwrap();
m.add_step(step("Twist", Procedure::Static {}, &["clamp"], &["twist"]), None).unwrap();
m.add_step(step("Modes", Procedure::Frequency { modes: 6, min_hz: Some(1.0), max_hz: None }, &["clamp", "lift"], &[]), None).unwrap();
m.add_step(step("Lift", Procedure::Static {}, &["clamp", "lift"], &["twist", "swell"]), None).unwrap();
let mut release = step("Release", Procedure::Static {}, &["clamp"], &[]);
release.output = [OutputVariable::Displacement].into_iter().collect();
m.add_step(release, None).unwrap();
m
}
// ---------------------------------------------------------------------------------------------
// A minimal reader of what the writer emits (full deck readers are a later milestone)
#[derive(Default, Debug)]
struct Parsed {
nodes: BTreeMap<u32, [f64; 3]>,
elements: BTreeMap<u32, (String, Vec<u32>)>,
nsets: BTreeMap<String, Vec<u32>>,
elsets: BTreeMap<String, Vec<u32>>,
surfaces: BTreeMap<String, Vec<(u32, String)>>,
}
fn param(keyword: &str, name: &str) -> Option<String> {
keyword.split(',').skip(1).find_map(|p| {
let (k, v) = p.split_once('=')?;
(k.trim().eq_ignore_ascii_case(name)).then(|| v.trim().to_string())
})
}
fn read(deck: &str) -> Parsed {
let mut p = Parsed::default();
let mut keyword = String::new();
let mut pending: Vec<String> = Vec::new();
for line in deck.lines() {
if line.starts_with("**") {
continue;
}
if line.starts_with('*') {
keyword = line.to_string();
continue;
}
let head = keyword.split(',').next().unwrap_or_default().trim().to_ascii_uppercase();
let fields: Vec<String> = line.split(',').map(|f| f.trim().to_string()).collect();
let label = |s: &str| s.parse::<u32>().unwrap();
match head.as_str() {
"*NODE" => {
let xyz = [1, 2, 3].map(|i| fields[i].parse::<f64>().unwrap());
p.nodes.insert(label(&fields[0]), xyz);
}
"*ELEMENT" => {
pending.extend(fields.iter().filter(|f| !f.is_empty()).cloned());
if !line.trim_end().ends_with(',') {
let ids: Vec<u32> = pending.drain(..).map(|f| label(&f)).collect();
p.elements.insert(ids[0], (param(&keyword, "TYPE").unwrap(), ids[1..].to_vec()));
}
}
"*NSET" => p.nsets.entry(param(&keyword, "NSET").unwrap()).or_default().extend(fields.iter().map(|f| label(f))),
"*ELSET" => p.elsets.entry(param(&keyword, "ELSET").unwrap()).or_default().extend(fields.iter().map(|f| label(f))),
"*SURFACE" if param(&keyword, "TYPE").as_deref() == Some("ELEMENT") => {
p.surfaces.entry(param(&keyword, "NAME").unwrap()).or_default().push((label(&fields[0]), fields[1].clone()));
}
_ => {}
}
}
p
}
// ---------------------------------------------------------------------------------------------
// Golden decks
fn golden(name: &str, deck: &str) {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/abaqus").join(format!("{name}.inp"));
if std::env::var_os("MODELCRAFT_BLESS").is_some() {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, deck).unwrap();
}
let expected = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e} (run with MODELCRAFT_BLESS=1 to write it)", path.display()));
assert!(expected == deck, "{name}.inp differs from the written deck (MODELCRAFT_BLESS=1 rewrites it):\n{deck}");
}
fn assert_lines_fit(deck: &str) {
for (i, line) in deck.lines().enumerate() {
assert!(line.len() <= abaqus::MAX_LINE, "line {} has {} characters", i + 1, line.len());
assert!(line.is_ascii(), "line {} is not ASCII", i + 1);
}
}
#[test]
fn golden_decks() {
for (name, model) in [("cantilever", cantilever()), ("plate", plate()), ("frequency", frequency())] {
let deck = abaqus::write(&model).unwrap();
assert_lines_fit(&deck);
golden(name, &deck);
// Deterministic: the same model, and the model after a file round trip, give the same bytes.
assert_eq!(abaqus::write(&model).unwrap(), deck);
let reread: Model = serde_json::from_str(&serde_json::to_string(&model).unwrap()).unwrap();
assert_eq!(abaqus::write(&reread).unwrap(), deck, "{name}");
}
}
#[test]
fn the_end_force_is_shared_by_area_and_keeps_its_total() {
let deck = abaqus::write(&cantilever()).unwrap();
let cload: Vec<&str> = deck.lines().skip_while(|l| !l.starts_with("*CLOAD")).skip(1).take_while(|l| !l.starts_with('*')).collect();
// Four equal corners of a flat square face.
assert_eq!(cload, ["17, 3, -25.0", "18, 3, -25.0", "19, 3, -25.0", "20, 3, -25.0"]);
let total: f64 = cload.iter().map(|l| l.rsplit(", ").next().unwrap().parse::<f64>().unwrap()).sum();
assert_eq!(total, -100.0);
}
#[test]
fn the_written_mesh_and_sets_read_back() {
for model in [cantilever(), plate(), frequency()] {
let deck = abaqus::write(&model).unwrap();
let p = read(&deck);
let rps: BTreeMap<u32, [f64; 3]> = model.reference_points().values().map(|r| (r.label, r.xyz)).collect();
let mut nodes: BTreeMap<u32, [f64; 3]> = model.nodes().iter().map(|(l, n)| (*l, n.xyz)).collect();
nodes.extend(rps);
assert_eq!(p.nodes, nodes);
let connectivity: BTreeMap<u32, Vec<u32>> = p.elements.iter().map(|(l, (_, n))| (*l, n.clone())).collect();
assert_eq!(connectivity, model.elements().iter().map(|(l, e)| (*l, e.nodes.clone())).collect());
let r = model.resolve().unwrap();
for (name, s) in &r.sets {
assert_eq!(p.nsets.get(name).cloned().unwrap_or_default(), s.nodes, "nset {name}");
assert_eq!(p.elsets.get(name).cloned().unwrap_or_default(), s.elements, "elset {name}");
let faces: Vec<(u32, String)> =
s.faces.iter().map(|f| (f.element, abaqus::face_id(model.element(f.element).unwrap().kind, f.face).unwrap().to_string())).collect();
assert_eq!(p.surfaces.get(name).cloned().unwrap_or_default(), faces, "surface {name}");
}
}
}
// ---------------------------------------------------------------------------------------------
// Node order: the element Jacobian of the written connectivity, in Abaqus's own conventions
/// Natural coordinates of each node, by Abaqus element family.
fn natural(kind: ElementKind) -> Vec<[f64; 3]> {
let hex = [[-1., -1., -1.], [1., -1., -1.], [1., 1., -1.], [-1., 1., -1.], [-1., -1., 1.], [1., -1., 1.], [1., 1., 1.], [-1., 1., 1.]];
match kind {
ElementKind::Hex8 => hex.to_vec(),
ElementKind::Hex20 => {
let mut v = hex.to_vec();
// Mid-sides: the bottom face's edges, the top face's, then the vertical ones.
v.extend([[0., -1., -1.], [1., 0., -1.], [0., 1., -1.], [-1., 0., -1.]]);
v.extend([[0., -1., 1.], [1., 0., 1.], [0., 1., 1.], [-1., 0., 1.]]);
v.extend([[-1., -1., 0.], [1., -1., 0.], [1., 1., 0.], [-1., 1., 0.]]);
v
}
ElementKind::Tet4 | ElementKind::Tet10 => {
let mut v = vec![[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0., 0., 1.]];
if kind == ElementKind::Tet10 {
v.extend([[0.5, 0., 0.], [0.5, 0.5, 0.], [0., 0.5, 0.], [0., 0., 0.5], [0.5, 0., 0.5], [0., 0.5, 0.5]]);
}
v
}
ElementKind::Wedge6 => vec![[0., 0., -1.], [1., 0., -1.], [0., 1., -1.], [0., 0., 1.], [1., 0., 1.], [0., 1., 1.]],
ElementKind::Quad4 => vec![[-1., -1., 0.], [1., -1., 0.], [1., 1., 0.], [-1., 1., 0.]],
ElementKind::Quad8 => {
vec![[-1., -1., 0.], [1., -1., 0.], [1., 1., 0.], [-1., 1., 0.], [0., -1., 0.], [1., 0., 0.], [0., 1., 0.], [-1., 0., 0.]]
}
ElementKind::Tri3 => vec![[0., 0., 0.], [1., 0., 0.], [0., 1., 0.]],
_ => Vec::new(),
}
}
/// Abaqus's shape functions of an element family at natural point `q`.
fn shape(kind: ElementKind, q: [f64; 3]) -> Vec<f64> {
let nodes = natural(kind);
let [x, y, z] = q;
match kind {
ElementKind::Hex8 => nodes.iter().map(|p| (1. + x * p[0]) * (1. + y * p[1]) * (1. + z * p[2]) / 8.).collect(),
ElementKind::Hex20 => nodes
.iter()
.map(|p| {
let (a, b, c) = (1. + x * p[0], 1. + y * p[1], 1. + z * p[2]);
if p[0] == 0. {
(1. - x * x) * b * c / 4.
} else if p[1] == 0. {
a * (1. - y * y) * c / 4.
} else if p[2] == 0. {
a * b * (1. - z * z) / 4.
} else {
a * b * c * (x * p[0] + y * p[1] + z * p[2] - 2.) / 8.
}
})
.collect(),
ElementKind::Tet4 => vec![1. - x - y - z, x, y, z],
ElementKind::Tet10 => {
let l = [1. - x - y - z, x, y, z];
let mut n: Vec<f64> = l.iter().map(|li| li * (2. * li - 1.)).collect();
for (a, b) in [(0, 1), (1, 2), (2, 0), (0, 3), (1, 3), (2, 3)] {
n.push(4. * l[a] * l[b]);
}
n
}
ElementKind::Wedge6 => {
let t = [1. - x - y, x, y];
t.iter().map(|ti| ti * (1. - z) / 2.).chain(t.iter().map(|ti| ti * (1. + z) / 2.)).collect()
}
ElementKind::Quad4 => nodes.iter().map(|p| (1. + x * p[0]) * (1. + y * p[1]) / 4.).collect(),
ElementKind::Quad8 => nodes
.iter()
.map(|p| {
if p[0] == 0. {
(1. - x * x) * (1. + y * p[1]) / 2.
} else if p[1] == 0. {
(1. + x * p[0]) * (1. - y * y) / 2.
} else {
(1. + x * p[0]) * (1. + y * p[1]) * (x * p[0] + y * p[1] - 1.) / 4.
}
})
.collect(),
ElementKind::Tri3 => vec![1. - x - y, x, y],
_ => Vec::new(),
}
}
/// Columns ∂X/∂ξ, ∂X/∂η, ∂X/∂ζ of the mapping at `q` (central differences).
fn jacobian(kind: ElementKind, xyz: &[[f64; 3]], q: [f64; 3]) -> [[f64; 3]; 3] {
let map = |q: [f64; 3]| {
let n = shape(kind, q);
let mut p = [0.0; 3];
for (ni, x) in n.iter().zip(xyz) {
for k in 0..3 {
p[k] += ni * x[k];
}
}
p
};
let h = 1e-6;
[0, 1, 2].map(|d| {
let (mut a, mut b) = (q, q);
a[d] += h;
b[d] -= h;
let (pa, pb) = (map(a), map(b));
[0, 1, 2].map(|k| (pa[k] - pb[k]) / (2. * h))
})
}
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
/// The smallest Jacobian determinant over the nodes and the centroid (solids), or the smallest
/// normal component along `up` (shells).
fn min_jacobian(kind: ElementKind, xyz: &[[f64; 3]], up: [f64; 3]) -> f64 {
let nodes = natural(kind);
let centroid = [0, 1, 2].map(|k| nodes.iter().map(|p| p[k]).sum::<f64>() / nodes.len() as f64);
nodes
.iter()
.chain([&centroid])
.map(|q| {
let [a, b, c] = jacobian(kind, xyz, *q);
if kind.dimension() == 3 { dot(cross(a, b), c) } else { dot(cross(a, b), up) }
})
.fold(f64::INFINITY, f64::min)
}
/// A one-element model of `kind` with a slanted, stretched, positive-volume placement of the
/// model's reference element (mid-side nodes at edge midpoints).
fn one_element(kind: ElementKind) -> Model {
let corners: Vec<[f64; 3]> = match kind {
ElementKind::Tri3 => vec![[0., 0., 0.], [1., 0., 0.], [0., 1., 0.]],
ElementKind::Quad4 | ElementKind::Quad8 => vec![[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.]],
ElementKind::Tet4 | ElementKind::Tet10 => vec![[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0., 0., 1.]],
ElementKind::Wedge6 => vec![[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0., 0., 1.], [1., 0., 1.], [0., 1., 1.]],
_ => vec![[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.], [0., 0., 1.], [1., 0., 1.], [1., 1., 1.], [0., 1., 1.]],
};
let mut xyz = corners.clone();
xyz.resize(kind.node_count(), [0.0; 3]);
for e in kind.edges() {
if let [a, b, m] = **e {
xyz[m] = [0, 1, 2].map(|i| (corners[a][i] + corners[b][i]) / 2.0);
}
}
// A shear and stretch with a positive determinant, then a shift.
let place = |p: [f64; 3]| [3. * p[0] + 0.5 * p[1] + 10., 2. * p[1] + 0.3 * p[2] - 4., 1.5 * p[2] + 0.2 * p[0] + 7.];
let mut m = Model::default();
for (i, p) in xyz.iter().enumerate() {
m.set_node(i as u32 + 1, place(*p)).unwrap();
}
m.set_element(1, kind, (1..=kind.node_count() as u32).collect()).unwrap();
m.add_set("all", elements([1])).unwrap();
m.add_set("base", Set { nodes: vec![1], ..Set::default() }).unwrap();
m.add_material("Steel", steel()).unwrap();
let (section, formulation) = if kind.dimension() == 3 {
(SectionKind::Solid {}, Formulation::default())
} else {
(SectionKind::Shell { thickness: 1.0, offset: 0.0, points: 5 }, Formulation { integration: Integration::Reduced, ..Formulation::default() })
};
m.add_section("s", Section { set: "all".into(), material: "Steel".into(), kind: section, formulation }).unwrap();
m.add_boundary_condition("fix", BoundaryCondition::Fixed { target: Target::Set("base".into()) }).unwrap();
m.add_step(step("Modes", Procedure::Frequency { modes: 1, min_hz: None, max_hz: None }, &["fix"], &[]), None).unwrap();
m
}
#[test]
fn written_connectivity_has_a_positive_jacobian_in_abaqus_conventions() {
let kinds = [ElementKind::Tet4, ElementKind::Tet10, ElementKind::Wedge6, ElementKind::Hex8, ElementKind::Hex20];
let shells = [ElementKind::Tri3, ElementKind::Quad4, ElementKind::Quad8];
for kind in kinds.into_iter().chain(shells) {
let model = one_element(kind);
let p = read(&abaqus::write(&model).unwrap());
let (_, nodes) = &p.elements[&1];
let xyz: Vec<[f64; 3]> = nodes.iter().map(|n| p.nodes[n]).collect();
// A shell's positive normal is the side its face 1 (SPOS) looks to: the placed +Z here.
let up = cross([3., 0., 0.2], [0.5, 2., 0.]);
let j = min_jacobian(kind, &xyz, up);
assert!(j > 0.0, "{kind:?}: smallest Jacobian {j}");
// The test has teeth: a mirrored linear element shows up (and a misplaced quadratic
// mid-side block, below).
if kind.node_count() == natural(kind).len()
&& matches!(kind, ElementKind::Tet4 | ElementKind::Wedge6 | ElementKind::Hex8 | ElementKind::Tri3 | ElementKind::Quad4)
{
let mut mirrored = xyz.clone();
match kind {
ElementKind::Tet4 | ElementKind::Tri3 => mirrored.swap(1, 2),
ElementKind::Wedge6 => {
mirrored.swap(1, 2);
mirrored.swap(4, 5);
}
ElementKind::Quad4 => mirrored.swap(1, 3),
_ => {
mirrored.swap(1, 3);
mirrored.swap(5, 7);
}
}
assert!(min_jacobian(kind, &mirrored, up) < 0.0, "{kind:?}: mirrored");
}
}
// Hex20 in the order with the vertical edges' mid-sides before the top face's (Nastran's).
let model = one_element(ElementKind::Hex20);
let p = read(&abaqus::write(&model).unwrap());
let mut xyz: Vec<[f64; 3]> = p.elements[&1].1.iter().map(|n| p.nodes[n]).collect();
let (top, vertical) = (xyz[12..16].to_vec(), xyz[16..20].to_vec());
xyz[12..16].copy_from_slice(&vertical);
xyz[16..20].copy_from_slice(&top);
assert!(min_jacobian(ElementKind::Hex20, &xyz, [0.; 3]) < 0.0, "a misplaced mid-side block is caught");
}
#[test]
fn model_faces_are_abaqus_faces() {
// Each face's node positions (1-based, Abaqus element library, our transcription).
let table: [(ElementKind, &[&[usize]]); 3] = [
(ElementKind::Hex8, &[&[1, 2, 3, 4], &[5, 8, 7, 6], &[1, 5, 6, 2], &[2, 6, 7, 3], &[3, 7, 8, 4], &[4, 8, 5, 1]]),
(ElementKind::Tet4, &[&[1, 2, 3], &[1, 4, 2], &[2, 4, 3], &[3, 4, 1]]),
(ElementKind::Wedge6, &[&[1, 2, 3], &[4, 6, 5], &[1, 4, 5, 2], &[2, 5, 6, 3], &[3, 6, 4, 1]]),
];
for (kind, faces) in table {
assert_eq!(kind.faces().len(), faces.len());
for (i, (ours, theirs)) in kind.faces().iter().zip(faces).enumerate() {
let ours: BTreeSet<usize> = ours.iter().map(|p| p + 1).collect();
let theirs: BTreeSet<usize> = theirs.iter().copied().collect();
assert_eq!(ours, theirs, "{kind:?} face {}", i + 1);
assert_eq!(abaqus::face_id(kind, i as u8 + 1), Some(format!("S{}", i + 1).as_str()));
}
assert_eq!(abaqus::face_id(kind, 0), None);
assert_eq!(abaqus::face_id(kind, faces.len() as u8 + 1), None);
}
assert_eq!(abaqus::face_id(ElementKind::Quad4, 1), Some("SPOS"));
assert_eq!(abaqus::face_id(ElementKind::Tri3, 2), Some("SNEG"));
assert_eq!(abaqus::face_id(ElementKind::Beam2, 1), None);
}
// ---------------------------------------------------------------------------------------------
// Numbers and limits
#[test]
fn numbers_read_back_exactly() {
for (v, s) in [(0.1, "0.1"), (210_000.0, "210000.0"), (7.85e-9, "7.85E-9"), (1e300, "1.0E300"), (-0.5, "-0.5"), (1e16, "1.0E16")] {
assert_eq!(abaqus::number(v).unwrap(), s);
}
let mut x: u64 = 0x9E37_79B9_7F4A_7C15;
for _ in 0..100_000 {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
let v = f64::from_bits(x);
if !v.is_finite() {
assert!(abaqus::number(v).is_err());
continue;
}
let s = abaqus::number(v).unwrap();
assert_eq!(s.parse::<f64>().unwrap().to_bits(), v.to_bits(), "{v:e} → {s}");
assert!(s.len() <= 24, "{s}");
assert!(!s.contains('e'), "{s}");
}
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
assert!(matches!(abaqus::number(bad), Err(Error::Unsupported { .. })));
}
}
#[test]
fn largest_labels_longest_numbers_and_names_fit_a_line() {
let mut m = Model::default();
let base = modelcraft_model::MAX_LABEL - 30;
let ugly = -1.234_567_890_123_456_7e-300;
let mut xyz = one_element(ElementKind::Hex20).nodes().values().map(|n| n.xyz).collect::<Vec<_>>();
xyz[0] = [ugly, ugly, ugly];
for (i, p) in xyz.iter().enumerate() {
m.set_node(base + i as u32, *p).unwrap();
}
m.set_element(modelcraft_model::MAX_LABEL, ElementKind::Hex20, (0..20).map(|i| base + i).collect()).unwrap();
let long = format!("A{}", "x".repeat(modelcraft_model::MAX_NAME_LEN - 1));
m.add_set(&long, Set { elements: vec![modelcraft_model::MAX_LABEL], nodes: (0..20).map(|i| base + i).collect(), ..Set::default() }).unwrap();
m.add_material(
&long,
Material { elastic: Some(Elastic::Isotropic { young: ugly.abs(), poisson: 0.3 }), density: Some(1.0), ..Material::default() },
)
.unwrap();
m.add_section(&long, Section { set: long.clone(), material: long.clone(), kind: SectionKind::Solid {}, formulation: Formulation::default() })
.unwrap();
m.add_reference_point(&long, ReferencePoint { xyz: [ugly; 3], label: 0 }).unwrap();
m.add_coupling(&long, Coupling { reference_point: long.clone(), set: long.clone(), kind: CouplingKind::Kinematic, dofs: Dofs::all() }).unwrap();
m.add_boundary_condition(&long, BoundaryCondition::Fixed { target: Target::Set(long.clone()) }).unwrap();
m.add_step(step(&long, Procedure::Frequency { modes: 100_000, min_hz: Some(ugly.abs()), max_hz: Some(1e300) }, &[&long], &[]), None).unwrap();
let deck = abaqus::write(&m).unwrap();
assert_lines_fit(&deck);
let p = read(&deck);
assert_eq!(p.elements[&modelcraft_model::MAX_LABEL].1.len(), 20, "a hex20 continues onto a second line");
assert_eq!(p.nodes[&base], [ugly; 3]);
}
// ---------------------------------------------------------------------------------------------
// Step activity, load summing
fn block<'a>(deck: &'a str, step: &str) -> Vec<&'a str> {
let start = format!("*STEP, NAME={step}");
deck.lines().skip_while(|l| !l.starts_with(&start)).take_while(|l| *l != "*END STEP").filter(|l| !l.starts_with("**")).collect()
}
#[test]
fn steps_write_the_activity_table() {
let deck = abaqus::write(&frequency()).unwrap();
// `clamp` holds at zero in every step: the initial state.
let initial: Vec<&str> = deck.lines().skip_while(|l| !l.starts_with("** Initial state")).skip(1).take(3).collect();
assert_eq!(initial, ["*BOUNDARY", "** boundary condition clamp", "root, ENCASTRE"]);
let twist = block(&deck, "Twist");
assert!(twist.contains(&"*CLOAD, OP=NEW") && twist.contains(&"99999999, 4, 5000.0"), "{twist:?}");
assert!(!twist.iter().any(|l| l.starts_with("*BOUNDARY")), "{twist:?}");
// The frequency step adds `lift` to its base state and writes no loads.
let modes = block(&deck, "Modes");
assert!(modes.contains(&"*BOUNDARY") && modes.contains(&"99999999, 3, 3, 0.5"), "{modes:?}");
assert!(!modes.iter().any(|l| l.contains("LOAD")), "{modes:?}");
assert!(modes.contains(&"6, 1.0"), "{modes:?}");
// `Lift` adds `lift` again: the frequency step's change did not carry on.
let lift = block(&deck, "Lift");
assert!(lift.contains(&"*BOUNDARY") && lift.contains(&"99999999, 3, 3, 0.5"), "{lift:?}");
assert!(lift.contains(&"*DLOAD, OP=NEW") && lift.contains(&"all, BY, 0.001"), "{lift:?}");
// `Release` drops `lift` and every load.
let release = block(&deck, "Release");
assert!(release.contains(&"*BOUNDARY, OP=NEW") && release.contains(&"root, ENCASTRE"), "{release:?}");
for k in ["*CLOAD, OP=NEW", "*DLOAD, OP=NEW"] {
let at = release.iter().position(|l| *l == k).unwrap_or_else(|| panic!("{k} in {release:?}"));
assert!(release.get(at + 1).is_none_or(|l| l.starts_with('*')), "{k} restates nothing: {release:?}");
}
assert!(!release.contains(&"*DSLOAD, OP=NEW"), "no pressure before, none now");
assert!(release.contains(&"U") && !release.contains(&"*ELEMENT OUTPUT"), "{release:?}");
}
#[test]
fn loads_sharing_nodes_elements_or_surfaces_add_up() {
let mut m = plate();
m.add_load("gust", Load::Pressure { set: "top".into(), magnitude: 0.02 }).unwrap();
m.add_set("half", elements([1, 2])).unwrap();
m.add_load("extra", Load::Gravity { acceleration: [0.0, 0.0, -9810.0], set: Some("half".into()) }).unwrap();
m.add_load("p1", Load::Force { target: Target::Set("mid".into()), vector: [3.0, 0.0, 0.0], distribution: Distribution::PerNode }).unwrap();
m.add_load("p2", Load::Force { target: Target::Set("mid".into()), vector: [1.0, 0.0, 0.0], distribution: Distribution::PerNode }).unwrap();
for l in ["gust", "extra", "p1", "p2"] {
m.set_load_active("Load", l, true).unwrap();
}
let deck = abaqus::write(&m).unwrap();
let lines = block(&deck, "Load");
assert!(lines.contains(&"top, P, 0.03"), "{lines:?}");
assert!(lines.contains(&"5, 1, 4.0"), "{lines:?}");
// Two gravity loads on elements 1 and 2: summed per element.
assert!(lines.contains(&"1, GRAV, 19620.0, 0.0, 0.0, -1.0"), "{lines:?}");
assert!(lines.contains(&"3, GRAV, 9810.0, 0.0, 0.0, -1.0"), "{lines:?}");
assert!(!lines.iter().any(|l| l.starts_with("skin, GRAV")), "{lines:?}");
}
// ---------------------------------------------------------------------------------------------
// What the format cannot express, and models that are not ready
fn unsupported(m: &Model) -> String {
match abaqus::write(m) {
Err(Error::Unsupported { format: "Abaqus", what }) => what,
other => panic!("expected unsupported, got {other:?}"),
}
}
fn with_section(kind: ElementKind, section: SectionKind, integration: Integration) -> Model {
let mut m = one_element(kind);
let formulation = Formulation { integration, ..Formulation::default() };
m.replace_section("s", Section { set: "all".into(), material: "Steel".into(), kind: section, formulation }).unwrap();
m
}
#[test]
fn what_abaqus_cannot_express_is_named() {
let shell = SectionKind::Shell { thickness: 1.0, offset: 0.0, points: 5 };
let mut tri6 = Model::default();
for (i, p) in [[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0.5, 0., 0.], [0.5, 0.5, 0.], [0., 0.5, 0.]].iter().enumerate() {
tri6.set_node(i as u32 + 1, *p).unwrap();
}
tri6.set_element(1, ElementKind::Tri6, (1..=6).collect()).unwrap();
tri6.add_set("all", elements([1])).unwrap();
tri6.add_material("Steel", steel()).unwrap();
tri6.add_section("s", Section { set: "all".into(), material: "Steel".into(), kind: shell, formulation: Formulation::default() }).unwrap();
tri6.add_step(step("Modes", Procedure::Frequency { modes: 1, min_hz: None, max_hz: None }, &[], &[]), None).unwrap();
assert!(unsupported(&tri6).contains("tri6 shells"));
assert!(unsupported(&with_section(ElementKind::Quad8, shell, Integration::Full)).contains("full integration of quad8"));
assert!(unsupported(&with_section(ElementKind::Tet10, SectionKind::Solid {}, Integration::Reduced)).contains("reduced integration of tet10"));
assert!(unsupported(&with_section(ElementKind::Wedge6, SectionKind::Solid {}, Integration::Reduced)).contains("wedge6"));
// A linear tetrahedron integrates at one point either way.
assert!(abaqus::write(&with_section(ElementKind::Tet4, SectionKind::Solid {}, Integration::Reduced)).unwrap().contains("TYPE=C3D4\n"));
let mut case = cantilever();
case.add_set("ROOT", Set { nodes: vec![1], ..Set::default() }).unwrap();
let what = unsupported(&case);
assert!(what.contains("sets `ROOT` and `root`") || what.contains("sets `root` and `ROOT`"), "{what}");
let mut ortho = plate();
let carbon = Elastic::EngineeringConstants { young: [140e3, 10e3, 10e3], poisson: [0.3, 0.3, 0.4], shear: [5e3, 5e3, 3.8e3] };
ortho.replace_material("Aluminium", Material { elastic: Some(carbon), density: Some(1.6e-9), ..Material::default() }).unwrap();
assert!(unsupported(&ortho).contains("orthotropic material `Aluminium` in a shell section"));
let mut loose = frequency();
let dofs = Dofs::try_from(vec![1, 2, 4, 5, 6]).unwrap();
loose.replace_coupling("tie", Coupling { reference_point: "Tip".into(), set: "tip".into(), kind: CouplingKind::Distributing, dofs }).unwrap();
assert!(unsupported(&loose).contains("coupling `tie`"));
// Several problems are named together.
let mut both = with_section(ElementKind::Quad8, shell, Integration::Full);
both.add_set("ALL", Set { nodes: vec![1], ..Set::default() }).unwrap();
assert!(abaqus::write(&both).unwrap_err().to_string().contains("full integration"));
}
#[test]
fn orthotropic_solids_beams_and_rods_are_written() {
let mut m = cantilever();
let carbon = Elastic::EngineeringConstants { young: [140e3, 10e3, 10e3], poisson: [0.3, 0.3, 0.4], shear: [5e3, 5e3, 3.8e3] };
m.replace_material("Steel", Material { elastic: Some(carbon), density: None, ..Material::default() }).unwrap();
let deck = abaqus::write(&m).unwrap();
assert!(deck.contains("*ELASTIC, TYPE=ENGINEERING CONSTANTS\n140000.0, 10000.0, 10000.0, 0.3, 0.3, 0.4, 5000.0, 5000.0\n3800.0\n"), "{deck}");
// A frame of one beam and one rod.
let mut f = Model::default();
for (i, p) in [[0., 0., 0.], [1000., 0., 0.], [1000., 0., 500.]].iter().enumerate() {
f.set_node(i as u32 + 1, *p).unwrap();
}
f.set_element(1, ElementKind::Beam2, vec![1, 2]).unwrap();
f.set_element(2, ElementKind::Rod2, vec![2, 3]).unwrap();
f.add_set("beam", elements([1])).unwrap();
f.add_set("rod", elements([2])).unwrap();
f.add_set("base", Set { nodes: vec![1, 3], ..Set::default() }).unwrap();
f.add_material("Steel", steel()).unwrap();
let i = BeamProfile::I { height: 100.0, width: 50.0, web: 5.0, flange: 8.0 };
let beam = SectionKind::Beam { profile: i, orientation: [0.0, 1.0, 0.0] };
f.add_section("b", Section { set: "beam".into(), material: "Steel".into(), kind: beam, formulation: Formulation::default() }).unwrap();
let rod = SectionKind::Rod { area: 50.0 };
f.add_section("r", Section { set: "rod".into(), material: "Steel".into(), kind: rod, formulation: Formulation::default() }).unwrap();
f.add_boundary_condition("pin", BoundaryCondition::Fixed { target: Target::Set("base".into()) }).unwrap();
f.add_step(step("Modes", Procedure::Frequency { modes: 3, min_hz: None, max_hz: Some(500.0) }, &["pin"], &[]), None).unwrap();
let deck = abaqus::write(&f).unwrap();
for expected in [
"*ELEMENT, TYPE=B31\n1, 1, 2\n",
"*ELEMENT, TYPE=T3D2\n2, 2, 3\n",
"*BEAM SECTION, SECTION=I, ELSET=beam, MATERIAL=Steel\n50.0, 100.0, 50.0, 50.0, 8.0, 8.0, 5.0\n0.0, 1.0, 0.0\n",
"*SOLID SECTION, ELSET=rod, MATERIAL=Steel\n50.0\n",
"3, , 500.0\n",
] {
assert!(deck.contains(expected), "{expected}\n{deck}");
}
}
#[test]
fn a_model_that_is_not_ready_is_refused() {
let mut m = cantilever();
m.remove_section("beam").unwrap();
assert!(matches!(abaqus::write(&m), Err(Error::Model(_))));
assert!(matches!(abaqus::write(&Model::default()), Err(Error::Model(_))));
}
/// The cantilever with its steel given each plasticity in turn.
fn plastic_cantilever(p: modelcraft_model::Plastic) -> Model {
let mut m = cantilever();
m.replace_material("Steel", Material { plastic: Some(p), ..steel() }).unwrap();
m
}
/// Plasticity is written as `*PLASTIC` (and Johnson–Cook's rate and heat keywords); damage and
/// erosion, failure models of Abaqus/Explicit, are reported.
#[test]
fn plasticity_is_written_and_failure_reported() {
use modelcraft_model::{Damage, Erosion, Hardening, HardeningPoint, Plastic};
let section = |deck: &str| deck.split("*MATERIAL, NAME=Steel").nth(1).and_then(|s| s.split("*SOLID SECTION").next()).unwrap().to_string();
let deck =
abaqus::write(&plastic_cantilever(Plastic::Bilinear { yield_stress: 250.0, hardening_modulus: 1000.0, hardening: Hardening::Isotropic }))
.unwrap();
assert!(section(&deck).contains("*PLASTIC\n250.0, 0.0\n10250.0, 10.0\n"), "{}", section(&deck));
let deck =
abaqus::write(&plastic_cantilever(Plastic::Bilinear { yield_stress: 250.0, hardening_modulus: 1000.0, hardening: Hardening::Kinematic }))
.unwrap();
assert!(section(&deck).contains("*PLASTIC, HARDENING=KINEMATIC\n250.0, 0.0\n1250.0, 1.0\n"), "{}", section(&deck));
let table = Plastic::Table { points: vec![HardeningPoint { strain: 0.0, stress: 200.0 }, HardeningPoint { strain: 0.1, stress: 300.0 }] };
let deck = abaqus::write(&plastic_cantilever(table)).unwrap();
assert!(section(&deck).contains("*PLASTIC\n200.0, 0.0\n300.0, 0.1\n"), "{}", section(&deck));
let jc = Plastic::JohnsonCook {
a: 90.0,
b: 292.0,
n: 0.31,
c: 0.025,
m: 1.09,
reference_strain_rate: 1.0,
room_temperature: 294.0,
melt_temperature: 1356.0,
specific_heat: Some(3.83e8),
taylor_quinney: 0.9,
};
let deck = abaqus::write(&plastic_cantilever(jc.clone())).unwrap();
let s = section(&deck);
assert!(
s.contains("*PLASTIC, HARDENING=JOHNSON COOK\n90.0, 292.0, 0.31, 1.09, 1356.0, 294.0\n*RATE DEPENDENT, TYPE=JOHNSON COOK\n0.025, 1.0\n*SPECIFIC HEAT\n383000000.0\n*INELASTIC HEAT FRACTION\n0.9\n"),
"{s}"
);
let mut failing = plastic_cantilever(jc);
let mut steel = failing.material("Steel").unwrap().clone();
steel.damage = Some(Damage::JohnsonCook { d1: 0.54, d2: 4.89, d3: -3.03, d4: 0.014, d5: 1.12 });
steel.erosion = Some(Erosion { plastic_strain: 1.0 });
failing.replace_material("Steel", steel).unwrap();
let Err(Error::Unsupported { what, .. }) = abaqus::write(&failing) else { panic!("damage is not written") };
assert!(what.contains("material `Steel`: damage and erosion"), "{what}");
}