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
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:
co-authored by
Claude Opus 5
parent
9be5f4a68f
commit
cca29aac8f
@@ -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 {
|
||||||
// Convert to dense for Lanczos implementation
|
return Err(SolverError::SolveError {
|
||||||
let k_dense: nalgebra::DMatrix<f64> = stiffness.to_dense();
|
reason: format!(
|
||||||
let m_dense: nalgebra::DMatrix<f64> = mass.to_dense();
|
"requested {} eigenvalues from a system of order {n}",
|
||||||
|
self.num_eigenvalues
|
||||||
// Apply shift-invert if specified
|
),
|
||||||
let (a_matrix, shift_value) = if let Some(sigma) = self.shift {
|
|
||||||
// (K - σM)⁻¹ M for shift-invert
|
|
||||||
let shifted: nalgebra::DMatrix<f64> = &k_dense - sigma * &m_dense;
|
|
||||||
let shifted_inv = shifted
|
|
||||||
.try_inverse()
|
|
||||||
.ok_or_else(|| SolverError::SolveError {
|
|
||||||
reason: "Shifted matrix is singular".to_string(),
|
|
||||||
})?;
|
|
||||||
(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 num_iter = (self.num_eigenvalues * 2).min(n);
|
|
||||||
let (eigenvalues, eigenvectors) = self.lanczos(&a_matrix, num_iter)?;
|
|
||||||
|
|
||||||
// Apply shift correction if needed
|
|
||||||
let corrected_eigenvalues = if self.shift.is_some() {
|
|
||||||
eigenvalues.map(|lambda| {
|
|
||||||
if lambda.abs() > 1e-12 {
|
|
||||||
shift_value + 1.0 / lambda
|
|
||||||
} else {
|
|
||||||
lambda
|
|
||||||
}
|
}
|
||||||
})
|
.into());
|
||||||
} else {
|
|
||||||
eigenvalues
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok((corrected_eigenvalues, eigenvectors))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lanczos algorithm implementation
|
let k_dense: DMatrix<f64> = stiffness.to_dense();
|
||||||
|
let m_dense: DMatrix<f64> = mass.to_dense();
|
||||||
|
|
||||||
|
// M = L Lᵀ. Cholesky fails exactly when M is not positive definite,
|
||||||
|
// which is the condition that makes the reduction below valid.
|
||||||
|
let chol = m_dense
|
||||||
|
.clone()
|
||||||
|
.cholesky()
|
||||||
|
.ok_or_else(|| SolverError::SolveError {
|
||||||
|
reason: "mass matrix is not symmetric positive definite".to_string(),
|
||||||
|
})?;
|
||||||
|
let l = chol.l();
|
||||||
|
|
||||||
|
let sigma = self.shift.unwrap_or(0.0);
|
||||||
|
let shifted = &k_dense - sigma * &m_dense;
|
||||||
|
|
||||||
|
// B = L⁻¹ S L⁻ᵀ via two triangular solves: X = L⁻¹ S, then
|
||||||
|
// B = (L⁻¹ Xᵀ)ᵀ. No inverse of L is formed.
|
||||||
|
let x = solve_lower(&l, &shifted)?;
|
||||||
|
let b = solve_lower(&l, &x.transpose())?.transpose();
|
||||||
|
|
||||||
|
// 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 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
|
||||||
|
// when the loss of orthogonality is severe.
|
||||||
|
for _ in 0..2 {
|
||||||
for k in 0..=j {
|
for k in 0..=j {
|
||||||
let vk = lanczos_vectors.column(k);
|
let vk = basis.column(k);
|
||||||
let h = w.dot(&vk);
|
let h = w.dot(&vk);
|
||||||
w -= h * vk;
|
w -= h * vk;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Solve tridiagonal eigenvalue problem
|
|
||||||
let (tri_eigenvalues, tri_eigenvectors) =
|
|
||||||
self.solve_tridiagonal(&alpha, &beta, self.num_eigenvalues)?;
|
|
||||||
|
|
||||||
// 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))
|
// Project onto the Krylov subspace actually built.
|
||||||
}
|
let mut t = DMatrix::zeros(m, m);
|
||||||
|
for i in 0..m {
|
||||||
/// 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");
|
||||||
|
|||||||
Reference in New Issue
Block a user