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:
authored and GitHub committed 2026-10-13 17:44:21 -04:00
1 parent 81fa8d7220
commit 40f98f0af9
33 files changed
+4559 -53

No files matched your search

+138
View File
@@ -0,0 +1,138 @@
//! Small dense matrices: element matrices and the little systems inside an element (static
//! condensation, extrapolation fits).
use crate::{Error, Result};
/// A square matrix, row-major.
#[derive(Debug, Clone, PartialEq)]
pub struct Matrix {
pub n: usize,
pub data: Vec<f64>,
}
impl Matrix {
pub fn zeros(n: usize) -> Matrix {
Matrix { n, data: vec![0.0; n * n] }
}
pub fn get(&self, i: usize, j: usize) -> f64 {
self.data.get(i * self.n + j).copied().unwrap_or(0.0)
}
pub fn add(&mut self, i: usize, j: usize, v: f64) {
if let Some(x) = self.data.get_mut(i * self.n + j) {
*x += v;
}
}
/// y = A x (x shorter than n is padded with zeros).
pub fn mul_vec(&self, x: &[f64]) -> Vec<f64> {
self.data.chunks_exact(self.n.max(1)).map(|row| row.iter().zip(x).map(|(a, b)| a * b).sum()).collect()
}
}
/// The Cholesky factor of a small symmetric positive definite matrix (lower triangle, row-major).
#[derive(Debug, Clone)]
pub struct Cholesky {
n: usize,
l: Vec<f64>,
}
impl Cholesky {
/// Factor `a` (only its lower triangle is read). Fails when a pivot is not positive relative
/// to the matrix's diagonal.
pub fn new(a: &Matrix) -> Result<Cholesky> {
let n = a.n;
let mut l = vec![0.0; n * n];
let scale = (0..n).map(|i| a.get(i, i).abs()).fold(0.0, f64::max);
for j in 0..n {
let mut d = a.get(j, j);
for k in 0..j {
let v = l[j * n + k];
d -= v * v;
}
if !(d > 1e-13 * scale) {
return Err(Error::Singular(format!("a {n} × {n} element matrix is not positive definite (pivot {j})")));
}
let djj = d.sqrt();
l[j * n + j] = djj;
for i in j + 1..n {
let mut s = a.get(i, j);
for k in 0..j {
s -= l[i * n + k] * l[j * n + k];
}
l[i * n + j] = s / djj;
}
}
Ok(Cholesky { n, l })
}
/// Solve A x = b in place.
pub fn solve(&self, b: &mut [f64]) {
let n = self.n.min(b.len());
for i in 0..n {
let mut s = b[i];
for k in 0..i {
s -= self.l[i * self.n + k] * b[k];
}
b[i] = s / self.l[i * self.n + i];
}
for i in (0..n).rev() {
let mut s = b[i];
for k in i + 1..n {
s -= self.l[k * self.n + i] * b[k];
}
b[i] = s / self.l[i * self.n + i];
}
}
}
/// The inverse and determinant of a 3 × 3 matrix; `None` when it is singular or not finite.
pub fn inverse3(m: &[[f64; 3]; 3]) -> Option<([[f64; 3]; 3], f64)> {
let det = det3(m);
if !(det.abs() > 0.0 && det.is_finite()) {
return None;
}
let mut inv = [[0.0; 3]; 3];
for (i, row) in inv.iter_mut().enumerate() {
for (j, v) in row.iter_mut().enumerate() {
let (j1, j2) = ((j + 1) % 3, (j + 2) % 3);
let (i1, i2) = ((i + 1) % 3, (i + 2) % 3);
// inv[i][j] = cofactor[j][i] / det.
*v = (m[j1][i1] * m[j2][i2] - m[j1][i2] * m[j2][i1]) / det;
}
}
Some((inv, det))
}
pub fn det3(m: &[[f64; 3]; 3]) -> f64 {
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cholesky_solves_and_rejects_indefinite() {
let mut a = Matrix::zeros(3);
a.data = vec![4.0, 2.0, 0.6, 2.0, 5.0, 1.0, 0.6, 1.0, 3.0];
let x = [1.0, -2.0, 0.5];
let mut b = a.mul_vec(&x);
Cholesky::new(&a).unwrap().solve(&mut b);
assert!(b.iter().zip(x).all(|(p, q)| (p - q).abs() < 1e-14), "{b:?}");
a.data[8] = -1.0;
assert!(Cholesky::new(&a).is_err());
let m = [[2.0, 0.5, 0.0], [0.1, 3.0, 0.2], [0.0, 0.4, 1.0]];
let (inv, det) = inverse3(&m).unwrap();
assert!((det - det3(&m)).abs() < 1e-15);
for i in 0..3 {
for j in 0..3 {
let p: f64 = (0..3).map(|k| m[i][k] * inv[k][j]).sum();
assert!((p - if i == j { 1.0 } else { 0.0 }).abs() < 1e-14);
}
}
assert!(inverse3(&[[1.0, 2.0, 3.0], [2.0, 4.0, 6.0], [0.0, 0.0, 1.0]]).is_none());
}
}