Files
OneCAD-Simulation/crates/solver/src/sparse.rs
T
jyvikoandClaude Opus 5.5 8871f6721d solver: mortar contact in nonlinear static steps
Frictionless contact pairs enforced by an augmented Lagrangian: nodal pressures from the
mortar weighted gaps, Uzawa updates after each converged increment until the gaps are within
1e-5 of the element thickness, and the consistent tangent (outer products of the weighted
gap gradients and the pressure-weighted Hessians from the automatic differentiation of the
mortar integrals). Finite sliding searches and integrates on the current configuration every
evaluation; without nonlinear geometry the pairs and their mortar integrals are fixed in the
reference configuration (small sliding, linear and exact). The tangent's pattern takes the
face pairs' blocks; the step cuts back when the active set chatters. Initial overclosure is
offset or refused; general contact, 2D models and arc length are refused by name; the linear
analyses refuse contact by name instead of the mesh.

With contact the convergence force scale comes from the converged states, the loads and the
current state. The loads-and-reactions balance check takes the nodal reactions into its scale
(no spurious warning for a part squeezed between held faces).

Tests: the contact patch test on non-matching hex8/tet4 meshes (exact with small sliding,
to 1e-4 with finite sliding), Hertz cylinder on a flat (peak pressure within 3%, half-width
bracketed) with quadratic Newton convergence, overclosure, thread-count determinism and the
refusals; Hertz sphere on a flat (heavy).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-14 14:58:32 -04:00

