FEM foundation A: DofKind/DofLayout, shape-function library with Hessians, solvers on DofLayout (#35)

* fem: DofKind/DofLayout and the shape-function library with second derivatives

DofKind (Ux..Rz, T, P), DofSet, ElementDofs (per-node kinds and condensed
internal unknowns) and DofLayout: mesh nodes by label, kinds in order, then
reference points. basis::Basis covers line2/3, tri3/6, quad4/8/9, tet4/10,
wedge6/15, pyramid5, hex8/20/27 with values, first and second natural
derivatives (second-order forward AD, fem::jet) and the physical gradients,
Hessians and Laplacians with the map's second-derivative term. Tested for
Kronecker delta, partition of unity, polynomial completeness, finite
differences, affine and curved maps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* solver, explicit: number degrees of freedom with DofLayout

The implicit mesh numbers its unknowns with a DofLayout (mesh nodes by label
with the kinds their elements declare, then six per coupled reference point);
assembly adds element blocks by (node, DofKind), so a node may carry any mix
of kinds; couplings, boundary conditions, loads and results address
(node, kind). The explicit system keeps node-major translations, now checked
against its layout (NODE_DOFS). Solid formulations declare their ElementDofs.

Every static, frequency and explicit result of the solver, explicit, engine
and io test suites hashes bit-identically before and after (957 results).
docs/fem.md describes the DOF model and the shape-function conventions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* fem, solver: simplify after review

Block offsets resolved once per node pair; add_scalar/add_diagonal take
the kinds like add_block; one checked Jacobian inverse (dense::positive_inverse)
for the solid kernels and the basis library; static natural-node tables; the
Hessian path inverts the Jacobian once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-13 19:54:32 -04:00
1 parent c686cbd531
commit bd55abea7f
16 files changed
+1805 -192

No files matched your search

+102 -50
View File
@@ -1,11 +1,14 @@
//! Sparse symmetric matrices in compressed rows, both triangles stored.
//!
//! Assembly works on the node graph: a node's row holds every node it shares an element with,
//! and each node pair is a dense 3 × 3 block, so the pattern is built once from the element
//! connectivity and the stiffness and mass share it. Sums run in a fixed order (elements by
//! and each node pair is a dense block of their degrees of freedom ([`DofLayout`]: a node's are
//! contiguous), so the pattern is built once from the element connectivity and the stiffness and
//! mass share it. Sums run in a fixed order (elements by
//! label, then the element's own node order), so assembled values are the same bit for bit on
//! every run and platform.
use modelcraft_fem::{DofLayout, DofSet};
use crate::{Error, Result};
/// A square sparse matrix in compressed rows with sorted column indices.
@@ -95,21 +98,22 @@ impl NodeGraph {
NodeGraph { adjacency }
}
/// Nonzeros of the dof-level matrix (3 × 3 per node pair).
pub fn entries(&self) -> usize {
self.adjacency.iter().map(|r| 9 * r.len()).sum()
/// Nonzeros of the dof-level matrix: a dense block per node pair.
pub fn entries(&self, layout: &DofLayout) -> usize {
self.adjacency.iter().enumerate().map(|(a, row)| layout.range(a).len() * row.iter().map(|b| layout.range(*b).len()).sum::<usize>()).sum()
}
/// A zero matrix over every degree of freedom (3 per node) on this pattern.
pub fn zero_matrix(&self) -> Csr {
let n = 3 * self.adjacency.len();
/// A zero matrix over the mesh nodes' degrees of freedom (layout slots `0..nodes`) on this
/// pattern.
pub fn zero_matrix(&self, layout: &DofLayout) -> Csr {
let n = layout.node_dofs();
let mut ptr = Vec::with_capacity(n + 1);
ptr.push(0);
let mut idx = Vec::with_capacity(self.entries());
for row in &self.adjacency {
for _ in 0..3 {
let mut idx = Vec::with_capacity(self.entries(layout));
for (a, row) in self.adjacency.iter().enumerate() {
for _ in layout.range(a) {
for &b in row {
idx.extend([3 * b, 3 * b + 1, 3 * b + 2]);
idx.extend(layout.range(b));
}
ptr.push(idx.len());
}
@@ -119,30 +123,56 @@ impl NodeGraph {
}
}
/// Where node b's 3 × 3 block starts in each of node a's three rows (the rows of a node share
/// one pattern, built by [`NodeGraph::zero_matrix`]): one search per node pair.
fn block_offsets(k: &Csr, a: usize, b: usize) -> Result<[usize; 3]> {
let (start, end) = (k.ptr.get(3 * a).copied().unwrap_or(0), k.ptr.get(3 * a + 1).copied().unwrap_or(0));
let cols = k.idx.get(start..end).ok_or_else(|| Error::Internal("pattern".into()))?;
let at = cols.binary_search(&(3 * b)).map_err(|_| Error::Internal("entry outside the pattern".into()))?;
Ok([0, 1, 2].map(|r| k.ptr.get(3 * a + r).copied().unwrap_or(0) + at))
/// Most kinds a node carries ([`modelcraft_fem::DofKind::ALL`]).
const MAX_KINDS: usize = 8;
/// Where node b's block lies in node a's rows of a matrix built by [`NodeGraph::zero_matrix`],
/// for the degrees of freedom `kinds` of both: the value index of (row r, column c) is
/// `rows[r] + cols[c]`. The rows of a node share one pattern, so this is one search per node
/// pair.
struct Block {
rows: [usize; MAX_KINDS],
cols: [usize; MAX_KINDS],
}
/// Add an element matrix (3n × 3n, row-major, degrees of freedom node by node) into a matrix
/// built by [`NodeGraph::zero_matrix`]; `nodes` are the element's global node indices.
pub fn add_block(k: &mut Csr, nodes: &[usize], data: &[f64]) -> Result<()> {
let width = 3 * nodes.len();
impl Block {
fn new(k: &Csr, layout: &DofLayout, a: usize, b: usize, kinds: DofSet) -> Result<Block> {
let first = layout.range(a).start;
let (start, end) = (k.ptr.get(first).copied().unwrap_or(0), k.ptr.get(first + 1).copied().unwrap_or(0));
let pattern = k.idx.get(start..end).ok_or_else(|| Error::Internal("pattern".into()))?;
let at = pattern.binary_search(&layout.range(b).start).map_err(|_| Error::Internal("entry outside the pattern".into()))?;
let missing = |kind| Error::Internal(format!("an element degree of freedom ({kind:?}) its node does not have"));
let mut block = Block { rows: [0; MAX_KINDS], cols: [0; MAX_KINDS] };
for ((row, col), kind) in block.rows.iter_mut().zip(&mut block.cols).zip(kinds.iter()) {
let r = layout.dof(a, kind).ok_or_else(|| missing(kind))?;
*row = k.ptr.get(r).copied().unwrap_or(0) + at;
*col = layout.kinds(b).position(kind).ok_or_else(|| missing(kind))?;
}
Ok(block)
}
}
fn add_at(k: &mut Csr, at: usize, v: f64) {
if let Some(x) = k.val.get_mut(at) {
*x += v;
}
}
/// Add an element matrix into a matrix built by [`NodeGraph::zero_matrix`]: row-major over the
/// element's degrees of freedom, node by node and `kinds` in order within a node; `nodes` are
/// the element's layout slots.
pub fn add_block(k: &mut Csr, layout: &DofLayout, nodes: &[usize], kinds: DofSet, data: &[f64]) -> Result<()> {
let per = kinds.len();
let width = per * nodes.len();
if data.len() != width * width {
return Err(Error::Internal("element matrix size".into()));
}
for (i, &a) in nodes.iter().enumerate() {
for (j, &b) in nodes.iter().enumerate() {
let rows = block_offsets(k, a, b)?;
for (r, start) in rows.iter().enumerate() {
for c in 0..3 {
if let (Some(x), Some(v)) = (k.val.get_mut(start + c), data.get((3 * i + r) * width + 3 * j + c)) {
*x += v;
}
let block = Block::new(k, layout, a, b, kinds)?;
for r in 0..per {
for c in 0..per {
add_at(k, block.rows[r] + block.cols[c], data.get((per * i + r) * width + per * j + c).copied().unwrap_or(0.0));
}
}
}
@@ -150,34 +180,31 @@ pub fn add_block(k: &mut Csr, nodes: &[usize], data: &[f64]) -> Result<()> {
Ok(())
}
/// Add an n × n element matrix to each displacement component's block (consistent mass).
pub fn add_scalar(k: &mut Csr, nodes: &[usize], data: &[f64]) -> Result<()> {
/// Add an n × n element matrix to the block of each of `kinds` (a solid's consistent mass on its
/// translations).
pub fn add_scalar(k: &mut Csr, layout: &DofLayout, nodes: &[usize], kinds: DofSet, data: &[f64]) -> Result<()> {
let n = nodes.len();
if data.len() != n * n {
return Err(Error::Internal("element matrix size".into()));
}
for (i, &a) in nodes.iter().enumerate() {
for (j, &b) in nodes.iter().enumerate() {
let rows = block_offsets(k, a, b)?;
let block = Block::new(k, layout, a, b, kinds)?;
let v = data.get(i * n + j).copied().unwrap_or(0.0);
for (c, start) in rows.iter().enumerate() {
if let Some(x) = k.val.get_mut(start + c) {
*x += v;
}
for c in 0..kinds.len() {
add_at(k, block.rows[c] + block.cols[c], v);
}
}
}
Ok(())
}
/// Add a per-node diagonal to each displacement component (lumped mass).
pub fn add_diagonal(k: &mut Csr, nodes: &[usize], diagonal: &[f64]) -> Result<()> {
/// Add a per-node diagonal to each of `kinds` (a solid's lumped mass on its translations).
pub fn add_diagonal(k: &mut Csr, layout: &DofLayout, nodes: &[usize], kinds: DofSet, diagonal: &[f64]) -> Result<()> {
for (&a, v) in nodes.iter().zip(diagonal) {
let rows = block_offsets(k, a, a)?;
for (c, start) in rows.iter().enumerate() {
if let Some(x) = k.val.get_mut(start + c) {
*x += v;
}
let block = Block::new(k, layout, a, a, kinds)?;
for c in 0..kinds.len() {
add_at(k, block.rows[c] + block.cols[c], *v);
}
}
Ok(())
@@ -192,14 +219,15 @@ mod tests {
// Two "elements" sharing node 1.
let g = NodeGraph::new(3, [vec![0usize, 1], vec![1, 2]].into_iter());
assert_eq!(g.adjacency, vec![vec![0, 1], vec![0, 1, 2], vec![1, 2]]);
let mut k = g.zero_matrix();
let layout = DofLayout::uniform(0..3, DofSet::TRANSLATIONS);
let mut k = g.zero_matrix(&layout);
let mut e = vec![0.0; 36];
for i in 0..6 {
e[i * 6 + i] = 2.0;
e[i * 6 + (i + 3) % 6] = -1.0;
}
add_block(&mut k, &[0, 1], &e).unwrap();
add_block(&mut k, &[1, 2], &e).unwrap();
add_block(&mut k, &layout, &[0, 1], DofSet::TRANSLATIONS, &e).unwrap();
add_block(&mut k, &layout, &[1, 2], DofSet::TRANSLATIONS, &e).unwrap();
assert_eq!(k.diagonal(), vec![2.0, 2.0, 2.0, 4.0, 4.0, 4.0, 2.0, 2.0, 2.0]);
let x = vec![1.0; 9];
let mut y = vec![0.0; 9];
@@ -209,14 +237,38 @@ mod tests {
let mut mirrored = k.clone();
mirrored.mirror_lower();
assert_eq!(mirrored, k);
assert!(add_block(&mut k, &[0, 2], &e).is_err(), "nodes 0 and 2 share no element");
assert!(add_block(&mut k, &layout, &[0, 2], DofSet::TRANSLATIONS, &e).is_err(), "nodes 0 and 2 share no element");
// Scalar (per component) and diagonal additions land on each component.
let mut m = g.zero_matrix();
add_scalar(&mut m, &[0, 1], &[2.0, 1.0, 1.0, 2.0]).unwrap();
add_diagonal(&mut m, &[2], &[5.0]).unwrap();
let mut m = g.zero_matrix(&layout);
add_scalar(&mut m, &layout, &[0, 1], DofSet::TRANSLATIONS, &[2.0, 1.0, 1.0, 2.0]).unwrap();
add_diagonal(&mut m, &layout, &[2], DofSet::TRANSLATIONS, &[5.0]).unwrap();
assert_eq!(m.diagonal(), vec![2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 5.0, 5.0, 5.0]);
assert_eq!(m.row(0).1, &[2.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
let s = k.add_scaled(-1.0, &k).unwrap();
assert!(s.val.iter().all(|v| *v == 0.0));
}
#[test]
fn mixed_kinds_per_node_assemble_into_their_blocks() {
// A solid on nodes 0 and 1 (translations) and a beam-like element on 1 and 2 (six each):
// node 1 carries six, and the solid's block lands on its translations.
let mut b = DofLayout::builder();
b.node(10, DofSet::TRANSLATIONS).node(11, DofSet::STRUCTURAL).node(12, DofSet::STRUCTURAL);
let layout = b.build();
let g = NodeGraph::new(3, [vec![0usize, 1], vec![1, 2]].into_iter());
let mut k = g.zero_matrix(&layout);
assert_eq!((k.n, k.idx.len(), g.entries(&layout)), (15, 3 * 9 + 6 * 15 + 6 * 12, 189));
let solid: Vec<f64> = (0..36).map(|i| i as f64).collect();
add_block(&mut k, &layout, &[0, 1], DofSet::TRANSLATIONS, &solid).unwrap();
let beam = vec![1.0; 144];
add_block(&mut k, &layout, &[1, 2], DofSet::STRUCTURAL, &beam).unwrap();
// Row Uy of node 1 (global 4): the solid's row 4 on node 0's translations and node 1's.
let (cols, vals) = k.row(4);
let at = |c: usize| vals[cols.binary_search(&c).unwrap()];
assert_eq!([at(0), at(1), at(2)], [24.0, 25.0, 26.0]);
assert_eq!([at(3), at(4), at(5)], [27.0 + 1.0, 28.0 + 1.0, 29.0 + 1.0]);
assert_eq!(at(6), 1.0, "rotation Rx of node 1 from the beam only");
// A translations element cannot claim rotations its node lacks.
assert!(add_block(&mut k, &layout, &[0, 1], DofSet::STRUCTURAL, &beam).is_err());
}
}