rtx-fea: repair the eigensolver, and stop the suite lying about the rest
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]>
This commit is contained in:
Omar Sobh
2026-08-19 07:46:01 -07:00
co-authored by Claude Opus 5
parent 9be5f4a68f
commit cca29aac8f
15 changed files with 400 additions and 198 deletions
@@ -6,7 +6,6 @@ use rtx_cfd::solvers::lbm::boundary::{BounceBackBc, LbmBoundaryCondition, ZouHeB
use rtx_cfd::solvers::lbm::{D2Q9Parameters, D2Q9Solver}; use rtx_cfd::solvers::lbm::{D2Q9Parameters, D2Q9Solver};
#[test] #[test]
#[ignore = "Pre-existing LBM solver implementation issue"]
fn test_bounce_back_boundary_mass_conservation() { fn test_bounce_back_boundary_mass_conservation() {
let nx = 16; let nx = 16;
let ny = 16; let ny = 16;
@@ -29,7 +28,6 @@ fn test_bounce_back_boundary_mass_conservation() {
} }
#[test] #[test]
#[ignore = "Pre-existing LBM solver implementation issue"]
fn test_bounce_back_no_slip_condition() { fn test_bounce_back_no_slip_condition() {
let nx = 16; let nx = 16;
let ny = 8; let ny = 8;
@@ -115,7 +113,6 @@ fn test_zou_he_velocity_boundary() {
} }
#[test] #[test]
#[ignore = "Pre-existing Zou-He boundary condition issue"]
fn test_zou_he_pressure_boundary() { fn test_zou_he_pressure_boundary() {
let bc = ZouHeBc::new(1.2); // Pressure boundary let bc = ZouHeBc::new(1.2); // Pressure boundary
@@ -146,7 +143,6 @@ fn test_boundary_condition_trait() {
} }
#[test] #[test]
#[ignore = "Pre-existing LBM solver implementation issue"]
fn test_lid_driven_cavity_setup() { fn test_lid_driven_cavity_setup() {
let nx = 32; let nx = 32;
let ny = 32; let ny = 32;
@@ -30,7 +30,6 @@ fn test_triangle_aspect_ratio() {
} }
#[test] #[test]
#[ignore = "Pre-existing mesh quality calculation issue"]
fn test_degenerate_triangle_aspect_ratio() { fn test_degenerate_triangle_aspect_ratio() {
let mut mesh = UnstructuredMesh::new(); let mut mesh = UnstructuredMesh::new();
@@ -53,7 +52,6 @@ fn test_degenerate_triangle_aspect_ratio() {
} }
#[test] #[test]
#[ignore = "Pre-existing mesh quality calculation issue"]
fn test_quadrilateral_aspect_ratio() { fn test_quadrilateral_aspect_ratio() {
let mut mesh = UnstructuredMesh::new(); let mut mesh = UnstructuredMesh::new();
@@ -77,7 +75,6 @@ fn test_quadrilateral_aspect_ratio() {
} }
#[test] #[test]
#[ignore = "Pre-existing mesh quality calculation issue"]
fn test_rectangle_aspect_ratio() { fn test_rectangle_aspect_ratio() {
let mut mesh = UnstructuredMesh::new(); let mut mesh = UnstructuredMesh::new();
@@ -207,7 +204,6 @@ fn test_tetrahedron_aspect_ratio() {
} }
#[test] #[test]
#[ignore = "Pre-existing mesh quality calculation issue"]
fn test_structured_mesh_quality() { fn test_structured_mesh_quality() {
let mesh = StructuredMesh::new(5, 5, 2.0, 2.0).unwrap(); let mesh = StructuredMesh::new(5, 5, 2.0, 2.0).unwrap();
@@ -223,7 +219,6 @@ fn test_structured_mesh_quality() {
} }
#[test] #[test]
#[ignore = "Pre-existing mesh quality histogram issue"]
fn test_mesh_quality_histogram() { fn test_mesh_quality_histogram() {
let mut mesh = UnstructuredMesh::new(); let mut mesh = UnstructuredMesh::new();
@@ -122,7 +122,6 @@ fn test_unstructured_mesh_cell_subdivision() {
} }
#[test] #[test]
#[ignore = "Pre-existing mesh validation issue"]
fn test_hanging_node_consistency() { fn test_hanging_node_consistency() {
let mut mesh = UnstructuredMesh::new(); let mut mesh = UnstructuredMesh::new();
@@ -33,7 +33,6 @@ mod simple_tests {
} }
#[tokio::test] #[tokio::test]
#[ignore = "Pre-existing SIMPLE solver convergence issue"]
async fn test_lid_driven_cavity_re100() -> CfdResult<()> { async fn test_lid_driven_cavity_re100() -> CfdResult<()> {
// Classic benchmark: lid-driven cavity at Re=100 // Classic benchmark: lid-driven cavity at Re=100
let config = CfdConfig::new() let config = CfdConfig::new()
@@ -998,7 +998,6 @@ mod tests {
} }
#[test] #[test]
#[ignore = "Pre-existing shape function assertion failure"]
fn test_wedge15() { fn test_wedge15() {
let elem = Wedge15; let elem = Wedge15;
let xi = vec![0.25, 0.25, 0.0]; let xi = vec![0.25, 0.25, 0.0];
@@ -578,7 +578,6 @@ mod tests {
} }
#[test] #[test]
#[ignore = "Pre-existing memory info display assertion failure"]
fn test_memory_info_display() { fn test_memory_info_display() {
let info = MemoryInfo { let info = MemoryInfo {
total_memory: 8 * 1024 * 1024 * 1024, // 8 GB total_memory: 8 * 1024 * 1024 * 1024, // 8 GB
@@ -29,134 +29,218 @@ impl EigenvalueSolver {
self self
} }
/// Solve generalized eigenvalue problem K*φ = λ*M*φ using Lanczos algorithm /// Solve the generalized eigenvalue problem `K φ = λ M φ`.
///
/// Returns the `num_eigenvalues` eigenvalues closest to the shift
/// (lowest, when no shift is set) in **ascending order**, together with
/// their eigenvectors as columns in the original coordinate space,
/// normalized to unit Euclidean length. Modal analysis names modes by
/// index, so the ordering is part of the contract.
///
/// # Method
///
/// Lanczos requires a **symmetric** operator. The obvious reduction
/// `M⁻¹K` is not symmetric even when `K` and `M` both are, so it cannot
/// be used here: it has the right eigenvalues but drives the Lanczos
/// recurrence to a wrong answer rather than an inaccurate one. Instead
/// `M` is factored as `M = L Lᵀ` and the problem reduced to the
/// symmetric standard form
///
/// ```text
/// B = L⁻¹ (K - σM) L⁻ᵀ, B y = (λ - σ) y, φ = L⁻ᵀ y
/// ```
///
/// Lanczos then runs on `A = B⁻¹`, not on `B`. Plain Lanczos converges to
/// the eigenvalues of **largest** magnitude first, and modal analysis
/// wants the **lowest** — so shift-invert is not an optional refinement
/// here, it is what makes the answer the one that was asked for. The
/// eigenvalues of `A` map back as `λ = σ + 1/μ`.
///
/// # Errors
///
/// `M` must be symmetric positive definite — true of any consistent or
/// lumped mass matrix with positive densities. `K - σM` must be
/// non-singular; with the default shift of zero that means `K` must be
/// non-singular, which requires the structure to be constrained against
/// rigid-body motion. An unconstrained structure has zero-frequency
/// rigid-body modes and needs an explicit negative shift via
/// [`Self::with_shift`].
pub fn solve( pub fn solve(
&self, &self,
stiffness: &SparseMatrix, stiffness: &SparseMatrix,
mass: &SparseMatrix, mass: &SparseMatrix,
) -> FeaResult<(DVector<f64>, DMatrix<f64>)> { ) -> FeaResult<(DVector<f64>, DMatrix<f64>)> {
let n = stiffness.nrows(); let n = stiffness.nrows();
if self.num_eigenvalues == 0 || self.num_eigenvalues > n {
return Err(SolverError::SolveError {
reason: format!(
"requested {} eigenvalues from a system of order {n}",
self.num_eigenvalues
),
}
.into());
}
// Convert to dense for Lanczos implementation let k_dense: DMatrix<f64> = stiffness.to_dense();
let k_dense: nalgebra::DMatrix<f64> = stiffness.to_dense(); let m_dense: DMatrix<f64> = mass.to_dense();
let m_dense: nalgebra::DMatrix<f64> = mass.to_dense();
// Apply shift-invert if specified // M = L Lᵀ. Cholesky fails exactly when M is not positive definite,
let (a_matrix, shift_value) = if let Some(sigma) = self.shift { // which is the condition that makes the reduction below valid.
// (K - σM)⁻¹ M for shift-invert let chol = m_dense
let shifted: nalgebra::DMatrix<f64> = &k_dense - sigma * &m_dense; .clone()
let shifted_inv = shifted .cholesky()
.try_inverse() .ok_or_else(|| SolverError::SolveError {
.ok_or_else(|| SolverError::SolveError { reason: "mass matrix is not symmetric positive definite".to_string(),
reason: "Shifted matrix is singular".to_string(), })?;
})?; let l = chol.l();
(shifted_inv * &m_dense, sigma)
} else {
// Standard eigenvalue problem: M⁻¹K
let m_inv = m_dense
.try_inverse()
.ok_or_else(|| SolverError::SolveError {
reason: "Mass matrix is singular".to_string(),
})?;
(m_inv * &k_dense, 0.0)
};
// Lanczos algorithm let sigma = self.shift.unwrap_or(0.0);
let num_iter = (self.num_eigenvalues * 2).min(n); let shifted = &k_dense - sigma * &m_dense;
let (eigenvalues, eigenvectors) = self.lanczos(&a_matrix, num_iter)?;
// Apply shift correction if needed // B = L⁻¹ S L⁻ᵀ via two triangular solves: X = L⁻¹ S, then
let corrected_eigenvalues = if self.shift.is_some() { // B = (L⁻¹ Xᵀ)ᵀ. No inverse of L is formed.
eigenvalues.map(|lambda| { let x = solve_lower(&l, &shifted)?;
if lambda.abs() > 1e-12 { let b = solve_lower(&l, &x.transpose())?.transpose();
shift_value + 1.0 / lambda
} else {
lambda
}
})
} else {
eigenvalues
};
Ok((corrected_eigenvalues, eigenvectors)) // A = B⁻¹. Symmetrized to shed the asymmetry that the inverse of a
// numerically-symmetric matrix picks up at rounding level; Lanczos is
// sensitive to it.
let a = b.try_inverse().ok_or_else(|| SolverError::SolveError {
reason: if self.shift.is_some() {
"shifted matrix K - σM is singular".to_string()
} else {
"stiffness matrix is singular — an unconstrained structure has \
rigid-body modes and needs an explicit shift"
.to_string()
},
})?;
let a = 0.5 * (&a + &a.transpose());
// A generous Krylov subspace: the extra vectors are what separate
// converged Ritz values from spurious ones, and the systems reaching
// this solver are dense anyway.
let num_iter = (2 * self.num_eigenvalues + 20).min(n);
let (mu, y) = self.lanczos(&a, num_iter)?;
// μ = 1/(λ - σ), so λ = σ + 1/μ. Ritz values that have not separated
// from zero correspond to eigenvalues infinitely far from the shift;
// they are not converged modes and are dropped.
let mut pairs: Vec<(f64, usize)> = mu
.iter()
.enumerate()
.filter(|(_, m)| m.abs() > 1e-12)
.map(|(i, m)| (sigma + 1.0 / m, i))
.collect();
if pairs.len() < self.num_eigenvalues {
return Err(SolverError::SolveError {
reason: format!(
"only {} of {} requested modes converged",
pairs.len(),
self.num_eigenvalues
),
}
.into());
}
// Ascending in λ: with shift-invert these are the modes nearest σ.
pairs.sort_by(|a, b| a.0.total_cmp(&b.0));
pairs.truncate(self.num_eigenvalues);
let eigenvalues = DVector::from_iterator(pairs.len(), pairs.iter().map(|&(l, _)| l));
// φ = L⁻ᵀ y takes the eigenvectors back out of the Cholesky factor's
// coordinates into the original space.
let selected = DMatrix::from_fn(y.nrows(), pairs.len(), |i, j| y[(i, pairs[j].1)]);
let mut eigenvectors = solve_upper(&l.transpose(), &selected)?;
for mut col in eigenvectors.column_iter_mut() {
let norm = col.norm();
if norm > 1e-300 {
col /= norm;
}
}
Ok((eigenvalues, eigenvectors))
} }
/// Lanczos algorithm implementation /// Lanczos iteration on a symmetric operator.
///
/// Returns **all** Ritz pairs the Krylov subspace produced — every
/// eigenvalue of the projected tridiagonal matrix, with eigenvectors
/// already lifted back into the operator's space. Selecting among them is
/// the caller's job, because which ones are wanted depends on whether a
/// shift-invert transformation is in play.
///
/// Full reorthogonalization against every previous Lanczos vector is used
/// rather than the three-term recurrence alone. Without it the vectors
/// lose orthogonality in floating point and the iteration returns
/// duplicated ("ghost") copies of converged eigenvalues, which is
/// indistinguishable from a genuine repeated root — exactly the failure a
/// modal analysis must not have.
fn lanczos( fn lanczos(
&self, &self,
a: &DMatrix<f64>, a: &DMatrix<f64>,
num_iter: usize, num_iter: usize,
) -> FeaResult<(DVector<f64>, DMatrix<f64>)> { ) -> FeaResult<(DVector<f64>, DMatrix<f64>)> {
let n = a.nrows(); let n = a.nrows();
debug_assert!(num_iter <= n);
// Initialize with random vector // A fixed, non-symmetric start vector. It must not be orthogonal to
// any eigenvector of interest; a constant vector can be, by symmetry
// of the mode shapes, so the entries are varied. Fixed rather than
// random keeps the result reproducible run to run.
let mut v = DVector::from_fn(n, |i, _| ((i as f64 + 1.0) * 0.1).sin()); let mut v = DVector::from_fn(n, |i, _| ((i as f64 + 1.0) * 0.1).sin());
v.normalize_mut(); let norm = v.norm();
if norm < 1e-300 {
return Err(SolverError::SolveError {
reason: "Lanczos start vector vanished".to_string(),
}
.into());
}
v /= norm;
// Lanczos vectors and tridiagonal matrix coefficients let mut basis = DMatrix::zeros(n, num_iter);
let mut lanczos_vectors = DMatrix::zeros(n, num_iter); let mut alpha = vec![0.0; num_iter];
let mut alpha = DVector::zeros(num_iter); let mut beta = vec![0.0; num_iter];
let mut beta = DVector::zeros(num_iter);
lanczos_vectors.column_mut(0).copy_from(&v); // Number of iterations actually completed — the subspace can be
// exhausted early when the start vector lies in an invariant
// subspace, and the projected matrix must then be truncated to match.
let mut m = num_iter;
basis.column_mut(0).copy_from(&v);
let mut w = a * &v; let mut w = a * &v;
alpha[0] = v.dot(&w); alpha[0] = v.dot(&w);
w -= alpha[0] * &v; w -= alpha[0] * &v;
for j in 1..num_iter { for j in 1..num_iter {
beta[j - 1] = w.norm(); beta[j - 1] = w.norm();
if beta[j - 1] < 1e-12 { if beta[j - 1] < 1e-12 {
// Early termination if Krylov subspace is exhausted m = j;
break; break;
} }
let v_prev = v.clone();
v = &w / beta[j - 1]; v = &w / beta[j - 1];
lanczos_vectors.column_mut(j).copy_from(&v); basis.column_mut(j).copy_from(&v);
w = a * &v; w = a * &v;
alpha[j] = v.dot(&w); alpha[j] = v.dot(&w);
w -= alpha[j] * &v + beta[j - 1] * &v_prev; w -= alpha[j] * &v;
// Re-orthogonalization for numerical stability // Full reorthogonalization, twice. One pass is not always enough
for k in 0..=j { // when the loss of orthogonality is severe.
let vk = lanczos_vectors.column(k); for _ in 0..2 {
let h = w.dot(&vk); for k in 0..=j {
w -= h * vk; let vk = basis.column(k);
let h = w.dot(&vk);
w -= h * vk;
}
} }
} }
// Solve tridiagonal eigenvalue problem // Project onto the Krylov subspace actually built.
let (tri_eigenvalues, tri_eigenvectors) = let mut t = DMatrix::zeros(m, m);
self.solve_tridiagonal(&alpha, &beta, self.num_eigenvalues)?; for i in 0..m {
// Transform eigenvectors back to original space
let eigenvectors = &lanczos_vectors * &tri_eigenvectors;
// Normalize eigenvectors
let mut normalized_eigenvectors = DMatrix::zeros(n, self.num_eigenvalues);
for i in 0..self.num_eigenvalues {
let mut col = eigenvectors.column(i).clone_owned();
col.normalize_mut();
normalized_eigenvectors.column_mut(i).copy_from(&col);
}
Ok((tri_eigenvalues, normalized_eigenvectors))
}
/// Solve tridiagonal eigenvalue problem using QR algorithm
fn solve_tridiagonal(
&self,
alpha: &DVector<f64>,
beta: &DVector<f64>,
num_eigenvalues: usize,
) -> FeaResult<(DVector<f64>, DMatrix<f64>)> {
let n = alpha.len().min(num_eigenvalues);
// Build tridiagonal matrix
let mut t = DMatrix::zeros(n, n);
for i in 0..n {
t[(i, i)] = alpha[i]; t[(i, i)] = alpha[i];
if i > 0 { if i > 0 {
t[(i, i - 1)] = beta[i - 1]; t[(i, i - 1)] = beta[i - 1];
@@ -164,18 +248,36 @@ impl EigenvalueSolver {
} }
} }
// Use nalgebra's symmetric eigendecomposition
let eigen = t.symmetric_eigen(); let eigen = t.symmetric_eigen();
// Extract requested number of smallest eigenvalues and eigenvectors // Lift the Ritz vectors out of the Krylov basis: x = V y.
let num_vals = num_eigenvalues.min(n); let ritz_values = eigen.eigenvalues.clone();
let eigenvalues = DVector::from_fn(num_vals, |i, _| eigen.eigenvalues[i]); let ritz_vectors = basis.columns(0, m) * &eigen.eigenvectors;
let eigenvectors = DMatrix::from_fn(n, num_vals, |i, j| eigen.eigenvectors[(i, j)]);
Ok((eigenvalues, eigenvectors)) Ok((ritz_values, ritz_vectors))
} }
} }
/// Solve `L X = B` for `X`, with `L` lower triangular.
fn solve_lower(l: &DMatrix<f64>, b: &DMatrix<f64>) -> FeaResult<DMatrix<f64>> {
l.clone().solve_lower_triangular(b).ok_or_else(|| {
SolverError::SolveError {
reason: "triangular solve failed: zero on the diagonal".to_string(),
}
.into()
})
}
/// Solve `U X = B` for `X`, with `U` upper triangular.
fn solve_upper(u: &DMatrix<f64>, b: &DMatrix<f64>) -> FeaResult<DMatrix<f64>> {
u.clone().solve_upper_triangular(b).ok_or_else(|| {
SolverError::SolveError {
reason: "triangular solve failed: zero on the diagonal".to_string(),
}
.into()
})
}
/// Modal analysis results. /// Modal analysis results.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ModalResults { pub struct ModalResults {
@@ -186,8 +288,25 @@ pub struct ModalResults {
} }
impl ModalResults { impl ModalResults {
/// Derive frequencies and periods from the eigenvalues of `K φ = λ M φ`.
///
/// `λ` is `ω²` in rad²/s², so `ω = √λ` and `f = √λ / (2π)`. Note this is
/// *not* `√(λ / 2π)` — the two agree only at `λ = 2π`, so a smoke test
/// that checks the frequency is merely positive will not tell them apart.
///
/// A negative eigenvalue is not a frequency. It means the reduced
/// stiffness is not positive definite — buckling, or an unconverged
/// nonlinear step — and the corresponding entry is reported as NaN rather
/// than silently squared away into a plausible-looking number.
pub fn from_eigenvalues(eigenvalues: DVector<f64>, eigenvectors: DMatrix<f64>) -> Self { pub fn from_eigenvalues(eigenvalues: DVector<f64>, eigenvectors: DMatrix<f64>) -> Self {
let frequencies = eigenvalues.map(|lambda| (lambda / (2.0 * std::f64::consts::PI)).sqrt()); let two_pi = 2.0 * std::f64::consts::PI;
let frequencies = eigenvalues.map(|lambda| {
if lambda < 0.0 {
f64::NAN
} else {
lambda.sqrt() / two_pi
}
});
let periods = frequencies.map(|f| if f > 1e-12 { 1.0 / f } else { f64::INFINITY }); let periods = frequencies.map(|f| if f > 1e-12 { 1.0 / f } else { f64::INFINITY });
Self { Self {
@@ -199,78 +318,7 @@ impl ModalResults {
} }
} }
#[cfg(disabled)] // Tests live in tests/eigenvalue_closed_form.rs, where every expected value
mod tests { // is derived analytically. The `#[cfg(disabled)]` module that used to sit
use super::*; // here asserted a smallest eigenvalue of `4 - 2*sqrt(2)` for `tridiag(-1, 4,
use approx::assert_relative_eq; // -1)`, which is not an eigenvalue of that matrix at all.
fn create_test_matrices() -> (SparseMatrix, SparseMatrix) {
// Create simple test matrices
// K = [4, -1, 0; -1, 4, -1; 0, -1, 4] (tridiagonal stiffness)
// M = I (identity mass matrix)
let mut k = SparseMatrix::new(3, 3);
k.set(0, 0, 4.0).unwrap();
k.set(0, 1, -1.0).unwrap();
k.set(1, 0, -1.0).unwrap();
k.set(1, 1, 4.0).unwrap();
k.set(1, 2, -1.0).unwrap();
k.set(2, 1, -1.0).unwrap();
k.set(2, 2, 4.0).unwrap();
let mut m = SparseMatrix::new(3, 3);
m.set(0, 0, 1.0).unwrap();
m.set(1, 1, 1.0).unwrap();
m.set(2, 2, 1.0).unwrap();
(k, m)
}
#[test]
fn test_eigenvalue_solver() {
let (k, m) = create_test_matrices();
let solver = EigenvalueSolver::new(3);
let (eigenvalues, eigenvectors) = solver.solve(&k, &m).unwrap();
// Expected eigenvalues for this system (analytically known)
// λ₁ ≈ 2.0 - sqrt(2) ≈ 0.586
// λ₂ = 4.0
// λ₃ ≈ 2.0 + sqrt(2) ≈ 3.414
assert_eq!(eigenvalues.len(), 3);
assert_eq!(eigenvectors.ncols(), 3);
// Check that eigenvalues are sorted
for i in 1..eigenvalues.len() {
assert!(eigenvalues[i] >= eigenvalues[i - 1]);
}
// Check smallest eigenvalue is close to expected
assert_relative_eq!(eigenvalues[0], 4.0 - 2.0 * 2.0_f64.sqrt(), epsilon = 1e-6);
}
#[test]
fn test_modal_results() {
let eigenvalues = DVector::from_vec(vec![1.0, 4.0, 9.0]);
let eigenvectors = DMatrix::identity(3, 3);
let modal = ModalResults::from_eigenvalues(eigenvalues, eigenvectors);
// Check frequency calculation: f = sqrt(λ) / (2π)
assert_relative_eq!(
modal.frequencies[0],
1.0 / (2.0 * std::f64::consts::PI),
epsilon = 1e-10
);
assert_relative_eq!(
modal.frequencies[1],
2.0 / (2.0 * std::f64::consts::PI),
epsilon = 1e-10
);
assert_relative_eq!(
modal.frequencies[2],
3.0 / (2.0 * std::f64::consts::PI),
epsilon = 1e-10
);
}
}
@@ -9,7 +9,6 @@ mod tests {
use nalgebra::{DMatrix, DVector, Vector3}; use nalgebra::{DMatrix, DVector, Vector3};
#[test] #[test]
#[ignore = "Pre-existing math utils assertion failure"]
fn test_math_utils_constants() { fn test_math_utils_constants() {
assert!(MathUtils::EPSILON > 0.0); assert!(MathUtils::EPSILON > 0.0);
assert!(MathUtils::SMALL > 0.0); assert!(MathUtils::SMALL > 0.0);
@@ -198,7 +197,6 @@ mod tests {
} }
#[test] #[test]
#[ignore = "Pre-existing von mises stress assertion failure"]
fn test_von_mises_stress_biaxial() { fn test_von_mises_stress_biaxial() {
// Equal biaxial stress // Equal biaxial stress
let stress = DVector::from_vec(vec![100.0, 100.0, 0.0, 0.0, 0.0, 0.0]); let stress = DVector::from_vec(vec![100.0, 100.0, 0.0, 0.0, 0.0, 0.0]);
@@ -280,7 +278,6 @@ mod tests {
} }
#[test] #[test]
#[ignore = "Pre-existing integration assertion failure"]
fn test_integrate_1d_trigonometric() { fn test_integrate_1d_trigonometric() {
use std::f64::consts::PI; use std::f64::consts::PI;
@@ -331,7 +331,6 @@ mod assembly_tests {
} }
#[test] #[test]
#[ignore] // GlobalAssembly not yet fully implemented
fn test_global_matrix_assembly() { fn test_global_matrix_assembly() {
// RED: Test global stiffness matrix assembly // RED: Test global stiffness matrix assembly
let mesh = mesh_tests::create_test_mesh(); let mesh = mesh_tests::create_test_mesh();
@@ -413,7 +412,6 @@ mod solver_tests {
use nalgebra::DMatrix; use nalgebra::DMatrix;
#[test] #[test]
#[ignore] // DirectSolver not yet fully implemented
fn test_direct_solver() { fn test_direct_solver() {
// RED: Test direct solver for small systems // RED: Test direct solver for small systems
let n = 3; let n = 3;
@@ -438,7 +436,6 @@ mod solver_tests {
} }
#[test] #[test]
#[ignore] // IterativeSolver not yet fully implemented
fn test_iterative_solver() { fn test_iterative_solver() {
// RED: Test iterative solver for larger systems // RED: Test iterative solver for larger systems
let n = 10; let n = 10;
@@ -472,7 +469,6 @@ mod solver_tests {
} }
#[test] #[test]
#[ignore] // SolverOptions not yet fully implemented
fn test_solver_options() { fn test_solver_options() {
// RED: Test solver configuration options // RED: Test solver configuration options
// let options = SolverOptions { // let options = SolverOptions {
@@ -501,7 +497,6 @@ mod analysis_tests {
use super::*; use super::*;
#[test] #[test]
#[ignore] // StaticAnalysis not yet fully implemented
fn test_static_analysis_setup() { fn test_static_analysis_setup() {
// RED: Test complete static analysis setup // RED: Test complete static analysis setup
let mesh = mesh_tests::create_test_mesh(); let mesh = mesh_tests::create_test_mesh();
@@ -516,7 +511,6 @@ mod analysis_tests {
} }
#[test] #[test]
#[ignore] // StaticAnalysis not yet fully implemented
fn test_cantilever_beam_analysis() { fn test_cantilever_beam_analysis() {
// RED: Test classic cantilever beam problem // RED: Test classic cantilever beam problem
// Create beam mesh (simplified) // Create beam mesh (simplified)
@@ -0,0 +1,173 @@
//! 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);
}
}
@@ -7,7 +7,6 @@ use rtx_fea::mesh::{Element, MaterialId, Mesh, Node};
use rtx_fea::solvers::eigenvalue::EigenvalueSolver; use rtx_fea::solvers::eigenvalue::EigenvalueSolver;
#[test] #[test]
#[ignore = "Pre-existing eigenvalue solver assertion failure"]
fn test_eigenvalue_solver_produces_real_values() { fn test_eigenvalue_solver_produces_real_values() {
// Create test matrices // Create test matrices
let mut k = SparseMatrix::new(3, 3); let mut k = SparseMatrix::new(3, 3);
@@ -41,8 +40,16 @@ fn test_eigenvalue_solver_produces_real_values() {
assert_relative_eq!(col.norm(), 1.0, epsilon = 1e-10); assert_relative_eq!(col.norm(), 1.0, epsilon = 1e-10);
} }
// Verify smallest eigenvalue matches analytical solution // Verify smallest eigenvalue matches analytical solution.
let expected_min = 4.0 - 2.0 * 2.0_f64.sqrt(); //
// For tridiag(c, a, c) of order n the eigenvalues are
// a + 2c cos(k pi / (n + 1)), k = 1..=n. Here a = 4, c = -1, n = 3, so
// the smallest is 4 - 2 cos(pi/4) = 4 - sqrt(2) ~= 2.5858.
//
// This previously read `4 - 2 sqrt(2)`, which is not an eigenvalue of
// this matrix; the test was quarantined rather than the expectation
// corrected. See tests/eigenvalue_closed_form.rs for the fuller set.
let expected_min = 4.0 - 2.0_f64.sqrt();
assert_relative_eq!(eigenvalues[0], expected_min, epsilon = 1e-6); assert_relative_eq!(eigenvalues[0], expected_min, epsilon = 1e-6);
} }
@@ -68,7 +68,6 @@ fn test_material_database() {
} }
#[test] #[test]
#[ignore = "Pre-existing element factory assertion failure"]
fn test_element_factory() { fn test_element_factory() {
// Test element creation for all supported types // Test element creation for all supported types
for element_type in ElementType::all() { for element_type in ElementType::all() {
@@ -99,7 +99,6 @@ mod pyramid13_tests {
} }
#[test] #[test]
#[ignore = "Pre-existing pyramid partition of unity assertion failure"]
fn test_pyramid13_partition_of_unity() { fn test_pyramid13_partition_of_unity() {
// RED: Test that shape functions sum to 1 everywhere // RED: Test that shape functions sum to 1 everywhere
let pyramid13 = Pyramid13::new(); let pyramid13 = Pyramid13::new();
@@ -84,7 +84,6 @@ mod quadrilateral9_tests {
} }
#[test] #[test]
#[ignore = "Pre-existing jacobian computation assertion failure"]
fn test_quad9_jacobian() { fn test_quad9_jacobian() {
// RED: Test Jacobian computation // RED: Test Jacobian computation
let quad9 = Quadrilateral9::new(); let quad9 = Quadrilateral9::new();
@@ -32,7 +32,6 @@ mod standalone_tests {
} }
#[test] #[test]
#[ignore = "Pre-existing mesh algorithm assertion failure"]
fn test_mesh_has_real_algorithms() { fn test_mesh_has_real_algorithms() {
// Verify mesh module has real implementations // Verify mesh module has real implementations
let mesh_path = include_str!("../src/mesh/mod.rs"); let mesh_path = include_str!("../src/mesh/mod.rs");