rtx-fea: wire NonlinearStaticAnalysis — Newton on the consistent tangent, MMS-verified at second order
Documentation / Build API Documentation (push) Canceled after 0s
Documentation / Build User Guide (push) Canceled after 0s
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

NonlinearStaticAnalysis::run returned DVector::zeros unconditionally, like
ModalAnalysis and DynamicAnalysis before their repair. It is now full
Newton-Raphson on R(u) = f_ext - f_int(u):

- ElementMatrixComputer::compute_internal_force_and_tangent integrates
  f_int = int(B' sigma dV) and K_T = int(B' D_T B dV) in ONE quadrature
  sweep from a constitutive closure in the element's reduced Voigt space —
  computing both together is what keeps the tangent consistent with the
  stress, which is what quadratic convergence rides on.
- materials::reduced_constitutive bridges the Material trait (Voigt-6) to
  that closure: 3-D passes the total strain straight through; 2-D supports
  the linear plane-stress closed form and refuses nonlinear materials
  explicitly, since plane-stress condensation of a general law needs a
  per-point iteration that is not implemented yet.
- Dirichlet DOFs are held at their (load-scaled) values and Newton runs on
  the free DOFs, so the prescribed motion enters through f_int itself — no
  K_fc bookkeeping to get wrong. Body force enters via set_body_force, the
  same hook pattern the CFD solvers use for manufactured solutions. Uniform
  load stepping; other strategies and quasi-Newton refuse explicitly.
- StandardFiniteElement::compute_internal_forces, previously a zeros stub,
  now delegates to the same machinery.
- Mesh::validate is now called in run() (the old TODO), and NonlinearConfig
  gained a Default.

Verified two ways (tests/nonlinear_static.rs):

- Equivalence: with LinearElastic the loop lands on the directly assembled
  linear solution to 1e-10 in exactly one Newton step — same B, quadrature
  and solver, so any disagreement is the nonlinear assembly.
- Manufactured solution with a genuinely nonlinear material (energy
  W = 1/2 e'De + alpha/3 I1^3, so stress and tangent are exact derivatives;
  body force by central differences of the closed-form stress): L2 errors
  6.032e-2, 1.780e-2, 4.595e-3 on 2/4/8 Hex8 — observed orders 1.76 and
  1.95, climbing to the theoretical 2. The forcing contains the nonlinear
  term, so the order is reachable only if it is solved; an inconsistent
  tangent is caught separately by the iteration-count bound.

This unblocks ECSW model-order reduction, which needs a working nonlinear
solve underneath it. 551 rtx-fea tests, 0 failing.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-19 19:44:08 -07:00
co-authored by Claude Fable 5
parent e94ad1be6b
commit 6510045b5d
6 changed files with 1019 additions and 23 deletions
@@ -272,6 +272,75 @@ impl MaterialResponse {
}
/// Base trait for all material models.
/// Constitutive closure in an element's reduced Voigt space, for the
/// nonlinear assembly path (`ElementMatrixComputer::
/// compute_internal_force_and_tangent`).
///
/// In 3-D the reduced space *is* the material's full Voigt-6 space, so the
/// closure passes the total strain straight to
/// [`Material::compute_response`] (from a virgin state — the analyses using
/// this are path-independent for now) and hands back its stress and
/// consistent tangent.
///
/// In 2-D the element works in plane stress with 3 strain components, and
/// condensing a *general* nonlinear material to plane stress requires a
/// per-point iteration on the out-of-plane strain that is not implemented
/// yet. A linear material needs no iteration — its plane-stress matrix is
/// closed-form from `(E, nu)` — so that case is supported and anything else
/// is an explicit error rather than silently wrong physics.
#[allow(clippy::type_complexity)]
pub fn reduced_constitutive(
material: &dyn Material,
spatial_dim: usize,
) -> FeaResult<
Box<dyn Fn(&nalgebra::DVector<f64>) -> FeaResult<(nalgebra::DVector<f64>, DMatrix<f64>)> + '_>,
> {
match spatial_dim {
3 => {
let state = material.initialize_state(0.0);
Ok(Box::new(move |strain: &nalgebra::DVector<f64>| {
let strain6 = Vector6::from_iterator(strain.iter().copied());
let response = material.compute_response(&strain6, &state, 0.0)?;
if !response.is_valid {
return Err(MaterialError::StateUpdateFailed {
reason: "material reported an invalid response".to_string(),
}
.into());
}
let stress = nalgebra::DVector::from_iterator(6, response.stress.iter().copied());
Ok((stress, response.tangent_matrix))
}))
}
2 => {
if !material.is_linear() {
return Err(MaterialError::UnsupportedModel {
model: format!(
"plane-stress reduction of nonlinear material '{}'",
material.material_type()
),
}
.into());
}
let properties = material.properties();
let (e, nu) = (properties.elastic_modulus, properties.poisson_ratio);
let factor = e / (1.0 - nu * nu);
let mut d = DMatrix::zeros(3, 3);
d[(0, 0)] = factor;
d[(1, 1)] = factor;
d[(0, 1)] = factor * nu;
d[(1, 0)] = factor * nu;
d[(2, 2)] = factor * (1.0 - nu) / 2.0;
Ok(Box::new(move |strain: &nalgebra::DVector<f64>| {
Ok((&d * strain, d.clone()))
}))
}
_ => Err(MaterialError::UnsupportedModel {
model: format!("{spatial_dim}-D constitutive reduction"),
}
.into()),
}
}
pub trait Material: Send + Sync {
/// Get material properties.
fn properties(&self) -> &MaterialProperties;