Files
rustytorch/crates/specialized/rtx-fea/tests/modal_closed_form.rs
T
Omar SobhandClaude Fable 5 4da70faa1e
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
rtx-fea: QM6 as an opt-in bending path; cantilever pinned to Euler-Bernoulli directly
The QM6 incompatible-modes stiffness existed and was verified
(compute_stiffness_matrix_incompatible) but nothing could reach it: the
assembler always routed Quad4 through the compatible element. AssemblyOptions
gains use_incompatible_modes (default false — every existing matrix is
byte-identical, which the manufactured-solution verification depends on),
threaded through GlobalAssembler into StandardFiniteElement; element types
QM6 does not apply to keep their standard stiffness either way.

What it buys, measured on the cantilever first bending mode against
Euler-Bernoulli's 40.3848 Hz:

    mesh    QM6 (error)          compatible (error)
    8x2     40.4020  (+0.04%)    81.8102  (+102.6%)
    16x4    40.3402  (-0.11%)    53.8022  (+33.2%)
    32x8    40.3242  (-0.15%)    44.0796  (+9.2%)

The frequency is now asserted against the closed form directly (0.5% band)
instead of as convergence-from-above, plus the condensation theorem — QM6
can only soften, so its frequency must sit at or below the compatible one on
every mesh. The slight undershoot on finer meshes is physical: the 2-D solid
carries the transverse shear flexibility the beam theory neglects.

552 rtx-fea tests, 0 failing.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-19 19:47:25 -07:00

