Files
rustytorch/crates/specialized/rtx-fea/tests/implementation_tests.rs
T
Omar SobhandClaude Opus 5 cca29aac8f
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
rtx-fea: repair the eigensolver, and stop the suite lying about the rest
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]>
2026-08-19 07:46:01 -07:00

285 lines
9.3 KiB
Rust

//! Comprehensive tests for all new implementations to ensure no stubs or placeholders
use approx::assert_relative_eq;
use rtx_fea::assembly::SparseMatrix;
use rtx_fea::mesh::ElementType;
use rtx_fea::mesh::{Element, MaterialId, Mesh, Node};
use rtx_fea::solvers::eigenvalue::EigenvalueSolver;
#[test]
fn test_eigenvalue_solver_produces_real_values() {
// Create test matrices
let mut k = SparseMatrix::new(3, 3);
k.set_entry(0, 0, 4.0).unwrap();
k.set_entry(0, 1, -1.0).unwrap();
k.set_entry(1, 0, -1.0).unwrap();
k.set_entry(1, 1, 4.0).unwrap();
k.set_entry(1, 2, -1.0).unwrap();
k.set_entry(2, 1, -1.0).unwrap();
k.set_entry(2, 2, 4.0).unwrap();
let mut m = SparseMatrix::new(3, 3);
m.set_entry(0, 0, 1.0).unwrap();
m.set_entry(1, 1, 1.0).unwrap();
m.set_entry(2, 2, 1.0).unwrap();
let solver = EigenvalueSolver::new(3);
let (eigenvalues, eigenvectors) = solver.solve(&k, &m).unwrap();
// Check that we get non-zero eigenvalues
assert_eq!(eigenvalues.len(), 3);
assert!(eigenvalues[0] > 0.0, "First eigenvalue should be positive");
assert!(
eigenvalues[1] > eigenvalues[0],
"Eigenvalues should be sorted"
);
// Check eigenvectors are normalized
for i in 0..3 {
let col = eigenvectors.column(i);
assert_relative_eq!(col.norm(), 1.0, epsilon = 1e-10);
}
// Verify smallest eigenvalue matches analytical solution.
//
// 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);
}
#[test]
fn test_element_quality_metrics_real_calculations() {
let mut mesh = Mesh::new(2).unwrap();
// Create a simple triangle
let n1 = mesh.add_node(Node::new_2d(0.0, 0.0));
let n2 = mesh.add_node(Node::new_2d(1.0, 0.0));
let n3 = mesh.add_node(Node::new_2d(0.5, 0.866)); // Equilateral triangle
let mut element = Element::new(ElementType::Tri3, vec![n1, n2, n3], MaterialId(0)).unwrap();
// Get node coordinates
let node_coords = vec![
mesh.get_node(n1).unwrap().coordinates.clone(),
mesh.get_node(n2).unwrap().coordinates.clone(),
mesh.get_node(n3).unwrap().coordinates.clone(),
];
// Update quality metrics
element.update_quality(&node_coords).unwrap();
let quality = element.quality.unwrap();
// Check aspect ratio (should be close to 1 for equilateral)
assert!(
quality.aspect_ratio > 0.9 && quality.aspect_ratio < 1.1,
"Aspect ratio {} should be close to 1 for equilateral triangle",
quality.aspect_ratio
);
// Check Jacobian determinant (positive for valid element)
assert!(
quality.jacobian_determinant > 0.0,
"Jacobian determinant should be positive"
);
// Check angles (should all be close to 60 degrees for equilateral)
assert!(
quality.min_angle > 59.0 && quality.min_angle < 61.0,
"Min angle {} should be close to 60",
quality.min_angle
);
assert!(
quality.max_angle > 59.0 && quality.max_angle < 61.0,
"Max angle {} should be close to 60",
quality.max_angle
);
}
#[test]
fn test_icosphere_generation() {
let mesh = Mesh::generate_icosphere(1.0, 2).unwrap();
// Check that mesh has correct structure
assert!(
mesh.num_nodes() > 12,
"Icosphere should have more than 12 nodes after subdivision"
);
assert!(
mesh.num_elements() > 20,
"Icosphere should have more than 20 faces after subdivision"
);
// Check all nodes are on sphere surface
for (_id, node) in &mesh.nodes {
let radius = node.coordinates.norm();
assert_relative_eq!(radius, 1.0, epsilon = 1e-10);
}
// Check all elements are triangles
for (_id, element) in &mesh.elements {
assert_eq!(
element.element_type,
ElementType::Tri3,
"Icosphere should only have triangular elements"
);
}
}
#[test]
fn test_uv_sphere_generation() {
let mesh = Mesh::generate_uv_sphere(2.0, 8, 16).unwrap();
// Check mesh structure
assert!(mesh.num_nodes() > 0, "UV sphere should have nodes");
assert!(mesh.num_elements() > 0, "UV sphere should have elements");
// Check all nodes are on sphere surface
for (_id, node) in &mesh.nodes {
let radius = node.coordinates.norm();
assert_relative_eq!(radius, 2.0, epsilon = 1e-10);
}
// Check we have both triangles (at poles) and quads (in middle)
let mut has_triangles = false;
let mut has_quads = false;
for (_id, element) in &mesh.elements {
match element.element_type {
ElementType::Tri3 => has_triangles = true,
ElementType::Quad4 => has_quads = true,
_ => panic!("Unexpected element type in UV sphere"),
}
}
assert!(has_triangles, "UV sphere should have triangles at poles");
assert!(has_quads, "UV sphere should have quads in middle bands");
}
#[test]
fn test_sphere_refinement() {
let mut mesh = Mesh::generate_icosphere(1.5, 0).unwrap();
let initial_elements = mesh.num_elements();
// Refine the sphere
mesh.refine_sphere(1.5).unwrap();
// Check refinement created more elements
assert_eq!(
mesh.num_elements(),
initial_elements * 4,
"Each triangle should be subdivided into 4"
);
// Check all nodes remain on sphere surface
for (_id, node) in &mesh.nodes {
let radius = node.coordinates.norm();
assert_relative_eq!(radius, 1.5, epsilon = 1e-10);
}
}
#[test]
fn test_wedge15_shape_function_derivatives() {
use rtx_fea::elements::shape_functions::ShapeFunctions;
use rtx_fea::elements::shape_functions::shape_special::Wedge15;
let wedge = Wedge15;
// Test at a point in the element
let xi = vec![0.25, 0.25, 0.0];
let derivs = wedge.derivatives(&xi).unwrap();
// Check dimensions
assert_eq!(derivs.nrows(), 15, "Should have 15 nodes");
assert_eq!(derivs.ncols(), 3, "Should have 3 derivative directions");
// Check that derivatives are not all zero (was the bug)
let sum_abs: f64 = derivs.iter().map(|x| x.abs()).sum();
assert!(sum_abs > 1e-10, "Derivatives should not all be zero");
// Check specific derivative values for vertical edges
// At t=0, vertical edge shape functions should have non-zero dt derivatives
assert!(
(derivs[(12, 2)]).abs() < 1e-10,
"Vertical edge derivative at t=0"
);
}
#[test]
fn test_ptx_kernel_not_placeholder() {
// Read the element kernel source to ensure it's not a placeholder
let kernel_source = include_str!("../src/kernels/element_kernels.rs");
// Check that kernel computes actual stiffness matrix, not just multiplication
assert!(
kernel_source.contains("element_stiffness_kernel"),
"Should have stiffness kernel"
);
assert!(
kernel_source.contains("D11 = E/(1-nu^2)"),
"Should compute material matrix"
);
assert!(
kernel_source.contains("// Computes K_e = B^T * D * B"),
"Should document proper computation"
);
assert!(
!kernel_source.contains("placeholder"),
"Should not contain 'placeholder' comment"
);
}
#[test]
fn test_no_simplified_implementations() {
// Scan source files for "simplified" comments
let sources = [
include_str!("../src/elements/shape_functions/shape_special.rs"),
include_str!("../src/mesh/mesh_generation.rs"),
include_str!("../src/solvers/eigenvalue.rs"),
];
for source in &sources {
// Wedge15 derivatives were fixed, so this is OK now
let lines: Vec<&str> = source.lines().collect();
for (i, line) in lines.iter().enumerate() {
if line.contains("simplified") && !line.contains("// Simplified approach") {
// Check if it's in actual code, not just a comment about approach
if !line.trim().starts_with("//") {
panic!(
"Found 'simplified' in actual code at line {}: {}",
i + 1,
line
);
}
}
}
}
}
#[test]
fn test_multi_block_dot_product_reduction() {
// This would require actual GPU to test fully, but we can check the code exists
let kernel_source = include_str!("../src/kernels/matrix_kernels.rs");
// Check that final reduction is implemented
assert!(
kernel_source.contains("// If multiple blocks were used, need final reduction"),
"Should handle multi-block case"
);
assert!(
kernel_source.contains("partial_results"),
"Should allocate partial results buffer"
);
assert!(
kernel_source.contains("final_reduction"),
"Should perform final reduction"
);
assert!(
!kernel_source.contains("tracing::warn!(\"Multi-block"),
"Should not have warning about incomplete reduction"
);
}