314 lines
13 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.
//! 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 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 fieldcraft_fem::{DofLayout, DofSet};
use crate::{Error, Result};
/// Rows per parallel block of a product.
const ROWS: usize = 4096;
/// A square sparse matrix in compressed rows with sorted column indices.
#[derive(Debug, Clone, PartialEq)]
pub struct Csr {
pub n: usize,
pub ptr: Vec<usize>,
pub idx: Vec<usize>,
pub val: Vec<f64>,
}
impl Csr {
/// The row's column indices and values.
pub fn row(&self, r: usize) -> (&[usize], &[f64]) {
let (a, b) = (self.ptr.get(r).copied().unwrap_or(0), self.ptr.get(r + 1).copied().unwrap_or(0));
(self.idx.get(a..b).unwrap_or_default(), self.val.get(a..b).unwrap_or_default())
}
/// y = A x.
/// Rows are computed in parallel blocks; each row's sum runs in column order.
pub fn mul(&self, x: &[f64], y: &mut [f64]) {
let n = self.n.min(y.len());
fieldcraft_parallel::chunks_mut(y.get_mut(..n).unwrap_or_default(), ROWS, |b, block| {
for (i, yr) in block.iter_mut().enumerate() {
let (cols, vals) = self.row(b * ROWS + i);
*yr = cols.iter().zip(vals).map(|(c, v)| v * x.get(*c).copied().unwrap_or(0.0)).sum();
}
});
}
pub fn diagonal(&self) -> Vec<f64> {
(0..self.n)
.map(|r| {
let (cols, vals) = self.row(r);
cols.binary_search(&r).ok().and_then(|i| vals.get(i)).copied().unwrap_or(0.0)
})
.collect()
}
/// Make the matrix exactly symmetric by copying the lower triangle (what the factorization
/// reads) onto the upper one; the pattern must be structurally symmetric.
pub fn mirror_lower(&mut self) {
for r in 0..self.n {
let (start, end) = (self.ptr.get(r).copied().unwrap_or(0), self.ptr.get(r + 1).copied().unwrap_or(0));
for at in start..end {
let Some(&c) = self.idx.get(at) else { continue };
if c <= r {
continue;
}
let (cs, ce) = (self.ptr.get(c).copied().unwrap_or(0), self.ptr.get(c + 1).copied().unwrap_or(0));
let mirror = self.idx.get(cs..ce).and_then(|cols| cols.binary_search(&r).ok()).and_then(|i| self.val.get(cs + i)).copied();
if let (Some(v), Some(x)) = (mirror, self.val.get_mut(at)) {
*x = v;
}
}
}
}
/// `self + a·other` on the same pattern.
pub fn add_scaled(&self, a: f64, other: &Csr) -> Result<Csr> {
if self.ptr != other.ptr || self.idx != other.idx {
return Err(Error::Internal("matrices with different patterns".into()));
}
let val = self.val.iter().zip(&other.val).map(|(x, y)| x + a * y).collect();
Ok(Csr { n: self.n, ptr: self.ptr.clone(), idx: self.idx.clone(), val })
}
}
/// The node-block pattern of a mesh: for each node, the sorted nodes it shares an element with
/// (itself included).
#[derive(Debug, Clone)]
pub struct NodeGraph {
pub adjacency: Vec<Vec<usize>>,
}
impl NodeGraph {
pub fn new(nodes: usize, elements: impl Iterator<Item = impl AsRef<[usize]>>) -> NodeGraph {
let mut adjacency: Vec<Vec<usize>> = vec![Vec::new(); nodes];
for e in elements {
let e = e.as_ref();
for &a in e {
if let Some(row) = adjacency.get_mut(a) {
row.extend_from_slice(e);
}
}
}
for row in &mut adjacency {
row.sort_unstable();
row.dedup();
}
NodeGraph { adjacency }
}
/// The graph with more blocks, each coupling all its slots (contact: the slots a contact
/// element couples change with the deformation).
pub fn with_blocks<'a>(&self, blocks: impl Iterator<Item = &'a [usize]>) -> NodeGraph {
let mut adjacency = self.adjacency.clone();
let mut touched = Vec::new();
for b in blocks {
for &a in b {
if let Some(row) = adjacency.get_mut(a) {
row.extend_from_slice(b);
touched.push(a);
}
}
}
touched.sort_unstable();
touched.dedup();
for a in touched {
if let Some(row) = adjacency.get_mut(a) {
row.sort_unstable();
row.dedup();
}
}
NodeGraph { adjacency }
}
/// 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 the degrees of freedom of the graph's layout slots (the mesh nodes
/// `0..nodes`, and reference points when the graph has rows for them) on this pattern.
pub fn zero_matrix(&self, layout: &DofLayout) -> Csr {
let n = self.adjacency.len().checked_sub(1).map_or(0, |last| layout.range(last).end);
let mut ptr = Vec::with_capacity(n + 1);
ptr.push(0);
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(layout.range(b));
}
ptr.push(idx.len());
}
}
let val = vec![0.0; idx.len()];
Csr { n, ptr, idx, val }
}
}
/// Most kinds a node carries ([`fieldcraft_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],
}
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 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));
}
}
}
}
Ok(())
}
/// 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 block = Block::new(k, layout, a, b, kinds)?;
let v = data.get(i * n + j).copied().unwrap_or(0.0);
for c in 0..kinds.len() {
add_at(k, block.rows[c] + block.cols[c], v);
}
}
}
Ok(())
}
/// 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 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(())
}
/// Add `v` to entry (`i`, `j`), which must be in the pattern.
pub fn add_entry(k: &mut Csr, i: usize, j: usize, v: f64) -> Result<()> {
let (start, end) = (k.ptr.get(i).copied().unwrap_or(0), k.ptr.get(i + 1).copied().unwrap_or(0));
let at = k.idx.get(start..end).and_then(|cols| cols.binary_search(&j).ok()).ok_or_else(|| Error::Internal("entry outside the pattern".into()))?;
add_at(k, start + at, v);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn assemble_mirror_multiply() {
// 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 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, &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];
k.mul(&x, &mut y);
assert_eq!(y, vec![1.0; 3].into_iter().chain([2.0; 3]).chain([1.0; 3]).collect::<Vec<_>>());
// Mirroring keeps a symmetric matrix as it is.
let mut mirrored = k.clone();
mirrored.mirror_lower();
assert_eq!(mirrored, k);
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(&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());
}
}