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]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
cca29aac8f
commit
4c2cea36aa
@@ -102,6 +102,36 @@ impl EigenvalueSolver {
|
||||
let x = solve_lower(&l, &shifted)?;
|
||||
let b = solve_lower(&l, &x.transpose())?.transpose();
|
||||
|
||||
// Reject a rank-deficient problem explicitly.
|
||||
//
|
||||
// `try_inverse` does not fail on a matrix that is singular only to
|
||||
// working precision, and an unconstrained structure is exactly that
|
||||
// case: its rigid-body modes are zero eigenvalues that round to
|
||||
// ~1e-16 of the largest. Inverting anyway succeeds and returns
|
||||
// enormous, meaningless entries, which emerge as a handful of
|
||||
// near-zero frequencies that look like real low-frequency modes.
|
||||
// Better to say what is wrong and name the remedy.
|
||||
//
|
||||
// Only meaningful without a shift: `K - σM` is deliberately
|
||||
// indefinite when a shift is used to bracket interior modes.
|
||||
if self.shift.is_none() {
|
||||
let spectrum = b.clone().symmetric_eigenvalues();
|
||||
let largest = spectrum.iter().fold(0.0_f64, |acc, v| acc.max(v.abs()));
|
||||
let smallest = spectrum
|
||||
.iter()
|
||||
.fold(f64::INFINITY, |acc: f64, v| acc.min(v.abs()));
|
||||
|
||||
if largest <= 0.0 || smallest / largest < 1e-12 {
|
||||
return Err(SolverError::SolveError {
|
||||
reason: "stiffness matrix is singular — an unconstrained structure has \
|
||||
rigid-body modes at zero frequency. Constrain it, or pass an \
|
||||
explicit shift via `with_shift` to compute the modes around one."
|
||||
.to_string(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user