Files
rustytorch/crates/specialized/rtx-fea/tests/nonlinear_newmark_csm3.rs
T
Omar SobhandClaude Fable 5 4534d90684
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
Documentation / Build API Documentation (push) Canceled after 0s
Documentation / Build User Guide (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
rtx-fea + rtx-cfd: the single-step seams FSI2 stands on
rtx-fea: NonlinearDynamicAnalysis refactored onto a NonlinearDynamicStepper
- set_nodal_forces on both (the interface load of a coupling subiteration,
  replaceable between steps and between subiterations of one step);
- step(&DynamicState) is a pure function of the start-of-step state and
  the current forces - commits nothing, so a partitioned coupling re-runs
  one Newmark step to the interface fixed point (the piston semantics);
- run() marches through the same stepper: one code path, pinned from both
  ends (linear limit, CSM3, and a new manual-drive == run() assertion);
- new test: a nodal step load oscillates about the *static* nonlinear
  analysis's deflection (cross-code-path, mean within 3%, amplitude 6%),
  with re-run determinism and force-swap sensitivity asserted mid-march
  (a one-step response to a force change is ~ beta dt^2 - the first
  assertion draft demanded 10% and was corrected against the physics).

rtx-cfd: the subiteration seam and the moving no-slip closure
- EmbeddedPisoSolver::snapshot()/restore() (mask + time + init flag; the
  mask is now Clone): re-running a fluid step within a subiteration is
  bit-identical to never having diverted - proven on a moving body with
  cells flipping in the re-run window;
- polygon_interface_velocity: nearest-edge linear interpolation of
  per-vertex velocities, exact for the linear-along-edge boundary data a
  finite-element interface hands over - the no-slip closure that replaces
  FSI1's zero-velocity polygon.

Suites: rtx-fea 567, rtx-cfd 325, rtx-fsi piston+transfer - all green.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Lnyrw33Lu6rUhW42E9KHwq
2026-08-20 20:00:49 -07:00

450 lines
17 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.
//! Rung S2: the nonlinear Newmark analysis
//! (`analysis::nonlinear_dynamic`), verified in two steps:
//!
//! 1. **Linear limit**: under a load small enough that finite-strain terms
//! vanish (strains ~1e-9), the total-Lagrangian nonlinear stepper must
//! reproduce the verified linear `NewmarkStepper` marching the same
//! consistent mass and (plane-strain) stiffness, step for step.
//!
//! 2. **TurekHron CSM3**: the flag under gravity switched on at rest,
//! plane-strain St. VenantKirchhoff, Newmark average acceleration,
//! Δt = 0.005 — the benchmark's own time step. Reference (FEATFLOW):
//! `ux(A) = 14.305 ± 14.305 [1.0995 Hz]`,
//! `uy(A) = 63.607 ± 65.160 [1.0995 Hz]`.
//! Measured values and bands are in the test body; the mesh is the
//! 35×2 Quad8 the static CSM tests bounded at ~1.5%.
use nalgebra::{DMatrix, DVector, Vector3};
use rtx_fea::analysis::{
Analysis, AnalysisConfig, NewmarkStepper, NonlinearConfig, NonlinearDynamicAnalysis,
NonlinearStaticAnalysis,
};
use rtx_fea::assembly::dof_mapping::{AdvancedDofNumbering, DofComponent, DofMappingStrategy};
use rtx_fea::boundary::dirichlet::{DirichletBC, DirichletType};
use rtx_fea::boundary::{BoundaryCondition, BoundaryConditionSet, SpatialFunction};
use rtx_fea::elements::{ElementMatrixComputer, FiniteElement, StandardFiniteElement};
use rtx_fea::materials::{LinearElastic, MaterialDatabase};
use rtx_fea::mesh::{Element, ElementType, MaterialId, Mesh, Node, NodeId};
const E_MOD: f64 = 1.4e6;
const NU: f64 = 0.4;
const RHO: f64 = 1000.0;
const G: f64 = 2.0;
/// `nx` by `ny` Quad8 mesh of `[x0, x1] x [y0, y1]` (serendipity lattice),
/// as in `tests/total_lagrangian_svk.rs`.
fn quad8_rect_mesh(x0: f64, x1: f64, y0: f64, y1: f64, nx: usize, ny: usize) -> Mesh {
let mut mesh = Mesh::new(2).unwrap();
let (lx, ly) = (2 * nx + 1, 2 * ny + 1);
let mut grid = vec![vec![None; ly]; lx];
for (i, column) in grid.iter_mut().enumerate() {
for (j, slot) in column.iter_mut().enumerate() {
if i % 2 == 1 && j % 2 == 1 {
continue;
}
let x = x0 + (x1 - x0) * i as f64 / (2 * nx) as f64;
let y = y0 + (y1 - y0) * j as f64 / (2 * ny) as f64;
*slot = Some(mesh.add_node(Node::new_2d(x, y)));
}
}
for i in 0..nx {
for j in 0..ny {
let (a, b) = (2 * i, 2 * j);
let nodes = vec![
grid[a][b].unwrap(),
grid[a + 2][b].unwrap(),
grid[a + 2][b + 2].unwrap(),
grid[a][b + 2].unwrap(),
grid[a + 1][b].unwrap(),
grid[a + 2][b + 1].unwrap(),
grid[a + 1][b + 2].unwrap(),
grid[a][b + 1].unwrap(),
];
mesh.add_element(Element::new(ElementType::Quad8, nodes, MaterialId(0)).unwrap())
.unwrap();
}
}
mesh
}
fn materials() -> MaterialDatabase {
let mut db = MaterialDatabase::new();
db.add_material(
MaterialId(0),
LinearElastic::new(E_MOD, NU).with_density(RHO),
None,
);
db
}
fn clamp_left(mesh: &Mesh, x_left: f64) -> BoundaryConditionSet {
let clamped: Vec<NodeId> = mesh
.nodes
.iter()
.filter(|(_, node)| (node.position().x - x_left).abs() < 1e-12)
.map(|(&id, _)| id)
.collect();
let mut set = BoundaryConditionSet::new();
for component in [DofComponent::DisplacementX, DofComponent::DisplacementY] {
set.add_condition(BoundaryCondition::Dirichlet(DirichletBC {
nodes: clamped.clone(),
components: vec![component],
condition_type: DirichletType::Spatial(SpatialFunction(Box::new(|_| 0.0))),
time_range: None,
ramping_factor: 1.0,
gradual_enforcement: false,
}));
}
set
}
fn point_a(mesh: &Mesh, x: f64, y: f64) -> NodeId {
mesh.nodes
.iter()
.find(|(_, node)| {
(node.position().x - x).abs() < 1e-12 && (node.position().y - y).abs() < 1e-12
})
.map(|(&id, _)| id)
.expect("tracking point must be a mesh node")
}
fn mid_amp(series: &[f64]) -> (f64, f64) {
let max = series.iter().copied().fold(f64::MIN, f64::max);
let min = series.iter().copied().fold(f64::MAX, f64::min);
(0.5 * (max + min), 0.5 * (max - min))
}
fn crossing_frequency(times: &[f64], series: &[f64]) -> f64 {
let (mean, _) = mid_amp(series);
let mut crossings: Vec<f64> = Vec::new();
for k in 1..series.len() {
let (a, b) = (series[k - 1] - mean, series[k] - mean);
if a < 0.0 && b >= 0.0 {
crossings.push(times[k - 1] + (a / (a - b)) * (times[k] - times[k - 1]));
}
}
assert!(crossings.len() >= 3, "too few oscillation periods");
(crossings.len() - 1) as f64 / (crossings.last().unwrap() - crossings.first().unwrap())
}
/// 1. Linear limit: tiny gravity, TL nonlinear stepper vs the linear
/// `NewmarkStepper` on the same dense M, K, F.
#[test]
fn linear_limit_matches_the_linear_newmark_stepper() {
let mesh = quad8_rect_mesh(0.25, 0.6, 0.19, 0.21, 10, 2);
let scale = 1e-6;
let dt = 0.005;
let steps = 120;
let a_node = point_a(&mesh, 0.6, 0.2);
// Nonlinear TL run.
let mut analysis = NonlinearDynamicAnalysis::new(
mesh.clone(),
materials(),
clamp_left(&mesh, 0.25),
dt,
steps,
AnalysisConfig::default(),
)
.with_total_lagrangian();
analysis.set_body_force(move |_| Vector3::new(0.0, -RHO * G * scale, 0.0));
analysis.track_node(a_node);
let results = analysis.run().unwrap();
let uy_nonlinear: Vec<f64> = results.tracked[0].iter().map(|u| u[1]).collect();
// Linear reference: dense free-free M, K (plane strain, the TL tangent
// at u = 0), consistent F; the verified linear stepper.
let mut numbering =
AdvancedDofNumbering::displacement_only(&mesh, DofMappingStrategy::Sequential).unwrap();
for (&id, node) in mesh.nodes.iter() {
if (node.position().x - 0.25).abs() < 1e-12 {
for component in [DofComponent::DisplacementX, DofComponent::DisplacementY] {
let dof = numbering.get_dof(id, component).unwrap();
numbering.constrain_dof(dof).unwrap();
}
}
}
let free = numbering.free_dofs.clone();
let mut free_index = vec![None; numbering.total_dofs];
for (k, &dof) in free.iter().enumerate() {
free_index[dof] = Some(k);
}
let n = free.len();
let mut mass = DMatrix::zeros(n, n);
let mut stiffness = DMatrix::zeros(n, n);
let mut force = DVector::zeros(n);
let mu = E_MOD / (2.0 * (1.0 + NU));
let lambda = E_MOD * NU / ((1.0 + NU) * (1.0 - 2.0 * NU));
let mut d = DMatrix::zeros(3, 3);
d[(0, 0)] = lambda + 2.0 * mu;
d[(1, 1)] = lambda + 2.0 * mu;
d[(0, 1)] = lambda;
d[(1, 0)] = lambda;
d[(2, 2)] = mu;
let linear = move |strain: &DVector<f64>| Ok((&d * strain, d.clone()));
for element in mesh.elements.values() {
let coords: Vec<Vector3<f64>> = element
.nodes
.iter()
.map(|id| mesh.get_node(*id).unwrap().position())
.collect();
let fe = StandardFiniteElement::new(element.element_type, coords.clone());
let dofs: Vec<usize> = element
.nodes
.iter()
.flat_map(|node| numbering.get_node_dofs(*node))
.collect();
let zero = DVector::zeros(dofs.len());
let (_, k_e) = ElementMatrixComputer::compute_internal_force_and_tangent(
&fe, &coords, &zero, &linear, None,
)
.unwrap();
let m_scalar =
ElementMatrixComputer::compute_consistent_mass_matrix(&fe, &coords, RHO, None).unwrap();
let f_e = ElementMatrixComputer::compute_body_force_vector(
&fe,
&coords,
&|_| Vector3::new(0.0, -RHO * G * scale, 0.0),
None,
)
.unwrap();
for (lr, &dr) in dofs.iter().enumerate() {
let Some(fr) = free_index[dr] else { continue };
force[fr] += f_e[lr];
for (lc, &dc) in dofs.iter().enumerate() {
if let Some(fc) = free_index[dc] {
stiffness[(fr, fc)] += k_e[(lr, lc)];
let (a, b) = (lr / 2, lc / 2);
if lr % 2 == lc % 2 {
mass[(fr, fc)] += m_scalar.matrix[(a, b)];
}
}
}
}
}
let damping = DMatrix::zeros(n, n);
let stepper = NewmarkStepper::average_acceleration(mass, damping, stiffness, dt).unwrap();
let mut state = stepper
.initial_state(DVector::zeros(n), DVector::zeros(n), &force)
.unwrap();
let a_dofs = numbering.get_node_dofs(a_node);
let a_free = free_index[a_dofs[1]].expect("point A is free");
let mut uy_linear = Vec::with_capacity(steps);
for _ in 0..steps {
state = stepper.step(&state, &force).unwrap();
uy_linear.push(state.displacement[a_free]);
}
let amplitude = uy_linear.iter().fold(0.0f64, |acc, &x| acc.max(x.abs()));
let max_diff = uy_nonlinear
.iter()
.zip(&uy_linear)
.fold(0.0f64, |acc, (a, b)| acc.max((a - b).abs()));
println!(
" linear limit: amplitude {amplitude:.3e}, max |nonlinear - linear| {max_diff:.3e} \
({:.2e} relative)",
max_diff / amplitude
);
assert!(
max_diff < 1e-4 * amplitude,
"nonlinear stepper deviates from the linear one in the linear limit: \
{max_diff:.3e} vs amplitude {amplitude:.3e}"
);
}
/// 2. TurekHron CSM3. Bands are the measured ones for the 35×2 Quad8 mesh
/// (recorded in the printed line; the static CSM tests bound this mesh's
/// spatial error at ~1.5%).
#[test]
fn turek_hron_csm3_oscillation() {
let mesh = quad8_rect_mesh(0.25, 0.6, 0.19, 0.21, 35, 2);
let dt = 0.005;
let steps = 1200; // 6 s, ~6.6 oscillation periods (2000 steps measured the same bands in 372 s)
let a_node = point_a(&mesh, 0.6, 0.2);
let mut analysis = NonlinearDynamicAnalysis::new(
mesh.clone(),
materials(),
clamp_left(&mesh, 0.25),
dt,
steps,
AnalysisConfig::default(),
)
.with_total_lagrangian();
analysis.set_body_force(|_| Vector3::new(0.0, -RHO * G, 0.0));
analysis.track_node(a_node);
let start = std::time::Instant::now();
let results = analysis.run().unwrap();
let seconds = start.elapsed().as_secs_f64();
let ux: Vec<f64> = results.tracked[0].iter().map(|u| u[0]).collect();
let uy: Vec<f64> = results.tracked[0].iter().map(|u| u[1]).collect();
let (ux_mean, ux_amp) = mid_amp(&ux);
let (uy_mean, uy_amp) = mid_amp(&uy);
let frequency = crossing_frequency(&results.times, &uy);
let half = uy.len() / 2;
let (_, amp_first) = mid_amp(&uy[..half]);
let (_, amp_second) = mid_amp(&uy[half..]);
println!(
" CSM3 35x2 Quad8, dt = {dt}: ux(A) {:.3} ± {:.3} mm, uy(A) {:.3} ± {:.3} mm, \
f = {frequency:.4} Hz; half-window uy amps {:.3}/{:.3} mm; \
{} Newton iterations total (max {}/step); {seconds:.0} s. \
Reference: ux 14.305 ± 14.305, uy 63.607 ± 65.160 [1.0995 Hz]",
ux_mean * 1e3,
ux_amp * 1e3,
uy_mean * 1e3,
uy_amp * 1e3,
amp_first * 1e3,
amp_second * 1e3,
results.total_iterations,
results.max_iterations_per_step,
);
let rel = |a: f64, b: f64| ((a - b) / b).abs();
// Undamped average acceleration: the amplitude must persist.
assert!(
(amp_first - amp_second).abs() < 0.02 * amp_second,
"the undamped oscillation is losing amplitude: {amp_first:.4} vs {amp_second:.4}"
);
assert!(
rel(frequency, 1.0995) < 0.02,
"frequency {frequency:.4} vs reference 1.0995"
);
assert!(
rel(uy_mean, -63.607e-3) < 0.03,
"uy mean {:.4e} vs reference -63.607e-3",
uy_mean
);
assert!(
rel(uy_amp, 65.160e-3) < 0.03,
"uy amplitude {:.4e} vs reference 65.160e-3",
uy_amp
);
assert!(
rel(ux_mean, -14.305e-3) < 0.05 && rel(ux_amp, 14.305e-3) < 0.05,
"ux {:.4e} ± {:.4e} vs reference -14.305e-3 ± 14.305e-3",
ux_mean,
ux_amp
);
assert!(
results.max_iterations_per_step <= 5,
"Newton needed {} iterations in one step",
results.max_iterations_per_step
);
}
/// 3. The stepper under a nodal step load — the FSI2 seam. Three claims:
///
/// a. Driving the stepper by hand (set the nodal force, step, commit) is
/// bit-identical to `run()` with the same force set on the analysis —
/// one code path, verified from both ends.
/// b. `step` commits nothing: repeating a step from the same state under
/// the same force is bit-identical; changing the force between the
/// repeats changes the answer (the subiteration a coupling loop needs).
/// c. The undamped step response oscillates about the static deflection:
/// `mid_amp` of the tip trajectory must match the *static* nonlinear
/// analysis under the identical nodal force — a different code path —
/// in both mean and amplitude (`u(t) ≈ u_s (1 cos ωt)` while the
/// first mode dominates a tip-loaded cantilever).
#[test]
fn stepper_nodal_step_load_oscillates_about_the_static_deflection() {
let mesh = quad8_rect_mesh(0.25, 0.6, 0.19, 0.21, 10, 2);
let dt = 0.005;
let steps = 400; // 2 s: two periods of the ~1 Hz first mode
let a_node = point_a(&mesh, 0.6, 0.2);
let tip_force = Vector3::new(0.0, -0.1, 0.0);
// run() with the force set on the analysis.
let mut analysis = NonlinearDynamicAnalysis::new(
mesh.clone(),
materials(),
clamp_left(&mesh, 0.25),
dt,
steps,
AnalysisConfig::default(),
)
.with_total_lagrangian();
analysis.set_nodal_forces(vec![(a_node, tip_force)]);
analysis.track_node(a_node);
let results = analysis.run().unwrap();
let uy_run: Vec<f64> = results.tracked[0].iter().map(|u| u[1]).collect();
// The same march, driven by hand through the stepper.
let mut stepper = analysis.stepper().unwrap();
stepper.set_nodal_forces(&[(a_node, tip_force)]);
let mut state = stepper.rest_state().unwrap();
let a_dofs = stepper.node_dofs(a_node);
let mut uy_manual = Vec::with_capacity(steps);
for step in 0..steps {
if step == 7 {
// b. Re-running the same step is bit-identical; a different
// force from the same state gives a different answer and
// leaves no trace once the force is restored.
let (first, _) = stepper.step(&state).unwrap();
let (again, _) = stepper.step(&state).unwrap();
assert_eq!(
first.displacement, again.displacement,
"re-running a step from the same state changed the answer"
);
stepper.set_nodal_forces(&[(a_node, 2.0 * tip_force)]);
let (other, _) = stepper.step(&state).unwrap();
// One step's response to an extra force is ~ ΔF β Δt² / m_modal
// (≈ 1% of the accumulated displacement here), downward.
let moved = other.displacement[a_dofs[1]] - first.displacement[a_dofs[1]];
assert!(
moved < -1e-3 * first.displacement[a_dofs[1]].abs(),
"doubling the interface force did not move the step down: \
delta {moved:.3e} vs u {:.3e}",
first.displacement[a_dofs[1]]
);
stepper.set_nodal_forces(&[(a_node, tip_force)]);
}
let (new_state, _) = stepper.step(&state).unwrap();
state = new_state;
uy_manual.push(state.displacement[a_dofs[1]]);
}
// a. One code path, verified from both ends.
assert_eq!(
uy_run, uy_manual,
"manual stepper drive deviates from run()"
);
// c. Static deflection under the identical nodal force, from the
// nonlinear *static* analysis.
let mut static_analysis = NonlinearStaticAnalysis::new(
mesh.clone(),
materials(),
clamp_left(&mesh, 0.25),
NonlinearConfig::default(),
AnalysisConfig::default(),
)
.with_total_lagrangian();
static_analysis.set_nodal_forces(vec![(a_node, tip_force)]);
let static_results = static_analysis.run().unwrap();
assert!(static_results.convergence.converged);
let numbering =
AdvancedDofNumbering::displacement_only(&mesh, DofMappingStrategy::Sequential).unwrap();
let uy_static = static_results.displacements[numbering.get_node_dofs(a_node)[1]];
let (uy_mean, uy_amp) = mid_amp(&uy_manual);
println!(
" step load: static uy = {uy_static:.4e}, dynamic mid ± amp = \
{uy_mean:.4e} ± {uy_amp:.4e}"
);
assert!(
uy_static < -1e-4,
"static deflection suspiciously small: {uy_static:.3e}"
);
let rel = |a: f64, b: f64| ((a - b) / b).abs();
assert!(
rel(uy_mean, uy_static) < 0.03,
"oscillation midpoint {uy_mean:.4e} vs static deflection {uy_static:.4e}"
);
assert!(
rel(uy_amp, -uy_static) < 0.06,
"oscillation amplitude {uy_amp:.4e} vs |static| {:.4e}",
-uy_static
);
}