forked from 1b-agent/fieldcraft-upstream
The end-to-end demo met a raw-CDT sliver that the validity gate accepted (exactly positive) and the solver refused. Now: - One validity criterion, `fieldcraft_model::admissible_jacobian` (normalized Jacobian above 1e-12), used by the implicit solver (fem `positive_inverse`), the explicit tet4/hex8 kinematics and the mesh gate, which proves it on the whole element (tet4: the solvers' own Jacobian; curved kinds: a Bernstein floor), so whatever passes the gate passes the solvers. - CDT rounding repaired by flips on the rounded mesh (Diazzi et al. 2023 §5.1) instead of the ±2-ulp nudge. - `fieldcraft-mesh::tet`: a mutable tetrahedral complex whose every change is a checked replacement (positive and admissible tetrahedra, faces matched with opposite orientation) with an exact undo log; edge removal (Klincsek), multi-face removal, Delaunay insertion with boundary faces, edge collapse; a curvature- and thickness-graded size field; constrained Delaunay refinement with encroachment splits; ODT smoothing; the Klingner–Shewchuk schedule; boundary vertices moved onto the exact B-rep surfaces at the end; tet10 with mid-edge nodes on the geometry. - `mesh.tet.generate` gains size, order, quality, quality_target, curvature_angle, grading, max_passes, max_points and reports the CDT's and the mesh's quality; `solve.probe` takes a set and reports its largest von Mises stress. - Oracle cases filleted-bracket-fillet-stress(-coarse): the fillet's peak von Mises stress on tet10 meshes of 2k and 13.5k elements, 213.3 MPa (a four-level study stays within 1.8%); the CDT cases keep the raw tetrahedralization. - Corpus quality columns: all 20 valid parts mesh and pass the gate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
225 lines
8.1 KiB
Rust
225 lines
8.1 KiB
Rust
//! 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))
|
||
}
|
||
|
||
/// The inverse and determinant of a Jacobian that maps a reference element onto a valid one:
|
||
/// `None` unless it is admissible by FieldCraft's one validity criterion
|
||
/// ([`fieldcraft_model::admissible_jacobian`], the one the meshers' validity gate proves).
|
||
pub fn positive_inverse(j: &[[f64; 3]; 3]) -> Option<([[f64; 3]; 3], f64)> {
|
||
if !fieldcraft_model::admissible_jacobian(j) {
|
||
return None;
|
||
}
|
||
inverse3(j)
|
||
}
|
||
|
||
/// The eigenvalues and unit eigenvectors (the columns of the matrix returned) of a symmetric
|
||
/// 3 × 3 matrix, by cyclic Jacobi rotations (G. H. Golub, C. F. Van Loan, *Matrix
|
||
/// Computations*, 4th ed., Johns Hopkins 2013, §8.5): each rotation zeroes one off-diagonal
|
||
/// entry with the stable choice of angle, and the sweeps stop when the off-diagonal part is
|
||
/// round-off relative to the diagonal (at most 32 sweeps; three or four suffice). Accurate to a
|
||
/// few ulps of the largest eigenvalue; `None` for non-finite input.
|
||
pub fn symmetric_eigen(a: &[[f64; 3]; 3]) -> Option<([f64; 3], [[f64; 3]; 3])> {
|
||
if a.iter().flatten().any(|v| !v.is_finite()) {
|
||
return None;
|
||
}
|
||
let mut m = [[a[0][0], 0.5 * (a[0][1] + a[1][0]), 0.5 * (a[0][2] + a[2][0])], [0.0; 3], [0.0; 3]];
|
||
m[1] = [m[0][1], a[1][1], 0.5 * (a[1][2] + a[2][1])];
|
||
m[2] = [m[0][2], m[1][2], a[2][2]];
|
||
let mut v = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
|
||
for _ in 0..32 {
|
||
let off = m[0][1] * m[0][1] + m[0][2] * m[0][2] + m[1][2] * m[1][2];
|
||
let diag = m[0][0] * m[0][0] + m[1][1] * m[1][1] + m[2][2] * m[2][2];
|
||
if off <= 1e-36 * diag || off == 0.0 {
|
||
break;
|
||
}
|
||
for (p, q) in [(0, 1), (0, 2), (1, 2)] {
|
||
let apq = m[p][q];
|
||
if apq == 0.0 {
|
||
continue;
|
||
}
|
||
// tan of the rotation angle, the smaller root of t² + 2θt − 1 = 0.
|
||
let theta = (m[q][q] - m[p][p]) / (2.0 * apq);
|
||
let sign = if theta >= 0.0 { 1.0 } else { -1.0 };
|
||
let t = sign / (theta.abs() + (theta * theta + 1.0).sqrt());
|
||
let c = 1.0 / (t * t + 1.0).sqrt();
|
||
let s = t * c;
|
||
for k in 0..3 {
|
||
let (mkp, mkq) = (m[k][p], m[k][q]);
|
||
m[k][p] = c * mkp - s * mkq;
|
||
m[k][q] = s * mkp + c * mkq;
|
||
}
|
||
for k in 0..3 {
|
||
let (mpk, mqk) = (m[p][k], m[q][k]);
|
||
m[p][k] = c * mpk - s * mqk;
|
||
m[q][k] = s * mpk + c * mqk;
|
||
}
|
||
for row in &mut v {
|
||
let (vp, vq) = (row[p], row[q]);
|
||
row[p] = c * vp - s * vq;
|
||
row[q] = s * vp + c * vq;
|
||
}
|
||
}
|
||
}
|
||
Some(([m[0][0], m[1][1], m[2][2]], v))
|
||
}
|
||
|
||
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());
|
||
}
|
||
|
||
#[test]
|
||
fn symmetric_eigen_decomposes() {
|
||
for a in [
|
||
[[4.0, 1.0, -2.0], [1.0, 3.0, 0.5], [-2.0, 0.5, 1.0]],
|
||
[[2.0, 0.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 5.0]],
|
||
[[1.0, 1e-9, 0.0], [1e-9, 1.0, 0.0], [0.0, 0.0, 1.0]],
|
||
[[1e6, 3.0, 2.0], [3.0, 1e-3, 0.0], [2.0, 0.0, -7.0]],
|
||
] {
|
||
let (l, v) = symmetric_eigen(&a).unwrap();
|
||
let scale = l.iter().fold(0.0f64, |m, x| m.max(x.abs()));
|
||
for k in 0..3 {
|
||
// A v_k = λ_k v_k and the v_k are orthonormal.
|
||
for i in 0..3 {
|
||
let av: f64 = (0..3).map(|j| a[i][j] * v[j][k]).sum();
|
||
assert!((av - l[k] * v[i][k]).abs() < 1e-12 * scale, "{a:?}: {l:?}");
|
||
}
|
||
for m in 0..3 {
|
||
let d: f64 = (0..3).map(|i| v[i][k] * v[i][m]).sum();
|
||
assert!((d - if k == m { 1.0 } else { 0.0 }).abs() < 1e-13);
|
||
}
|
||
}
|
||
}
|
||
assert!(symmetric_eigen(&[[f64::NAN, 0.0, 0.0], [0.0; 3], [0.0; 3]]).is_none());
|
||
}
|
||
}
|