forked from 1b-agent/fieldcraft-upstream
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:
16 files changed
+1805
-192
No files matched your search
+102
-50
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user