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:
Omar Sobh
2026-08-19 08:10:57 -07:00
co-authored by Claude Opus 5
parent cca29aac8f
commit 4c2cea36aa
9 changed files with 1065 additions and 53 deletions
+18 -10
View File
@@ -24,6 +24,7 @@ use cudarc::driver::safe::CudaContext;
use nalgebra::{DMatrix, Vector3, Vector6};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
pub use composite::*;
pub use damage::*;
@@ -333,7 +334,7 @@ pub trait Material: Send + Sync {
/// Material database for managing multiple materials.
pub struct MaterialDatabase {
/// Materials storage
materials: HashMap<crate::mesh::MaterialId, Box<dyn Material>>,
materials: HashMap<crate::mesh::MaterialId, Arc<dyn Material>>,
/// Material names
names: HashMap<crate::mesh::MaterialId, String>,
}
@@ -364,7 +365,7 @@ impl MaterialDatabase {
name: Option<String>,
) {
let name = name.unwrap_or_else(|| format!("Material_{}", id.as_usize()));
self.materials.insert(id, Box::new(material));
self.materials.insert(id, Arc::new(material));
self.names.insert(id, name);
}
@@ -403,16 +404,23 @@ impl MaterialDatabase {
}
impl Clone for MaterialDatabase {
/// Clone the database, sharing the materials themselves.
///
/// Materials are immutable once registered, so the `Arc` handles can be
/// shared rather than deep-copied and no `clone_box` on the trait object
/// is needed.
///
/// This previously cloned **names only** and silently dropped every
/// material, on the grounds that `Box<dyn Material>` cannot be cloned.
/// Because `GlobalAssembler` is constructed with `materials.clone()`,
/// that meant every assembler ever built received an empty database and
/// every analysis failed with `MaterialNotFound` — for a mesh that was
/// correctly specified.
fn clone(&self) -> Self {
// For now, create an empty database with same names
// Material cloning would require dyn trait object cloning support
let mut new_db = Self::new();
for (id, name) in &self.names {
new_db.names.insert(*id, name.clone());
Self {
materials: self.materials.clone(),
names: self.names.clone(),
}
// Note: materials are not cloned, this is a shallow clone for names only
// Full implementation would require Material trait to have clone_box method
new_db
}
}