Files
rustytorch/crates/specialized/rtx-fea/tests/element_matrices_physical.rs
T
Omar SobhandClaude Opus 5 4c2cea36aa rtx-fea: make the analysis stack produce physics, validated against closed form
The census found rtx-fea could not produce a non-zero answer for any
analysis type. Six defects sat between a correctly specified mesh and a
natural frequency, each of which alone was fatal. Every one was found by
writing the closed-form test first and confirming red.

1. Element matrices were a stub. StandardFiniteElement::
   compute_element_matrices returned DMatrix::zeros for stiffness, force
   and mass -- and it is what GlobalAssembler calls for every element, so
   every global matrix in the crate was zero. Real quadrature-based
   stiffness and mass already existed in ElementMatrixComputer; nothing
   called them. Now wired, with the scalar mass matrix expanded by a
   Kronecker product with the spatial identity to match the interleaved
   per-node DOF layout its stiffness uses.

2. Quadrature returned no points. quadrature_rule built
   QuadratureRule::new(vec![], ..). Every integration loop iterates over
   rule.points, so an empty rule does not fail -- it skips the loop and
   yields a zero matrix. Real Gauss rules for line, triangle, quad, tet
   and hex existed unused; now dispatched by element type, with wedges as
   the triangle-line tensor product and pyramids an explicit error rather
   than an empty rule.

3. transform_derivatives computed J^-T * dN where dN is
   (num_nodes x param_dim). By the chain rule it is dN * J^-1. The two
   agree only when both are square and symmetric; for any element with
   more nodes than parametric directions -- every element -- the old form
   was a dimension mismatch that panicked inside BLAS.

4. MaterialDatabase::clone silently dropped every material, cloning
   names only, because Box<dyn Material> is not Clone. GlobalAssembler is
   constructed with materials.clone(), so every assembler ever built got
   an empty database and every analysis failed MaterialNotFound on a
   correctly specified mesh. Materials are immutable once registered, so
   the map now holds Arc and cloning shares them.

5. displacement_only numbered three displacement components on a 2-D
   mesh. Elements supply two, so assembly rejected every contribution.

6. to_dof_numbering pushed each node's DOFs in HashMap iteration order.
   When that came out [v, u] the assembler wrote the element's u row into
   the global v row. The result was still symmetric, still had the right
   rigid-body null space and still summed to the right total mass -- it
   simply described a structure with its axes transposed per node, and
   get_dof(node, DisplacementX) then pointed at the wrong row so
   constraints were applied to the wrong direction too. DofComponent now
   carries a canonical_index and the DOFs are sorted by it.

ModalAnalysis is wired to real assembly and the repaired eigensolver, and
takes boundary conditions, which it previously had no way to accept. The
eigensolver now rejects a singular stiffness explicitly: try_inverse does
not fail on a matrix singular only to working precision, so an
unconstrained structure used to return rigid-body noise dressed up as
low-frequency modes.

Validation, 18 tests:

  - Element matrices: rigid translation stores no energy, exactly 3
    rigid-body modes in 2-D and 6 in 3-D, consistent mass integrates to
    rho*V, mass positive definite, and K and M each scale only with the
    property they depend on. A zero matrix passes symmetry and
    does-not-crash checks, so these are chosen to be ones it fails.
  - Modal, end to end: longitudinal modes of a fixed-free bar against
    f_n = (2n-1)/(4L) sqrt(E/rho), within 1% on the first three, and
    second-order convergence under refinement. Axial rather than
    cantilever bending on purpose: Quad4 shear-locks, so a bending
    tolerance would fail for a reason unrelated to correctness. Bending
    is asserted as convergence from above instead, which is the honest
    claim for a locking element.

Two fixtures corrected rather than tolerances loosened: integration_tests
expected 27 DOFs for a 9-node planar mesh (3 components per node), which
encoded defect 5 and contradicted comprehensive_tdd_tests asserting
num_nodes * 2 for the same situation.

rtx-fsi stays 26/26. No new failures; the rtx-cfd quarantine is
unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 08:10:57 -07:00

