fem: pyramid5 element (solver, quality, decks, VTK)

A five-node pyramid, the conforming transition between a hexahedron's face
and tetrahedra: Bedrosian's rational shape functions with a collapsed (Duffy)
Gauss rule, a certified Jacobian on the base square with exact volume, a
scaled Jacobian, C3D5 / CPYRAM / LS-DYNA degenerate solid, VTK_PYRAMID and
the binary mesh code 13. A distorted hex8-pyramid5-tet4 transition mesh
passes the patch test and uniaxial tension exactly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-16 12:48:01 -04:00
1 parent 4e6fd59409
commit 3a5a8213a1
28 files changed
+325 -28

No files matched your search

+59
View File
@@ -97,6 +97,65 @@ pub fn grid(n: [usize; 3], kind: ElementKind, map: impl Fn([f64; 3]) -> [f64; 3]
Grid { model, param, kind }
}
/// A hex–tet transition mesh on [0, 1]³ with `n` cells per direction: the cells with x below
/// one half are hex8, every other cell is split from its centre into six pyramids over its faces,
/// and each pyramid whose face is not shared with a hex cell into two tetrahedra (split along
/// the diagonal through the face's lowest node label, so neighbours agree). The mesh is
/// conforming: hexahedra meet pyramids on whole quadrilaterals, everything else on triangles.
pub fn transition_grid(n: [usize; 3], map: impl Fn([f64; 3]) -> [f64; 3]) -> Grid {
let size = n.map(|k| 2 * k + 1);
let label = |i: [usize; 3]| (1 + i[0] + size[0] * (i[1] + size[1] * i[2])) as u32;
let param_of = |i: [usize; 3]| [0, 1, 2].map(|k| i[k] as f64 / (2 * n[k]) as f64);
let mut model = Model::default();
let mut param = BTreeMap::new();
let mut node = |model: &mut Model, i: [usize; 3]| -> u32 {
let l = label(i);
if let std::collections::btree_map::Entry::Vacant(slot) = param.entry(l) {
let p = param_of(i);
model.set_node(l, map(p)).unwrap();
slot.insert(p);
}
l
};
let is_hex = |c: [isize; 3]| (0..3).all(|k| c[k] >= 0 && (c[k] as usize) < n[k]) && 2 * c[0] < n[0] as isize;
const CORNERS: [[usize; 3]; 8] = [[0, 0, 0], [2, 0, 0], [2, 2, 0], [0, 2, 0], [0, 0, 2], [2, 0, 2], [2, 2, 2], [0, 2, 2]];
// Hex8's faces (outward) and the cell across each.
let across: [[isize; 3]; 6] = [[0, 0, -1], [0, 0, 1], [0, -1, 0], [1, 0, 0], [0, 1, 0], [-1, 0, 0]];
let mut next = 1u32;
for cz in 0..n[2] {
for cy in 0..n[1] {
for cx in 0..n[0] {
let base = [2 * cx, 2 * cy, 2 * cz];
let at = |d: [usize; 3]| [base[0] + d[0], base[1] + d[1], base[2] + d[2]];
let cell = [cx as isize, cy as isize, cz as isize];
let corners: Vec<u32> = CORNERS.iter().map(|c| node(&mut model, at(*c))).collect();
if is_hex(cell) {
model.set_element(next, ElementKind::Hex8, corners).unwrap();
next += 1;
continue;
}
let apex = node(&mut model, at([1, 1, 1]));
for (face, d) in ElementKind::Hex8.faces().iter().zip(across) {
// Reversed, the outward face's normal points into the cell, at the apex.
let q: Vec<u32> = face.iter().rev().map(|&i| corners[i]).collect();
if is_hex([cell[0] + d[0], cell[1] + d[1], cell[2] + d[2]]) {
model.set_element(next, ElementKind::Pyramid5, vec![q[0], q[1], q[2], q[3], apex]).unwrap();
next += 1;
continue;
}
let lowest = (0..4).min_by_key(|&i| q[i]).unwrap();
let (a, b, c, e) = if lowest % 2 == 0 { (q[0], q[1], q[2], q[3]) } else { (q[1], q[2], q[3], q[0]) };
for tet in [[a, b, c, apex], [a, c, e, apex]] {
model.set_element(next, ElementKind::Tet4, tet.to_vec()).unwrap();
next += 1;
}
}
}
}
}
Grid { model, param, kind: ElementKind::Pyramid5 }
}
pub fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9
}
+50
View File
@@ -77,6 +77,56 @@ fn patch_test_every_element() {
}
}
/// 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::<f64>());
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::<Vec<_>>(), ["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).