forked from 1b-agent/fieldcraft-upstream
Implicit solver: fem kernels (tet4/10, hex8, hex20), sparse Cholesky, block Lanczos, solve.* commands (M4) (#23)
* Renumber the layer table for solver crates: fem at L1, solver at L2, fsi at L3 L0 model -> L1 geometry, mesh, fem, decks, render -> L2 solver (explicit, lbm later) -> L3 fsi (later) -> L4 doc -> L5 io -> L6 engine -> L7 ui-egui, mcp -> apps. UI_MIN_LAYER moves to 7, the testkit sits above it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * modelcraft-fem: element kernels for tet4, tet10, hex8 (full, incompatible modes), hex20 (full, reduced) Shape functions, Gauss and conical-product quadrature, stiffness, consistent and HRZ-lumped mass, body forces, consistent pressure loads on faces, integration-point stresses and their least-squares extrapolation to nodes, isotropic and orthotropic elasticity. The element table is one row per kind, so wedges, shells and beams are added locally. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * modelcraft-solver: linear static and frequency analysis with an analytic verification suite Assembly from the resolved model on the node graph, boundary conditions by elimination, consistent pressure, gravity and body-force loads, nested-dissection ordering, sparse up-looking Cholesky with a factor-size cap, reactions, integration-point and nodal-averaged stresses, von Mises; shift-invert block Lanczos with full reorthogonalization for natural modes, participation and effective mass. ADR 0001 records the own-linear-algebra decision (determinism, wasm). Verified against closed forms: displacement and traction patch tests for every element, cantilever tip deflection (Timoshenko) with convergence rates, Lamé cylinder, Kirsch hole (Kt -> 3), cantilever and free-free beam frequencies with six rigid-body modes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * Engine: solve.static, solve.frequency and solve.probe; results kept in the session Results are stored per step, restored on a failed command and cleared when the model changes. Caps (degrees of freedom, factor entries, Lanczos vectors) may only be lowered; hostile parameters are tested against a solvable session. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * docs: implicit solver architecture, roadmap M4, parity and references Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * Simplify the solver: shared face integration, block assembly, cached extrapolation - model: one face integrator (nodal::face_normal_weights) for tractions and pressures - fem: mid-side tables from ElementKind::edges, orthotropic via inverse3, symmetric accumulation without a symmetrize pass, incompatible-mode recovery without K_uu, model's coordinate check, dead table rows removed - solver: one pattern search per node pair (block, scalar and diagonal adds), extrapolation built once per element type, reactions from the prescribed rows only, matrices dropped after restriction, the free-body shift keyed on the factorization error, step selection in the solver, Error::Element naming the element - engine: the solver owns the default step and the shift check; the model comparison runs once Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * docs: results are stamped with the session revision 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:
33 files changed
+4559
-53
No files matched your search
@@ -0,0 +1,326 @@
|
||||
//! Sparse Cholesky factorization A = L Lᵀ of a symmetric positive definite matrix.
|
||||
//!
|
||||
//! The up-looking method of T. A. Davis, *Direct Methods for Sparse Linear Systems*, SIAM 2006,
|
||||
//! §4.1–4.8: the elimination tree (J. W. H. Liu, *The role of elimination trees in sparse
|
||||
//! factorization*, SIAM J. Matrix Anal. Appl. 11(1):134–172, 1990) gives the pattern of each row
|
||||
//! of L as a reach in the tree, the symbolic pass counts every column before any value is
|
||||
//! computed (so the factor's size is checked against a cap before memory is committed), and each
|
||||
//! row of L is a sparse triangular solve. Operations run in a fixed order, with no threads and no
|
||||
//! data-dependent reduction order, so the factor is the same bit for bit on every platform.
|
||||
//!
|
||||
//! A pivot at or below `PIVOT_TOLERANCE` times the matrix's own diagonal entry means the matrix
|
||||
//! is singular to working precision (an unconstrained rigid-body motion, a disconnected part, a
|
||||
//! zero-stiffness direction); the factorization stops and names the column.
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Relative pivot size below which the matrix counts as singular.
|
||||
pub const PIVOT_TOLERANCE: f64 = 1e-10;
|
||||
|
||||
/// The upper triangle of a symmetric matrix by columns: column k holds A(i, k) for i ≤ k, rows
|
||||
/// sorted.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UpperColumns {
|
||||
pub n: usize,
|
||||
pub ptr: Vec<usize>,
|
||||
pub idx: Vec<usize>,
|
||||
pub val: Vec<f64>,
|
||||
}
|
||||
|
||||
impl UpperColumns {
|
||||
/// From a symmetric matrix stored by full rows (row k's entries with column ≤ k are column k
|
||||
/// of the upper triangle).
|
||||
pub fn from_symmetric(a: &crate::sparse::Csr) -> UpperColumns {
|
||||
let mut ptr = Vec::with_capacity(a.n + 1);
|
||||
ptr.push(0);
|
||||
let mut idx = Vec::new();
|
||||
let mut val = Vec::new();
|
||||
for k in 0..a.n {
|
||||
let (cols, vals) = a.row(k);
|
||||
for (c, v) in cols.iter().zip(vals) {
|
||||
if *c <= k {
|
||||
idx.push(*c);
|
||||
val.push(*v);
|
||||
}
|
||||
}
|
||||
ptr.push(idx.len());
|
||||
}
|
||||
UpperColumns { n: a.n, ptr, idx, val }
|
||||
}
|
||||
|
||||
fn column(&self, k: usize) -> (&[usize], &[f64]) {
|
||||
let (a, b) = (self.ptr.get(k).copied().unwrap_or(0), self.ptr.get(k + 1).copied().unwrap_or(0));
|
||||
(self.idx.get(a..b).unwrap_or_default(), self.val.get(a..b).unwrap_or_default())
|
||||
}
|
||||
}
|
||||
|
||||
const NONE: usize = usize::MAX;
|
||||
|
||||
/// The elimination tree: parent of each column, `NONE` at roots.
|
||||
fn etree(a: &UpperColumns) -> Vec<usize> {
|
||||
let mut parent = vec![NONE; a.n];
|
||||
let mut ancestor = vec![NONE; a.n];
|
||||
for k in 0..a.n {
|
||||
let (rows, _) = a.column(k);
|
||||
for &i0 in rows {
|
||||
let mut i = i0;
|
||||
// Walk from i up to the root of its current subtree, compressing the path to k.
|
||||
while i != NONE && i < k {
|
||||
let next = ancestor.get(i).copied().unwrap_or(NONE);
|
||||
if let Some(x) = ancestor.get_mut(i) {
|
||||
*x = k;
|
||||
}
|
||||
if next == NONE
|
||||
&& let Some(p) = parent.get_mut(i)
|
||||
{
|
||||
*p = k;
|
||||
}
|
||||
i = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
parent
|
||||
}
|
||||
|
||||
/// The pattern of row k of L (excluding the diagonal): the reach of column k's entries in the
|
||||
/// elimination tree. Fills `stack[top..]` in topological order and returns `top`; `mark` holds
|
||||
/// `k` for visited columns.
|
||||
fn ereach(a: &UpperColumns, k: usize, parent: &[usize], stack: &mut [usize], mark: &mut [usize], path: &mut Vec<usize>) -> usize {
|
||||
let n = a.n;
|
||||
let mut top = n;
|
||||
if let Some(m) = mark.get_mut(k) {
|
||||
*m = k;
|
||||
}
|
||||
let (rows, _) = a.column(k);
|
||||
for &i0 in rows {
|
||||
let mut i = i0;
|
||||
if i > k {
|
||||
continue;
|
||||
}
|
||||
path.clear();
|
||||
while i != NONE && mark.get(i).copied() != Some(k) {
|
||||
path.push(i);
|
||||
if let Some(m) = mark.get_mut(i) {
|
||||
*m = k;
|
||||
}
|
||||
i = parent.get(i).copied().unwrap_or(NONE);
|
||||
}
|
||||
while let Some(v) = path.pop() {
|
||||
top -= 1;
|
||||
if let Some(s) = stack.get_mut(top) {
|
||||
*s = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
top
|
||||
}
|
||||
|
||||
/// A Cholesky factor, L by columns with the diagonal first in each column.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Factor {
|
||||
pub n: usize,
|
||||
ptr: Vec<usize>,
|
||||
idx: Vec<usize>,
|
||||
val: Vec<f64>,
|
||||
}
|
||||
|
||||
/// Why a factorization stopped.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum FactorError {
|
||||
/// The pivot of this column is not positive (relative to the diagonal).
|
||||
NotPositiveDefinite { column: usize },
|
||||
/// The factor would hold more entries than allowed.
|
||||
TooLarge { entries: usize, limit: usize },
|
||||
}
|
||||
|
||||
impl Factor {
|
||||
/// The elimination tree and the entry count of each column of L, without computing values.
|
||||
fn symbolic(a: &UpperColumns) -> (Vec<usize>, Vec<usize>) {
|
||||
let parent = etree(a);
|
||||
let mut counts = vec![1usize; a.n];
|
||||
let mut stack = vec![0; a.n];
|
||||
let mut mark = vec![NONE; a.n];
|
||||
let mut path = Vec::new();
|
||||
for k in 0..a.n {
|
||||
let top = ereach(a, k, &parent, &mut stack, &mut mark, &mut path);
|
||||
for &j in stack.get(top..).unwrap_or_default() {
|
||||
if let Some(c) = counts.get_mut(j) {
|
||||
*c += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
(parent, counts)
|
||||
}
|
||||
|
||||
pub fn new(a: &UpperColumns, max_entries: usize) -> std::result::Result<Factor, FactorError> {
|
||||
let n = a.n;
|
||||
let (parent, counts) = Factor::symbolic(a);
|
||||
let total: usize = counts.iter().sum();
|
||||
if total > max_entries {
|
||||
return Err(FactorError::TooLarge { entries: total, limit: max_entries });
|
||||
}
|
||||
let mut ptr = Vec::with_capacity(n + 1);
|
||||
ptr.push(0);
|
||||
for c in &counts {
|
||||
ptr.push(ptr.last().copied().unwrap_or(0) + c);
|
||||
}
|
||||
let mut idx = vec![0usize; total];
|
||||
let mut val = vec![0.0f64; total];
|
||||
// Next free slot in each column.
|
||||
let mut next: Vec<usize> = ptr.get(..n).unwrap_or_default().to_vec();
|
||||
let mut x = vec![0.0f64; n];
|
||||
let mut stack = vec![0usize; n];
|
||||
let mut mark = vec![NONE; n];
|
||||
let mut path = Vec::new();
|
||||
for k in 0..n {
|
||||
let top = ereach(a, k, &parent, &mut stack, &mut mark, &mut path);
|
||||
let (rows, vals) = a.column(k);
|
||||
let mut diag = 0.0;
|
||||
for (i, v) in rows.iter().zip(vals) {
|
||||
if *i < k {
|
||||
if let Some(xi) = x.get_mut(*i) {
|
||||
*xi = *v;
|
||||
}
|
||||
} else if *i == k {
|
||||
diag = *v;
|
||||
}
|
||||
}
|
||||
let mut d = diag;
|
||||
for &i in stack.get(top..).unwrap_or_default() {
|
||||
let start = ptr.get(i).copied().unwrap_or(0);
|
||||
let lii = val.get(start).copied().unwrap_or(1.0);
|
||||
let lki = x.get(i).copied().unwrap_or(0.0) / lii;
|
||||
if let Some(xi) = x.get_mut(i) {
|
||||
*xi = 0.0;
|
||||
}
|
||||
let end = next.get(i).copied().unwrap_or(start);
|
||||
for p in start + 1..end {
|
||||
let (r, l) = (idx.get(p).copied().unwrap_or(0), val.get(p).copied().unwrap_or(0.0));
|
||||
if let Some(xr) = x.get_mut(r) {
|
||||
*xr -= l * lki;
|
||||
}
|
||||
}
|
||||
d -= lki * lki;
|
||||
if let Some(slot) = next.get_mut(i) {
|
||||
if let (Some(ri), Some(vi)) = (idx.get_mut(*slot), val.get_mut(*slot)) {
|
||||
*ri = k;
|
||||
*vi = lki;
|
||||
}
|
||||
*slot += 1;
|
||||
}
|
||||
}
|
||||
if !(d > PIVOT_TOLERANCE * diag.abs()) || !d.is_finite() {
|
||||
return Err(FactorError::NotPositiveDefinite { column: k });
|
||||
}
|
||||
if let Some(slot) = next.get_mut(k) {
|
||||
if let (Some(ri), Some(vi)) = (idx.get_mut(*slot), val.get_mut(*slot)) {
|
||||
*ri = k;
|
||||
*vi = d.sqrt();
|
||||
}
|
||||
*slot += 1;
|
||||
}
|
||||
}
|
||||
Ok(Factor { n, ptr, idx, val })
|
||||
}
|
||||
|
||||
/// Entries in L.
|
||||
pub fn entries(&self) -> usize {
|
||||
self.val.len()
|
||||
}
|
||||
|
||||
/// Solve A x = b in place.
|
||||
pub fn solve(&self, b: &mut [f64]) -> Result<()> {
|
||||
if b.len() != self.n {
|
||||
return Err(Error::Internal("right-hand side length".into()));
|
||||
}
|
||||
// L y = b, by columns.
|
||||
for j in 0..self.n {
|
||||
let (start, end) = (self.ptr.get(j).copied().unwrap_or(0), self.ptr.get(j + 1).copied().unwrap_or(0));
|
||||
let yj = b.get(j).copied().unwrap_or(0.0) / self.val.get(start).copied().unwrap_or(1.0);
|
||||
if let Some(x) = b.get_mut(j) {
|
||||
*x = yj;
|
||||
}
|
||||
for p in start + 1..end {
|
||||
let (r, l) = (self.idx.get(p).copied().unwrap_or(0), self.val.get(p).copied().unwrap_or(0.0));
|
||||
if let Some(x) = b.get_mut(r) {
|
||||
*x -= l * yj;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Lᵀ x = y.
|
||||
for j in (0..self.n).rev() {
|
||||
let (start, end) = (self.ptr.get(j).copied().unwrap_or(0), self.ptr.get(j + 1).copied().unwrap_or(0));
|
||||
let mut s = b.get(j).copied().unwrap_or(0.0);
|
||||
for p in start + 1..end {
|
||||
let (r, l) = (self.idx.get(p).copied().unwrap_or(0), self.val.get(p).copied().unwrap_or(0.0));
|
||||
s -= l * b.get(r).copied().unwrap_or(0.0);
|
||||
}
|
||||
if let Some(x) = b.get_mut(j) {
|
||||
*x = s / self.val.get(start).copied().unwrap_or(1.0);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::sparse::Csr;
|
||||
|
||||
fn dense_to_csr(a: &[Vec<f64>]) -> Csr {
|
||||
let n = a.len();
|
||||
let mut ptr = vec![0];
|
||||
let mut idx = Vec::new();
|
||||
let mut val = Vec::new();
|
||||
for row in a {
|
||||
for (j, v) in row.iter().enumerate() {
|
||||
if *v != 0.0 {
|
||||
idx.push(j);
|
||||
val.push(*v);
|
||||
}
|
||||
}
|
||||
ptr.push(idx.len());
|
||||
}
|
||||
Csr { n, ptr, idx, val }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factor_and_solve_a_sparse_spd_matrix() {
|
||||
// A 2D Laplacian-like matrix on a 7 × 5 grid plus a diagonal shift: sparse with fill.
|
||||
let (w, h) = (7, 5);
|
||||
let n = w * h;
|
||||
let mut a = vec![vec![0.0; n]; n];
|
||||
for i in 0..w {
|
||||
for j in 0..h {
|
||||
let k = i * h + j;
|
||||
a[k][k] = 4.5;
|
||||
if i + 1 < w {
|
||||
a[k][k + h] = -1.0;
|
||||
a[k + h][k] = -1.0;
|
||||
}
|
||||
if j + 1 < h {
|
||||
a[k][k + 1] = -1.0;
|
||||
a[k + 1][k] = -1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
let csr = dense_to_csr(&a);
|
||||
let f = Factor::new(&UpperColumns::from_symmetric(&csr), usize::MAX).unwrap();
|
||||
let x: Vec<f64> = (0..n).map(|i| (i as f64 * 0.37).sin()).collect();
|
||||
let mut b = vec![0.0; n];
|
||||
csr.mul(&x, &mut b);
|
||||
f.solve(&mut b).unwrap();
|
||||
assert!(b.iter().zip(&x).all(|(p, q)| (p - q).abs() < 1e-13), "{b:?}");
|
||||
// The cap is checked before the numeric factorization.
|
||||
assert!(matches!(Factor::new(&UpperColumns::from_symmetric(&csr), 10), Err(FactorError::TooLarge { .. })));
|
||||
// A singular matrix (a pure Laplacian has the constant vector in its null space).
|
||||
let mut sing = a.clone();
|
||||
for (k, row) in sing.iter_mut().enumerate() {
|
||||
let off: f64 = row.iter().enumerate().filter(|(j, _)| *j != k).map(|(_, v)| -v).sum();
|
||||
row[k] = off;
|
||||
}
|
||||
let e = Factor::new(&UpperColumns::from_symmetric(&dense_to_csr(&sing)), usize::MAX).unwrap_err();
|
||||
assert_eq!(e, FactorError::NotPositiveDefinite { column: n - 1 });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user