291 lines
9.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Physical invariants of the element stiffness and mass matrices.
//!
//! These are the cheapest decisive checks that element matrix computation is
//! real. None of them needs a reference table: each is a property the exact
//! matrices must satisfy for any correct implementation, so a failure
//! localises to the quadrature, the Jacobian, the shape function derivatives
//! or the constitutive matrix rather than to an accuracy budget.
//!
//! They exist because `StandardFiniteElement::compute_element_matrices`
//! returned `DMatrix::zeros(..)` for every element in the mesh, which made
//! every global matrix `GlobalAssembler` produced zero, and every analysis
//! built on it a solve of a null system. A zero matrix passes a symmetry
//! check and a "does not crash" check, so the invariants below are chosen to
//! be ones a zero matrix fails.
use approx::assert_relative_eq;
use nalgebra::{DVector, Vector3};
use rtx_fea::elements::StandardFiniteElement;
use rtx_fea::materials::LinearElastic;
use rtx_fea::mesh::ElementType;
const E: f64 = 210e9;
const NU: f64 = 0.3;
const RHO: f64 = 7850.0;
fn steel() -> LinearElastic {
LinearElastic::new(E, NU).with_density(RHO)
}
/// Unit square Quad4, counter-clockwise from the origin. Area = 1.
fn unit_square() -> StandardFiniteElement {
StandardFiniteElement::new(
ElementType::Quad4,
vec![
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(1.0, 0.0, 0.0),
Vector3::new(1.0, 1.0, 0.0),
Vector3::new(0.0, 1.0, 0.0),
],
)
}
/// A 2 x 3 rectangle, to catch anything that only works when `det J == 1`.
fn rectangle_2x3() -> StandardFiniteElement {
StandardFiniteElement::new(
ElementType::Quad4,
vec![
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(2.0, 0.0, 0.0),
Vector3::new(2.0, 3.0, 0.0),
Vector3::new(0.0, 3.0, 0.0),
],
)
}
/// The stub this suite exists to catch returned zeros everywhere.
#[test]
fn element_matrices_are_not_zero() {
let m = unit_square()
.compute_element_matrices(&steel(), 0.0)
.unwrap();
assert!(
m.stiffness_matrix.amax() > 0.0,
"stiffness matrix is entirely zero — element matrix computation is a stub"
);
let mass = m
.mass_matrix
.as_ref()
.expect("a mass matrix is required for modal and dynamic analysis");
assert!(
mass.amax() > 0.0,
"mass matrix is entirely zero — element matrix computation is a stub"
);
}
/// `K` must be symmetric: it is `∫ Bᵀ D B dV` with `D` symmetric.
#[test]
fn stiffness_is_symmetric() {
let m = rectangle_2x3()
.compute_element_matrices(&steel(), 0.0)
.unwrap();
let k = &m.stiffness_matrix;
for i in 0..k.nrows() {
for j in 0..k.ncols() {
assert_relative_eq!(k[(i, j)], k[(j, i)], epsilon = 1e-6 * k.amax());
}
}
}
/// A rigid-body translation stores no strain energy, so `K t = 0`.
///
/// This is the single most informative check on the strain-displacement
/// matrix `B`: it fails for a sign error, a mis-transformed derivative, or a
/// wrong DOF ordering, none of which a symmetry check detects. It is the
/// element-level half of the patch test.
#[test]
fn rigid_translation_produces_no_internal_force() {
let element = rectangle_2x3();
let m = element.compute_element_matrices(&steel(), 0.0).unwrap();
let k = &m.stiffness_matrix;
let num_nodes = 4;
let dim = 2;
assert_eq!(k.nrows(), num_nodes * dim);
// One translation per spatial direction.
for d in 0..dim {
let mut t = DVector::zeros(num_nodes * dim);
for node in 0..num_nodes {
t[node * dim + d] = 1.0;
}
let f = k * &t;
assert!(
f.amax() < 1e-6 * k.amax(),
"translating the element in direction {d} produced internal force {:.3e} \
against a stiffness scale of {:.3e}",
f.amax(),
k.amax()
);
}
}
/// An unconstrained plane element has exactly three rigid-body modes — two
/// translations and one rotation — so `K` has a three-dimensional null space.
///
/// Fewer means the element is spuriously stiff; more means it is rank
/// deficient and admits a zero-energy deformation (hourglassing), which shows
/// up in a real analysis as a mode shape that is pure noise.
#[test]
fn stiffness_has_exactly_three_rigid_body_modes_in_2d() {
let m = rectangle_2x3()
.compute_element_matrices(&steel(), 0.0)
.unwrap();
let k = m.stiffness_matrix;
let eigenvalues = k.clone().symmetric_eigenvalues();
let scale = k.amax();
let num_zero = eigenvalues
.iter()
.filter(|&&e| e.abs() < 1e-9 * scale)
.count();
assert_eq!(
num_zero,
3,
"expected 3 rigid-body modes (2 translations + 1 rotation), found {num_zero}; \
eigenvalues (scaled): {:?}",
eigenvalues.iter().map(|e| e / scale).collect::<Vec<_>>()
);
}
/// The consistent mass matrix integrates to the element's total mass.
///
/// `Σᵢⱼ Mᵢⱼ = ∫ ρ (Σᵢ Nᵢ)(Σⱼ Nⱼ) dV = ∫ ρ dV = ρV`, using the partition of
/// unity. This is decisive for the quadrature rule and the Jacobian
/// determinant together: get either wrong and the total mass is wrong by
/// exactly the factor of the error.
#[test]
fn consistent_mass_integrates_to_rho_times_volume() {
for (element, area) in [(unit_square(), 1.0), (rectangle_2x3(), 6.0)] {
let m = element.compute_element_matrices(&steel(), 0.0).unwrap();
let mass = m.mass_matrix.expect("mass matrix required");
// Vector-valued mass: each spatial direction carries the full mass,
// so the whole matrix sums to `dim * rho * V`.
let dim = 2;
let total: f64 = mass.iter().sum();
assert_relative_eq!(total, dim as f64 * RHO * area, max_relative = 1e-9);
}
}
/// The mass matrix must be positive definite.
///
/// It is the metric in `K φ = λ M φ`; if it is not positive definite the
/// eigenproblem has no real spectrum and the Cholesky reduction in the
/// eigensolver fails outright.
#[test]
fn mass_matrix_is_positive_definite() {
let m = rectangle_2x3()
.compute_element_matrices(&steel(), 0.0)
.unwrap();
let mass = m.mass_matrix.expect("mass matrix required");
let eigenvalues = mass.clone().symmetric_eigenvalues();
let min = eigenvalues.iter().cloned().fold(f64::INFINITY, f64::min);
assert!(
min > 0.0,
"mass matrix has a non-positive eigenvalue {min:.3e}; \
eigenvalues: {eigenvalues:?}"
);
}
/// Mass scales linearly with density, stiffness with elastic modulus, and
/// neither picks up the other's property.
///
/// A single hard-coded default leaking into the computation would break this,
/// and would otherwise be invisible in a suite that only ever uses one
/// material.
#[test]
fn matrices_scale_with_the_material_they_are_given() {
let base = steel();
let stiffer = LinearElastic::new(2.0 * E, NU).with_density(RHO);
let denser = LinearElastic::new(E, NU).with_density(3.0 * RHO);
let element = rectangle_2x3();
let m0 = element.compute_element_matrices(&base, 0.0).unwrap();
let m_stiff = element.compute_element_matrices(&stiffer, 0.0).unwrap();
let m_dense = element.compute_element_matrices(&denser, 0.0).unwrap();
assert_relative_eq!(
m_stiff.stiffness_matrix.amax(),
2.0 * m0.stiffness_matrix.amax(),
max_relative = 1e-12
);
assert_relative_eq!(
m_dense.mass_matrix.as_ref().unwrap().amax(),
3.0 * m0.mass_matrix.as_ref().unwrap().amax(),
max_relative = 1e-12
);
// Doubling E must not change the mass; tripling ρ must not change K.
assert_relative_eq!(
m_stiff.mass_matrix.as_ref().unwrap().amax(),
m0.mass_matrix.as_ref().unwrap().amax(),
max_relative = 1e-12
);
assert_relative_eq!(
m_dense.stiffness_matrix.amax(),
m0.stiffness_matrix.amax(),
max_relative = 1e-12
);
}
/// The same invariants must hold in 3D, where the null space is six
/// dimensional — three translations and three rotations.
#[test]
fn hexahedron_satisfies_the_same_invariants() {
let element = StandardFiniteElement::new(
ElementType::Hex8,
vec![
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(2.0, 0.0, 0.0),
Vector3::new(2.0, 1.0, 0.0),
Vector3::new(0.0, 1.0, 0.0),
Vector3::new(0.0, 0.0, 3.0),
Vector3::new(2.0, 0.0, 3.0),
Vector3::new(2.0, 1.0, 3.0),
Vector3::new(0.0, 1.0, 3.0),
],
);
let volume = 2.0 * 1.0 * 3.0;
let m = element.compute_element_matrices(&steel(), 0.0).unwrap();
let k = m.stiffness_matrix;
let mass = m.mass_matrix.expect("mass matrix required");
assert_eq!(k.nrows(), 8 * 3);
// Rigid translations.
for d in 0..3 {
let mut t = DVector::zeros(24);
for node in 0..8 {
t[node * 3 + d] = 1.0;
}
let f = &k * &t;
assert!(
f.amax() < 1e-6 * k.amax(),
"3-D rigid translation in direction {d} produced force {:.3e}",
f.amax()
);
}
let scale = k.amax();
let num_zero = k
.clone()
.symmetric_eigenvalues()
.iter()
.filter(|&&e| e.abs() < 1e-9 * scale)
.count();
assert_eq!(
num_zero, 6,
"expected 6 rigid-body modes in 3-D, found {num_zero}"
);
let total: f64 = mass.iter().sum();
assert_relative_eq!(total, 3.0 * RHO * volume, max_relative = 1e-9);
}