CI / Clippy Check (push) Canceled after 0s
CI / Build (macos-latest) (push) Canceled after 0s
CI / Build (ubuntu-latest) (push) Canceled after 0s
CI / Test (macos-latest) (push) Canceled after 0s
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Build CPU-Only (Explicit) (push) Canceled after 0s
CI / Python Bindings (maturin) (macos-latest) (push) Canceled after 0s
CI / Python Bindings (maturin) (ubuntu-latest) (push) Canceled after 0s
CI / WASM Build + Size Check (push) Canceled after 0s
CI / Distributed Training Tests (push) Canceled after 0s
CI / CI Success (push) Canceled after 0s
Documentation / Build API Documentation (push) Canceled after 0s
Documentation / Build User Guide (push) Canceled after 0s
Performance Benchmarks / Run Benchmarks (push) Canceled after 0s
CI / Format Check (push) Canceled after 0s
Lifts the 27 `#[ignore]` markers on rtx-cfd and rtx-fea. 21 of them fail;
6 were stale, marking components that have since been implemented. The
suite now reports the truth, which means it is red.
The eigensolver had three independent defects, each individually fatal.
Found by writing closed-form tests first and confirming red:
- The generalized reduction formed M^-1 K and ran Lanczos on it.
M^-1 K has the right eigenvalues but is not symmetric even when K
and M both are, and Lanczos assumes symmetry -- so it returned a
wrong answer rather than an inaccurate one. On a 2-DOF spring-mass
chain with M = diag(2,1) it gave 1.633 against an exact root of
1 - sqrt(2)/2 ~= 0.293. Replaced with the Cholesky reduction
B = L^-1 (K - sigma M) L^-T.
- Output was unsorted. nalgebra's symmetric_eigen gives no ordering
guarantee and none was imposed; modal analysis names modes by index,
so the ordering is part of the contract.
- Eigenvectors could not be transformed back out of the Krylov basis.
The Lanczos block was (n x num_iter) and the tridiagonal
eigenvectors (min(num_iter, k) x k); whenever those differed the
multiply panicked on a dimension mismatch -- that is, on every
problem with more DOFs than requested modes, which is every real
modal analysis.
Lanczos now runs shift-invert by default. Plain Lanczos converges to the
eigenvalues of largest magnitude and modal analysis wants the lowest, so
without it the solver returns the modes nobody asked for. Also switched
to full reorthogonalization, twice per step, so converged eigenvalues do
not reappear as ghosts indistinguishable from genuine repeated roots.
ModalResults computed f = sqrt(lambda / 2pi) instead of
sqrt(lambda) / 2pi. The two agree only at lambda = 2pi, so a smoke test
asserting a positive frequency would never separate them. A
`#[cfg(disabled)]` module in the same file asserted the correct formula
-- the module was disabled rather than the bug fixed. That module is
removed; tests/eigenvalue_closed_form.rs supersedes it with every
expected value derived analytically.
Corrected a fixture rather than loosening its tolerance:
implementation_tests expected the smallest eigenvalue of
tridiag(-1, 4, -1) at order 3 to be 4 - 2 sqrt(2) ~= 1.172. The
eigenvalues of tridiag(c, a, c) are a + 2c cos(k pi / (n+1)), so the
true value is 4 - sqrt(2) ~= 2.586. The test had been quarantined for
failing to match an expectation that was never right.
rtx-fsi is untouched and stays 26/26.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
174 lines
6.4 KiB
Rust
174 lines
6.4 KiB
Rust
//! Closed-form validation of the generalized eigenvalue solver.
|
|
//!
|
|
//! `ModalAnalysis` is the intended consumer of this solver, so the solver has
|
|
//! to be right before the analysis is wired to it. Every expected value here
|
|
//! is derived analytically and stated in the test, not lifted from a previous
|
|
//! run of this code.
|
|
//!
|
|
//! The cases build up deliberately:
|
|
//!
|
|
//! 1. `M = I` with a matrix whose spectrum has a closed form — isolates the
|
|
//! Lanczos recurrence and the tridiagonal solve.
|
|
//! 2. `M != I` — exercises the generalized path `M^-1 K`, which is where a
|
|
//! mass matrix that is merely assumed to be the identity would hide.
|
|
//! 3. `n` much larger than the requested mode count — the case a real modal
|
|
//! analysis always hits, and the one that exercises the back-transformation
|
|
//! of eigenvectors out of the Krylov basis.
|
|
|
|
use approx::assert_relative_eq;
|
|
use rtx_fea::assembly::SparseMatrix;
|
|
use rtx_fea::solvers::eigenvalue::EigenvalueSolver;
|
|
|
|
/// Build a `SparseMatrix` from a dense row-major listing.
|
|
fn sparse_from_rows(rows: &[&[f64]]) -> SparseMatrix {
|
|
let n = rows.len();
|
|
let mut m = SparseMatrix::new(n, rows[0].len());
|
|
for (i, row) in rows.iter().enumerate() {
|
|
for (j, &v) in row.iter().enumerate() {
|
|
if v != 0.0 {
|
|
m.set_entry(i, j, v).unwrap();
|
|
}
|
|
}
|
|
}
|
|
assert_eq!(m.nrows(), n);
|
|
m
|
|
}
|
|
|
|
/// `K = tridiag(-1, 4, -1)` of order 3, `M = I`.
|
|
///
|
|
/// The eigenvalues of an `n x n` matrix `tridiag(c, a, c)` are
|
|
/// `a + 2c cos(k pi / (n + 1))` for `k = 1..=n`. With `a = 4`, `c = -1`,
|
|
/// `n = 3` and `cos(pi/4) = cos(3pi/4).abs() = sqrt(2)/2`, that gives
|
|
/// `4 - sqrt(2)`, `4`, `4 + sqrt(2)`.
|
|
#[test]
|
|
fn tridiagonal_spectrum_matches_closed_form() {
|
|
let k = sparse_from_rows(&[&[4.0, -1.0, 0.0], &[-1.0, 4.0, -1.0], &[0.0, -1.0, 4.0]]);
|
|
let m = sparse_from_rows(&[&[1.0, 0.0, 0.0], &[0.0, 1.0, 0.0], &[0.0, 0.0, 1.0]]);
|
|
|
|
let (eigenvalues, _) = EigenvalueSolver::new(3).solve(&k, &m).unwrap();
|
|
|
|
let sqrt2 = 2.0_f64.sqrt();
|
|
let expected = [4.0 - sqrt2, 4.0, 4.0 + sqrt2];
|
|
|
|
assert_eq!(eigenvalues.len(), 3);
|
|
for (i, &want) in expected.iter().enumerate() {
|
|
assert_relative_eq!(eigenvalues[i], want, epsilon = 1e-9);
|
|
}
|
|
}
|
|
|
|
/// Two-degree-of-freedom spring-mass chain, fixed at one end.
|
|
///
|
|
/// `K = [[2, -1], [-1, 1]]`, `M = diag(2, 1)`. Then
|
|
/// `det(K - lambda M) = 2 lambda^2 - 4 lambda + 1`, whose roots are
|
|
/// `1 -+ sqrt(2)/2`. `M` is deliberately not the identity: if the solver
|
|
/// ignored the mass matrix it would return the eigenvalues of `K` alone,
|
|
/// which are `(3 -+ sqrt(5))/2` and would fail this assertion.
|
|
#[test]
|
|
fn generalized_problem_accounts_for_the_mass_matrix() {
|
|
let k = sparse_from_rows(&[&[2.0, -1.0], &[-1.0, 1.0]]);
|
|
let m = sparse_from_rows(&[&[2.0, 0.0], &[0.0, 1.0]]);
|
|
|
|
let (eigenvalues, _) = EigenvalueSolver::new(2).solve(&k, &m).unwrap();
|
|
|
|
let half_sqrt2 = 2.0_f64.sqrt() / 2.0;
|
|
assert_relative_eq!(eigenvalues[0], 1.0 - half_sqrt2, epsilon = 1e-9);
|
|
assert_relative_eq!(eigenvalues[1], 1.0 + half_sqrt2, epsilon = 1e-9);
|
|
}
|
|
|
|
/// Eigenvalues must come back in ascending order.
|
|
///
|
|
/// Modal analysis names modes by index — mode 1 is the fundamental — so the
|
|
/// ordering is part of the contract, not a convenience. `nalgebra`'s
|
|
/// `symmetric_eigen` gives no ordering guarantee, so this has to be imposed.
|
|
#[test]
|
|
fn eigenvalues_are_returned_in_ascending_order() {
|
|
let k = sparse_from_rows(&[&[4.0, -1.0, 0.0], &[-1.0, 4.0, -1.0], &[0.0, -1.0, 4.0]]);
|
|
let m = sparse_from_rows(&[&[1.0, 0.0, 0.0], &[0.0, 1.0, 0.0], &[0.0, 0.0, 1.0]]);
|
|
|
|
let (eigenvalues, _) = EigenvalueSolver::new(3).solve(&k, &m).unwrap();
|
|
|
|
for i in 1..eigenvalues.len() {
|
|
assert!(
|
|
eigenvalues[i] >= eigenvalues[i - 1],
|
|
"eigenvalue {} ({}) is smaller than eigenvalue {} ({})",
|
|
i,
|
|
eigenvalues[i],
|
|
i - 1,
|
|
eigenvalues[i - 1]
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Ask for fewer modes than the problem has degrees of freedom.
|
|
///
|
|
/// This is the normal case — a modal analysis wants the lowest handful of an
|
|
/// otherwise large system — and it is the case that exercises the
|
|
/// back-transformation from the Krylov basis to the original space, where the
|
|
/// Lanczos vector block and the tridiagonal eigenvector block must agree in
|
|
/// dimension.
|
|
///
|
|
/// `K = tridiag(-1, 2, -1)` of order 10 has eigenvalues
|
|
/// `2 - 2 cos(k pi / 11)`, so the two smallest are `2 - 2 cos(pi/11)` and
|
|
/// `2 - 2 cos(2 pi / 11)`.
|
|
#[test]
|
|
fn requesting_a_subset_of_modes_returns_the_lowest_ones() {
|
|
const N: usize = 10;
|
|
|
|
let mut k = SparseMatrix::new(N, N);
|
|
let mut m = SparseMatrix::new(N, N);
|
|
for i in 0..N {
|
|
k.set_entry(i, i, 2.0).unwrap();
|
|
if i > 0 {
|
|
k.set_entry(i, i - 1, -1.0).unwrap();
|
|
k.set_entry(i - 1, i, -1.0).unwrap();
|
|
}
|
|
m.set_entry(i, i, 1.0).unwrap();
|
|
}
|
|
|
|
let num_modes = 2;
|
|
let (eigenvalues, eigenvectors) = EigenvalueSolver::new(num_modes).solve(&k, &m).unwrap();
|
|
|
|
assert_eq!(eigenvalues.len(), num_modes);
|
|
assert_eq!(
|
|
eigenvectors.nrows(),
|
|
N,
|
|
"eigenvectors must be returned in the original space, not the Krylov basis"
|
|
);
|
|
assert_eq!(eigenvectors.ncols(), num_modes);
|
|
|
|
let pi = std::f64::consts::PI;
|
|
for (i, want) in [
|
|
2.0 - 2.0 * (pi / 11.0).cos(),
|
|
2.0 - 2.0 * (2.0 * pi / 11.0).cos(),
|
|
]
|
|
.into_iter()
|
|
.enumerate()
|
|
{
|
|
assert_relative_eq!(eigenvalues[i], want, epsilon = 1e-8);
|
|
}
|
|
|
|
for i in 0..num_modes {
|
|
assert_relative_eq!(eigenvectors.column(i).norm(), 1.0, epsilon = 1e-10);
|
|
}
|
|
}
|
|
|
|
/// `f = sqrt(lambda) / (2 pi)`, not `sqrt(lambda / (2 pi))`.
|
|
///
|
|
/// `lambda` is `omega^2` in rad^2/s^2, so `omega = sqrt(lambda)` and
|
|
/// `f = omega / (2 pi)`. The two forms happen to agree only at
|
|
/// `lambda = 2 pi`, which is why a smoke test would not catch the difference.
|
|
#[test]
|
|
fn frequencies_convert_from_eigenvalues_correctly() {
|
|
use nalgebra::{DMatrix, DVector};
|
|
use rtx_fea::solvers::eigenvalue::ModalResults;
|
|
|
|
let eigenvalues = DVector::from_vec(vec![1.0, 4.0, 9.0]);
|
|
let modal = ModalResults::from_eigenvalues(eigenvalues, DMatrix::identity(3, 3));
|
|
|
|
let two_pi = 2.0 * std::f64::consts::PI;
|
|
for (i, omega) in [1.0_f64, 2.0, 3.0].into_iter().enumerate() {
|
|
assert_relative_eq!(modal.frequencies[i], omega / two_pi, epsilon = 1e-12);
|
|
assert_relative_eq!(modal.periods[i], two_pi / omega, epsilon = 1e-12);
|
|
}
|
|
}
|