rtx-fea: mor gains total-Lagrangian operators and a held-out ECSW residual
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

The mor scope was small-strain only ("geometrically linear") — a basis
trained on total-Lagrangian trajectories (the FSI flag marches
with_total_lagrangian) sampled through small-strain operators would
conserve the virtual work of the wrong force. ElementOperator now
carries a Formulation (SmallStrain | TotalLagrangian), the TL branch
mirroring NonlinearDynamicAnalysis exactly (SVK from Lame parameters,
total_lagrangian::internal_force_and_tangent); train_ecsw /
ReducedNonlinearModel::new keep their behavior and delegate, with
_formulated variants added. ecsw_residual evaluates a trained model's
||Cw - b||/||b|| on arbitrary snapshots — the held-out generalization
measurement; on the training set it reproduces training_residual to
1e-12 (pinned).

Verified sharply: identity-basis reduced TL solve vs a
tight-tolerance full TL solve agrees to 4.4e-15 (machine precision)
while small-strain operators land 1.1e-2 away at the same load — the
switch is exercised and exact. (At the default 1e-6 convergence
criteria the reference itself stops 1.7e-4 short; measured and
recorded in the test comment.)

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01X2GmJXeQ2njUecEKiJZ1G2
This commit is contained in:
Omar Sobh
2026-08-29 15:01:09 -05:00
co-authored by Claude Fable 5
parent 9fe9d7f74a
commit d9d8801f1a
4 changed files with 284 additions and 32 deletions
+84 -16
View File
@@ -20,13 +20,36 @@
//! negative weight would let a sampled element *produce* energy.
use super::nnls::nnls;
use super::reduced::ElementOperator;
use super::reduced::{ElementOperator, Formulation};
use crate::assembly::dof_mapping::AdvancedDofNumbering;
use crate::error::{FeaError, FeaResult};
use crate::materials::MaterialDatabase;
use crate::mesh::{ElementId, Mesh};
use nalgebra::{DMatrix, DVector};
/// Assemble the ECSW system `C`, `b` over the given snapshots.
fn assemble_system(
mesh: &Mesh,
materials: &MaterialDatabase,
operators: &[ElementOperator],
snapshots: &[DVector<f64>],
modes: usize,
) -> FeaResult<(DMatrix<f64>, DVector<f64>)> {
let mut c = DMatrix::zeros(snapshots.len() * modes, operators.len());
let mut b = DVector::zeros(snapshots.len() * modes);
for (s, snapshot) in snapshots.iter().enumerate() {
for (e, operator) in operators.iter().enumerate() {
let reduced_force =
operator.reduced_internal_force(materials, mesh.spatial_dimension, snapshot)?;
for m in 0..modes {
c[(s * modes + m, e)] = reduced_force[m];
b[s * modes + m] += reduced_force[m];
}
}
}
Ok((c, b))
}
/// The trained element sample: which elements carry the reduced internal
/// force, and with what weights.
#[derive(Debug, Clone)]
@@ -48,6 +71,28 @@ pub fn train_ecsw(
basis: &DMatrix<f64>,
snapshots: &[DVector<f64>],
tolerance: f64,
) -> FeaResult<EcswModel> {
train_ecsw_formulated(
mesh,
materials,
dof_numbering,
basis,
snapshots,
tolerance,
Formulation::SmallStrain,
)
}
/// [`train_ecsw`] with an explicit internal-force formulation — must match
/// the full-order analysis that produced the snapshots.
pub fn train_ecsw_formulated(
mesh: &Mesh,
materials: &MaterialDatabase,
dof_numbering: &AdvancedDofNumbering,
basis: &DMatrix<f64>,
snapshots: &[DVector<f64>],
tolerance: f64,
formulation: Formulation,
) -> FeaResult<EcswModel> {
if snapshots.is_empty() {
return Err(FeaError::InvalidInput(
@@ -55,22 +100,9 @@ pub fn train_ecsw(
));
}
let modes = basis.ncols();
let operators = ElementOperator::build_all(mesh, materials, dof_numbering, basis)?;
let num_elements = operators.len();
let operators = ElementOperator::build_all(mesh, materials, dof_numbering, basis, formulation)?;
let mut c = DMatrix::zeros(snapshots.len() * modes, num_elements);
let mut b = DVector::zeros(snapshots.len() * modes);
for (s, snapshot) in snapshots.iter().enumerate() {
for (e, operator) in operators.iter().enumerate() {
let reduced_force =
operator.reduced_internal_force(materials, mesh.spatial_dimension, snapshot)?;
for m in 0..modes {
c[(s * modes + m, e)] = reduced_force[m];
b[s * modes + m] += reduced_force[m];
}
}
}
let (c, b) = assemble_system(mesh, materials, &operators, snapshots, modes)?;
let scale = b.norm();
let weights = nnls(&c, &b, tolerance * scale)?;
@@ -92,3 +124,39 @@ pub fn train_ecsw(
training_residual,
})
}
/// Evaluate a trained model's sampled-force residual `||C w b|| / ||b||`
/// on an arbitrary snapshot set — the held-out generalization measurement:
/// train on one split, call this on the other. On the training set itself
/// it reproduces [`EcswModel::training_residual`].
pub fn ecsw_residual(
mesh: &Mesh,
materials: &MaterialDatabase,
dof_numbering: &AdvancedDofNumbering,
basis: &DMatrix<f64>,
snapshots: &[DVector<f64>],
model: &EcswModel,
formulation: Formulation,
) -> FeaResult<f64> {
if snapshots.is_empty() {
return Err(FeaError::InvalidInput(
"residual evaluation needs at least one snapshot".to_string(),
));
}
let modes = basis.ncols();
let operators = ElementOperator::build_all(mesh, materials, dof_numbering, basis, formulation)?;
let (c, b) = assemble_system(mesh, materials, &operators, snapshots, modes)?;
let mut weights = DVector::zeros(operators.len());
for &(element_id, weight) in &model.weights {
if let Some(index) = operators.iter().position(|op| op.element_id == element_id) {
weights[index] = weight;
}
}
let scale = b.norm();
Ok(if scale > 0.0 {
(&c * &weights - &b).norm() / scale
} else {
0.0
})
}
+9 -6
View File
@@ -13,17 +13,20 @@
//! 4. [`reduced::ReducedNonlinearModel`] — Newton in reduced coordinates,
//! assembling only the sampled elements.
//!
//! Scope: geometrically linear, materially nonlinear (what the nonlinear
//! analysis supports), homogeneous Dirichlet data. Lifting for
//! inhomogeneous boundary values is not implemented and the basis is over
//! the free DOFs only.
//! Scope: homogeneous Dirichlet data; the basis is over the free DOFs
//! only (lifting for inhomogeneous boundary values is not implemented).
//! Kinematics are selected per [`reduced::Formulation`]: the original
//! small-strain scope (geometrically linear, materially nonlinear), or
//! total-Lagrangian Saint VenantKirchhoff matching
//! `NonlinearDynamicAnalysis::with_total_lagrangian` — required when the
//! snapshots come from a total-Lagrangian trajectory (the FSI flag).
pub mod ecsw;
pub mod nnls;
pub mod pod;
pub mod reduced;
pub use ecsw::{EcswModel, train_ecsw};
pub use ecsw::{EcswModel, ecsw_residual, train_ecsw, train_ecsw_formulated};
pub use nnls::nnls;
pub use pod::pod_basis;
pub use reduced::ReducedNonlinearModel;
pub use reduced::{Formulation, ReducedNonlinearModel};
+67 -10
View File
@@ -4,12 +4,30 @@
use super::ecsw::EcswModel;
use crate::assembly::dof_mapping::AdvancedDofNumbering;
use crate::elements::total_lagrangian::{self, saint_venant_kirchhoff};
use crate::elements::{ElementMatrixComputer, StandardFiniteElement};
use crate::error::{AnalysisError, FeaError, FeaResult};
use crate::materials::MaterialDatabase;
use crate::mesh::{ElementId, Mesh};
use nalgebra::{DMatrix, DVector, Vector3};
/// Which internal-force kinematics the reduced operators assemble.
///
/// Must match the full-order analysis that produced the snapshots: a
/// basis trained on total-Lagrangian trajectories (e.g. the FSI flag,
/// which marches `with_total_lagrangian`) sampled through small-strain
/// operators would conserve the virtual work of the WRONG force. The
/// small-strain variant is the original scope ("geometrically linear,
/// materially nonlinear"); the total-Lagrangian variant mirrors
/// `NonlinearDynamicAnalysis`'s branch exactly (GreenLagrange strain,
/// Saint VenantKirchhoff constitutive from the material's Lamé
/// parameters, geometric tangent included).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Formulation {
SmallStrain,
TotalLagrangian,
}
/// One element's contribution to the reduced model: its finite element, its
/// gather map into the free-DOF vector, and its slice of the basis.
pub(crate) struct ElementOperator {
@@ -24,6 +42,7 @@ pub(crate) struct ElementOperator {
/// rows for constrained DOFs.
local_basis: DMatrix<f64>,
material_index: crate::mesh::MaterialId,
formulation: Formulation,
}
impl ElementOperator {
@@ -32,6 +51,7 @@ impl ElementOperator {
materials: &MaterialDatabase,
dof_numbering: &AdvancedDofNumbering,
basis: &DMatrix<f64>,
formulation: Formulation,
) -> FeaResult<Vec<Self>> {
let modes = basis.ncols();
let total = dof_numbering.total_dofs;
@@ -89,6 +109,7 @@ impl ElementOperator {
free_positions,
local_basis,
material_index: element.material_id,
formulation,
});
}
Ok(operators)
@@ -113,14 +134,30 @@ impl ElementOperator {
let material = materials
.get_material(self.material_index)
.expect("checked at build time");
let constitutive = crate::materials::reduced_constitutive(material, spatial_dim)?;
ElementMatrixComputer::compute_internal_force_and_tangent(
&self.finite_element,
&self.node_coords,
local_displacement,
constitutive.as_ref(),
None,
)
match self.formulation {
Formulation::SmallStrain => {
let constitutive = crate::materials::reduced_constitutive(material, spatial_dim)?;
ElementMatrixComputer::compute_internal_force_and_tangent(
&self.finite_element,
&self.node_coords,
local_displacement,
constitutive.as_ref(),
None,
)
}
Formulation::TotalLagrangian => {
// Mirrors NonlinearDynamicAnalysis's total-Lagrangian branch.
let (lambda, mu) = material.properties().lame_parameters();
let constitutive = saint_venant_kirchhoff(lambda, mu, spatial_dim);
total_lagrangian::internal_force_and_tangent(
&self.finite_element,
&self.node_coords,
local_displacement,
constitutive.as_ref(),
None,
)
}
}
}
/// `V_e' f_e(u)` for a full free-DOF displacement — used by training.
@@ -148,14 +185,34 @@ pub struct ReducedNonlinearModel<'a> {
}
impl<'a> ReducedNonlinearModel<'a> {
/// Build a POD-Galerkin model: every element, weight 1.
/// Build a POD-Galerkin model: every element, weight 1 (small-strain
/// operators — the original scope).
pub fn new(
mesh: &'a Mesh,
materials: &'a MaterialDatabase,
dof_numbering: &AdvancedDofNumbering,
basis: DMatrix<f64>,
) -> FeaResult<Self> {
let operators = ElementOperator::build_all(mesh, materials, dof_numbering, &basis)?;
Self::new_formulated(
mesh,
materials,
dof_numbering,
basis,
Formulation::SmallStrain,
)
}
/// [`Self::new`] with an explicit internal-force formulation — must
/// match the full-order analysis that produced the training snapshots.
pub fn new_formulated(
mesh: &'a Mesh,
materials: &'a MaterialDatabase,
dof_numbering: &AdvancedDofNumbering,
basis: DMatrix<f64>,
formulation: Formulation,
) -> FeaResult<Self> {
let operators =
ElementOperator::build_all(mesh, materials, dof_numbering, &basis, formulation)?;
let active = (0..operators.len()).map(|e| (e, 1.0)).collect();
Ok(Self {
mesh,