412 lines
16 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.
//! Modal analysis against closed-form natural frequencies.
//!
//! This is the end-to-end check that mesh, DOF numbering, constraints,
//! element matrices, global assembly and the eigensolver are all correct
//! *together*. Each has its own unit tests; none of those would catch a
//! mismatch between them, such as a mass matrix assembled in a different DOF
//! order than its stiffness.
//!
//! # Why axial modes and not a cantilever
//!
//! The obvious benchmark is the bending frequency of a cantilever,
//! `β₁L = 1.8751`. It is the wrong first test here. Bilinear `Quad4` elements
//! suffer **shear locking** in bending: their assumed displacement field
//! cannot represent pure bending without spurious shear strain, so a coarse
//! mesh is far too stiff and reports frequencies well above the true value.
//! A cantilever test would fail for a reason that has nothing to do with
//! whether the code under test is correct, and tuning the tolerance until it
//! passed would destroy its value as evidence.
//!
//! Longitudinal (axial) vibration has no such problem. The exact solution of
//! the 1-D wave equation for a fixed-free bar is
//!
//! ```text
//! f_n = (2n - 1) / (4L) * sqrt(E / rho), n = 1, 2, 3, ...
//! ```
//!
//! and a plane-stress mesh with transverse motion suppressed reduces to
//! exactly that problem. Linear elements with a consistent mass matrix
//! converge to it from above at `O(h²)`, so a modest mesh lands within a
//! fraction of a percent — tight enough that a real error cannot hide.
//!
//! Bending is still checked below, but as a *convergence* statement rather
//! than a single tolerance, which is the honest way to assert on an element
//! that is known to lock.
use rtx_fea::analysis::{Analysis, AnalysisConfig, AnalysisData, ModalAnalysis};
use rtx_fea::assembly::DofComponent;
use rtx_fea::boundary::{BoundaryCondition, BoundaryConditionSet, DirichletBC};
use rtx_fea::materials::{LinearElastic, MaterialDatabase};
use rtx_fea::mesh::{Element, ElementType, MaterialId, Mesh, Node, NodeId};
const E: f64 = 200e9;
const RHO: f64 = 8000.0;
const LENGTH: f64 = 1.0;
const HEIGHT: f64 = 0.05;
/// A rectangular `nx` by `ny` grid of `Quad4` elements spanning
/// `[0, LENGTH] x [0, HEIGHT]`, returned with its node grid so tests can pick
/// out edges to constrain.
fn bar_mesh(nx: usize, ny: usize) -> (Mesh, Vec<Vec<NodeId>>) {
let mut mesh = Mesh::new(2).unwrap();
let mut grid = vec![vec![NodeId(0); ny + 1]; nx + 1];
for (i, column) in grid.iter_mut().enumerate() {
for (j, slot) in column.iter_mut().enumerate() {
let x = LENGTH * i as f64 / nx as f64;
let y = HEIGHT * j as f64 / ny as f64;
*slot = mesh.add_node(Node::new_2d(x, y));
}
}
for i in 0..nx {
for j in 0..ny {
// Counter-clockwise, so the Jacobian determinant is positive.
let nodes = vec![
grid[i][j],
grid[i + 1][j],
grid[i + 1][j + 1],
grid[i][j + 1],
];
let element = Element::new(ElementType::Quad4, nodes, MaterialId(0)).unwrap();
mesh.add_element(element).unwrap();
}
}
(mesh, grid)
}
/// Poisson's ratio is zero throughout.
///
/// This is a deliberate modelling choice, not a convenience: with `nu = 0`
/// the axial and transverse responses decouple exactly, so the plane-stress
/// model reduces to the 1-D bar the closed form describes. A non-zero
/// Poisson's ratio would introduce a real physical difference between the two
/// and the comparison would no longer be exact.
fn steel_no_poisson() -> MaterialDatabase {
let mut materials = MaterialDatabase::new();
materials.add_material(
MaterialId(0),
LinearElastic::new(E, 0.0).with_density(RHO),
Some("steel".to_string()),
);
materials
}
fn frequencies_of(results: &rtx_fea::analysis::AnalysisResults) -> Vec<f64> {
match results
.additional_data
.get("frequencies")
.expect("frequencies missing")
{
AnalysisData::Vector(v) => v.iter().copied().collect(),
other => panic!("frequencies had unexpected type {other:?}"),
}
}
/// Longitudinal modes of a fixed-free bar against `f_n = (2n-1)/(4L)·√(E/ρ)`.
#[test]
fn axial_modes_match_the_closed_form_bar() {
let nx = 24;
let ny = 2;
let (mesh, grid) = bar_mesh(nx, ny);
let mut bcs = BoundaryConditionSet::new();
// Clamp the left edge axially.
let left_edge: Vec<NodeId> = grid[0].clone();
bcs.add_condition(BoundaryCondition::Dirichlet(DirichletBC::fixed(
left_edge,
vec![DofComponent::DisplacementX],
0.0,
)));
// Suppress transverse motion everywhere, reducing the plane-stress model
// to the 1-D bar the closed form describes. Without this the spectrum is
// interleaved with bending modes and the comparison is meaningless.
let all_nodes: Vec<NodeId> = grid.iter().flatten().copied().collect();
bcs.add_condition(BoundaryCondition::Dirichlet(DirichletBC::fixed(
all_nodes,
vec![DofComponent::DisplacementY],
0.0,
)));
let num_modes = 3;
let mut analysis = ModalAnalysis::new(
mesh,
steel_no_poisson(),
num_modes,
AnalysisConfig::default(),
)
.with_boundary_conditions(bcs);
let results = analysis.run().expect("modal analysis failed");
let computed = frequencies_of(&results);
let wave_speed = (E / RHO).sqrt();
for n in 1..=num_modes {
let exact = (2 * n - 1) as f64 / (4.0 * LENGTH) * wave_speed;
let got = computed[n - 1];
let relative_error = (got - exact).abs() / exact;
assert!(
relative_error < 0.01,
"mode {n}: computed {got:.4} Hz against exact {exact:.4} Hz \
({:.3}% error)",
relative_error * 100.0
);
// Linear elements with a consistent mass matrix are stiffer than the
// continuum, so the discrete frequency must come in high. Landing
// below the exact value means something is wrong even if the
// magnitude looks plausible.
assert!(
got >= exact * (1.0 - 1e-9),
"mode {n}: computed {got:.4} Hz is below the exact {exact:.4} Hz; \
a consistent-mass discretisation cannot be softer than the continuum"
);
}
}
/// Refining the mesh must drive the axial error down, and at the expected
/// second-order rate.
///
/// A single tolerance check can be satisfied by a wrong formula with a
/// compensating error. A convergence *rate* cannot: it pins the
/// discretisation itself.
#[test]
fn axial_frequency_converges_at_second_order() {
let wave_speed = (E / RHO).sqrt();
let exact = wave_speed / (4.0 * LENGTH);
let mut errors = Vec::new();
for nx in [4usize, 8, 16] {
let (mesh, grid) = bar_mesh(nx, 1);
let mut bcs = BoundaryConditionSet::new();
bcs.add_condition(BoundaryCondition::Dirichlet(DirichletBC::fixed(
grid[0].clone(),
vec![DofComponent::DisplacementX],
0.0,
)));
bcs.add_condition(BoundaryCondition::Dirichlet(DirichletBC::fixed(
grid.iter().flatten().copied().collect(),
vec![DofComponent::DisplacementY],
0.0,
)));
let mut analysis =
ModalAnalysis::new(mesh, steel_no_poisson(), 1, AnalysisConfig::default())
.with_boundary_conditions(bcs);
let results = analysis.run().expect("modal analysis failed");
errors.push((frequencies_of(&results)[0] - exact).abs() / exact);
}
for window in errors.windows(2) {
let rate = (window[0] / window[1]).log2();
assert!(
rate > 1.7,
"halving the element size reduced the error by only 2^{rate:.2}; \
expected close to second order. errors: {errors:?}"
);
}
}
/// An unconstrained structure has rigid-body modes, so `K` is singular.
///
/// The failure must be a clear error rather than a set of near-zero
/// eigenvalues that look like real low-frequency modes.
#[test]
fn unconstrained_structure_is_rejected_rather_than_silently_wrong() {
let (mesh, _) = bar_mesh(4, 1);
let mut analysis = ModalAnalysis::new(mesh, steel_no_poisson(), 2, AnalysisConfig::default());
let error = analysis
.run()
.expect_err("an unconstrained structure must not yield frequencies");
let message = error.to_string().to_lowercase();
assert!(
message.contains("singular") || message.contains("shift"),
"error should name the singular stiffness or the shift remedy, got: {error}"
);
}
/// Every reported natural frequency must be real and positive.
///
/// A constrained, positive-definite structure has no zero-frequency mode. A
/// zero or NaN here means the constraints did not reach the assembled system
/// or the eigenvalues came back negative.
#[test]
fn frequencies_are_real_and_positive() {
let (mesh, grid) = bar_mesh(6, 2);
let mut bcs = BoundaryConditionSet::new();
bcs.add_condition(BoundaryCondition::Dirichlet(DirichletBC::fixed(
grid[0].clone(),
vec![DofComponent::DisplacementX, DofComponent::DisplacementY],
0.0,
)));
let mut analysis = ModalAnalysis::new(mesh, steel_no_poisson(), 4, AnalysisConfig::default())
.with_boundary_conditions(bcs);
let results = analysis.run().expect("modal analysis failed");
let frequencies = frequencies_of(&results);
assert_eq!(frequencies.len(), 4);
for (i, f) in frequencies.iter().enumerate() {
assert!(
f.is_finite() && *f > 0.0,
"mode {} frequency is {f}, which is not a physical frequency",
i + 1
);
}
// Ascending, since modes are named by index.
for pair in frequencies.windows(2) {
assert!(
pair[1] >= pair[0],
"frequencies are not ascending: {frequencies:?}"
);
}
}
/// Cantilever bending, asserted as convergence rather than as a tolerance.
///
/// `Quad4` locks in bending, so the coarse-mesh frequency is far too high.
/// What must still hold is that refinement moves it monotonically *towards*
/// the Euler-Bernoulli value `f₁ = (β₁L)²/(2πL²)·√(EI/ρA)` with
/// `β₁L = 1.8751` — and that it approaches from above, which is the signature
/// of locking rather than of a bug.
#[test]
fn cantilever_bending_converges_towards_euler_bernoulli_from_above() {
let beta_l: f64 = 1.8751;
// Plane stress with unit thickness: A = h, I = h³/12.
let area = HEIGHT;
let second_moment = HEIGHT.powi(3) / 12.0;
let exact = beta_l.powi(2) / (2.0 * std::f64::consts::PI * LENGTH.powi(2))
* (E * second_moment / (RHO * area)).sqrt();
let mut computed = Vec::new();
for (nx, ny) in [(8usize, 2usize), (16, 4), (32, 8)] {
let (mesh, grid) = bar_mesh(nx, ny);
let mut bcs = BoundaryConditionSet::new();
bcs.add_condition(BoundaryCondition::Dirichlet(DirichletBC::fixed(
grid[0].clone(),
vec![DofComponent::DisplacementX, DofComponent::DisplacementY],
0.0,
)));
let mut analysis =
ModalAnalysis::new(mesh, steel_no_poisson(), 1, AnalysisConfig::default())
.with_boundary_conditions(bcs);
let results = analysis.run().expect("modal analysis failed");
computed.push(frequencies_of(&results)[0]);
}
for (i, f) in computed.iter().enumerate() {
assert!(
*f > exact * 0.95,
"mesh {i}: {f:.3} Hz is below the Euler-Bernoulli value {exact:.3} Hz; \
a locking element cannot be softer than the beam theory it approximates"
);
}
for pair in computed.windows(2) {
assert!(
pair[1] <= pair[0] * 1.001,
"refining the mesh increased the bending frequency ({:?}); \
locking must relax under refinement, not worsen",
computed
);
}
let coarse_error = (computed[0] - exact).abs() / exact;
let fine_error = (computed[computed.len() - 1] - exact).abs() / exact;
assert!(
fine_error < coarse_error,
"refinement did not reduce the bending error: {coarse_error:.4} -> {fine_error:.4} \
against exact {exact:.3} Hz, computed {computed:?}"
);
}
/// The same cantilever with QM6 incompatible modes: the frequency can be
/// asserted against Euler-Bernoulli *directly*, not merely as
/// convergence-from-above, because QM6 supplies the quadratic displacement
/// bending needs and removes the locking that inflated the compatible
/// element's frequency.
#[test]
fn cantilever_bending_matches_euler_bernoulli_with_qm6() {
use rtx_fea::assembly::AssemblyOptions;
let beta_l: f64 = 1.8751;
let area = HEIGHT;
let second_moment = HEIGHT.powi(3) / 12.0;
let exact = beta_l.powi(2) / (2.0 * std::f64::consts::PI * LENGTH.powi(2))
* (E * second_moment / (RHO * area)).sqrt();
let mut computed = Vec::new();
let mut compatible = Vec::new();
for (nx, ny) in [(8usize, 2usize), (16, 4), (32, 8)] {
for qm6 in [true, false] {
let (mesh, grid) = bar_mesh(nx, ny);
let mut bcs = BoundaryConditionSet::new();
bcs.add_condition(BoundaryCondition::Dirichlet(DirichletBC::fixed(
grid[0].clone(),
vec![DofComponent::DisplacementX, DofComponent::DisplacementY],
0.0,
)));
let mut analysis =
ModalAnalysis::new(mesh, steel_no_poisson(), 1, AnalysisConfig::default())
.with_boundary_conditions(bcs);
analysis.set_assembly_options(AssemblyOptions {
use_incompatible_modes: qm6,
..AssemblyOptions::default()
});
let results = analysis.run().expect("modal analysis failed");
let f = frequencies_of(&results)[0];
if qm6 {
computed.push(f);
} else {
compatible.push(f);
}
}
}
println!(" Euler-Bernoulli f1 = {exact:.4} Hz");
for (i, (q, c)) in computed.iter().zip(&compatible).enumerate() {
println!(
" mesh {i}: QM6 = {q:.4} Hz ({:+.2}%) compatible = {c:.4} Hz ({:+.2}%)",
100.0 * (q - exact) / exact,
100.0 * (c - exact) / exact
);
}
// Condensation can only soften: QM6 must be at or below the compatible
// frequency on every mesh. This is a theorem, not a tolerance.
for (q, c) in computed.iter().zip(&compatible) {
assert!(
q <= c,
"QM6 frequency {q:.4} above compatible {c:.4}: condensation \
cannot stiffen"
);
}
// Measured: 40.4020, 40.3402, 40.3242 Hz against Euler-Bernoulli's
// 40.3848 — within 0.04% to 0.15% on every mesh, including the coarsest,
// where the compatible element reads +102.6% from shear locking. The
// slight undershoot on the finer meshes is physical: the 2-D solid
// carries the transverse shear flexibility Euler-Bernoulli neglects, so
// the true frequency of this geometry sits a little below the beam
// theory's. The 0.5% band holds all of that and still fails the
// compatible element's coarsest reading by a factor of 200.
for (i, f) in computed.iter().enumerate() {
let relative_error = (f - exact).abs() / exact;
assert!(
relative_error < 0.005,
"mesh {i}: QM6 frequency {f:.4} Hz is {:.3}% from Euler-Bernoulli's {exact:.4} Hz — outside the band QM6 warrants",
100.0 * relative_error
);
}
}