Files
rustytorch/crates/specialized/rtx-fea/tests/total_lagrangian_svk.rs
T
Omar SobhandClaude Fable 5 c25f15b3c4
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
rtx-cfd + rtx-fea: embedded-boundary PISO and total-Lagrangian SVK — the first two Turek–Hron rungs
The Turek–Hron geometry decision (omni-cortex
docs/turek_hron_geometry_decision.md) chose an embedded boundary on the
fixed Cartesian MAC grid over body-fitted unstructured ALE; this commit
builds the first rung on each side of the ladder, verified MMS-first.

rtx-cfd — solvers::incompressible::{embedded, embedded_body}:
EmbeddedPisoSolver is the fixed-grid PISO predictor/projection with
per-side domain boundaries (ALE's SideBoundary semantics, so the channel
has an outlet), a (x, y, t) boundary-velocity function, and an optional
EmbeddedBody (signed distance + surface velocity; circle / rectangle /
union). EmbeddedMask classifies cells (fluid iff phi > 0 at the centre)
and faces (fluid iff both cells fluid; ghost within 1.5 h; solid deeper);
the predictor updates fluid faces only, the projection enforces continuity
on fluid cells with zero coefficient across prescribed faces, ghost faces
are re-imposed after each projection from a boundary-intercept
least-squares linear fit (exact for linear fields), the net ghost mass flux
is removed uniformly so a Neumann projection stays compatible, and loads
come by two routes: surface-stress reconstruction (full viscous traction)
and a control-volume momentum balance.

Verified (tests/embedded_mms.rs, tests/turek_hron_cfd.rs):
- no body, closed box: bit-identical to PisoSolver over 200 steps;
- embedded off-centre circle MMS 16/32/64: velocity orders 0.92, 0.97
  (plain PISO 0.85, 0.91), pressure 0.96, 0.90, max |div u| <= 9e-8 on
  every fluid cell, compatibility correction 6e-4 -> 3e-5; force on the
  circle vs the exact surface integral: surface route 0.52 -> 0.29 -> 0.15,
  control-volume route 0.61 -> 0.30 -> 0.15 (both first order, two
  unrelated readings of the same solution);
- Turek–Hron CFD1 (Re 20, h = 10 mm, flag two cells thick), settled to
  four digits: surface drag 15.71 / lift 0.94, control-volume drag 15.62 /
  lift 1.08 vs reference 14.29 / 1.119 — the drag routes agree to 0.6%,
  both +9.5%. A coarse first number; the refinement study waits on a
  multigrid projection (SOR: 0.1 s/step at 250x41 in the test profile).

Fourteenth defect of the campaign: the fixed-grid PISO predictor zeroes
the transverse convective face velocity on its domain sides (exact for
walls); carried into a solver with an outlet it dropped the OUTGOING
momentum flux through the outlet side of the v control volumes, the last
column accumulated, and CFD1 went NaN at t ~ 4 s. Found by printing where
max |u| lived (x = 2.5) after halving dt changed nothing. Fluxes now come
from the stored boundary faces on every side.

rtx-fea — elements::total_lagrangian + NonlinearStaticAnalysis::
with_total_lagrangian(): Green–Lagrange strain, second Piola–Kirchhoff
stress from a St. Venant–Kirchhoff law on the material's Lamé parameters
(plane strain in 2-D), B_L of the current deformation, material plus
geometric tangent; dead-load body force per reference volume.

Verified (tests/total_lagrangian_svk.rs):
- zero displacement: the plane-strain stiffness to 1e-13;
- tangent = d f_int/du by central differences at 20% random displacement
  (Quad4, Quad8, Hex8): relative < 1e-7, symmetric to 1e-12;
- a 34-degree rigid rotation produces no internal force; the small-strain
  routine does (negative control);
- manufactured finite-strain solution, body force by FD of the exact
  P = F S: Quad4 orders 1.95, 1.98; Quad8 2.93, 3.03, 3.02 (an 8%
  amplitude, Green–Lagrange strain to -0.25 near SVK's compressive limit
  E = -1/3, broke Newton on fine meshes — the material, not the code; 3%
  is clean);
- Turek–Hron CSM1 at 70x4 Quad8: u(A) = (-7.060, -65.43) mm vs
  (-7.188, -66.10), 1.0% / 1.8%, converging from below (35x2: -65.14);
  CSM2: (-0.4604, -16.79) vs (-0.4690, -16.97), 1.1% / 1.8%.

rtx-cfd 293 -> 301 green (5 unit + 3 integration), rtx-fea 559 -> 564.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-20 08:51:32 -07:00

554 lines
20 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.
//! Verification of the total-Lagrangian St. VenantKirchhoff path
//! (`elements::total_lagrangian`, `NonlinearStaticAnalysis::with_total_lagrangian`).
//!
//! In the order the claims must be established:
//!
//! 1. At zero displacement the TL tangent is the small-strain plane-strain
//! stiffness, to rounding.
//! 2. The tangent is the derivative of the internal force — finite
//! differences at a finite random displacement, for Quad4, Quad8, Hex8.
//! A plausible-but-wrong geometric stiffness fails this and nothing else.
//! 3. A finite rigid rotation produces no internal force (GreenLagrange
//! strain is objective); the small-strain routine does produce one — the
//! negative control that shows the test has teeth.
//! 4. Manufactured solutions at finite strain recover the element orders
//! (Quad4 ~2, Quad8 ~3 in L2), with the body force obtained by finite
//! differences of the exact first PiolaKirchhoff stress.
//! 5. TurekHron CSM1 and CSM2 (flag under gravity, clamped at the cylinder):
//! reference `u_x(A) = 7.18777e-3, u_y(A) = 66.1029e-3` (CSM1) and
//! `0.469006e-3, 16.9740e-3` (CSM2), FEATFLOW tables.
use nalgebra::{DMatrix, DVector, Vector3};
use rtx_fea::analysis::{Analysis, AnalysisConfig, NonlinearConfig, 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::total_lagrangian::{internal_force_and_tangent, saint_venant_kirchhoff};
use rtx_fea::elements::{ElementMatrixComputer, FiniteElement, StandardFiniteElement};
use rtx_fea::materials::{LinearElastic, MaterialDatabase};
use rtx_fea::mesh::{Element, ElementType, MaterialId, Mesh, Node, NodeId};
use std::f64::consts::PI;
const E_MOD: f64 = 1.4e6;
const NU: f64 = 0.4;
fn lame() -> (f64, f64) {
let mu = E_MOD / (2.0 * (1.0 + NU));
let lambda = E_MOD * NU / ((1.0 + NU) * (1.0 - 2.0 * NU));
(lambda, mu)
}
fn plane_strain_d() -> DMatrix<f64> {
let (lambda, mu) = lame();
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;
d
}
fn quad4_coords() -> Vec<Vector3<f64>> {
// A deliberately non-rectangular quadrilateral.
vec![
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(1.1, 0.1, 0.0),
Vector3::new(0.9, 1.0, 0.0),
Vector3::new(-0.1, 0.8, 0.0),
]
}
fn quad8_coords() -> Vec<Vector3<f64>> {
let c = quad4_coords();
let mid = |a: usize, b: usize| 0.5 * (c[a] + c[b]);
vec![
c[0],
c[1],
c[2],
c[3],
mid(0, 1),
mid(1, 2),
mid(2, 3),
mid(3, 0),
]
}
fn hex8_coords() -> Vec<Vector3<f64>> {
vec![
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(1.0, 0.0, 0.1),
Vector3::new(1.1, 1.0, 0.0),
Vector3::new(0.0, 0.9, 0.0),
Vector3::new(0.1, 0.0, 1.0),
Vector3::new(1.0, 0.1, 1.0),
Vector3::new(1.0, 1.0, 1.1),
Vector3::new(0.0, 1.0, 0.9),
]
}
/// Deterministic pseudo-random displacement of amplitude `amp`.
fn pseudo_random(n: usize, amp: f64, seed: u64) -> DVector<f64> {
let mut x = seed;
DVector::from_iterator(
n,
(0..n).map(|_| {
x = x
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
amp * (((x >> 33) as f64) / (1u64 << 31) as f64 - 1.0)
}),
)
}
/// 1. Zero displacement: TL tangent == small-strain plane-strain stiffness.
#[test]
fn at_zero_displacement_the_tangent_is_the_plane_strain_stiffness() {
let coords = quad4_coords();
let fe = StandardFiniteElement::new(ElementType::Quad4, coords.clone());
let (lambda, mu) = lame();
let svk = saint_venant_kirchhoff(lambda, mu, 2);
let u0 = DVector::zeros(8);
let (f_int, k_tl) = internal_force_and_tangent(&fe, &coords, &u0, svk.as_ref(), None).unwrap();
let d = plane_strain_d();
let linear = move |strain: &DVector<f64>| Ok((&d * strain, d.clone()));
let (_, k_lin) =
ElementMatrixComputer::compute_internal_force_and_tangent(&fe, &coords, &u0, &linear, None)
.unwrap();
assert!(
f_int.norm() == 0.0,
"internal force at zero displacement: {}",
f_int.norm()
);
let diff = (&k_tl - &k_lin).norm() / k_lin.norm();
assert!(
diff < 1e-13,
"TL tangent at u = 0 differs from the plane-strain stiffness by {diff:.3e}"
);
}
/// 2. The tangent is the derivative of the internal force (central
/// differences at a finite displacement; step chosen so the FD error is far
/// below the tolerance for a smooth polynomial residual).
#[test]
fn tangent_is_the_derivative_of_the_internal_force() {
let (lambda, mu) = lame();
for (name, element_type, coords, dim) in [
("Quad4", ElementType::Quad4, quad4_coords(), 2usize),
("Quad8", ElementType::Quad8, quad8_coords(), 2),
("Hex8", ElementType::Hex8, hex8_coords(), 3),
] {
let fe = StandardFiniteElement::new(element_type, coords.clone());
let svk = saint_venant_kirchhoff(lambda, mu, dim);
let n = coords.len() * dim;
// 20% strain-level displacement: genuinely finite.
let u = pseudo_random(n, 0.2, 7);
let (_, k) = internal_force_and_tangent(&fe, &coords, &u, svk.as_ref(), None).unwrap();
let eps = 1e-6;
let mut k_fd = DMatrix::zeros(n, n);
for j in 0..n {
let mut up = u.clone();
let mut um = u.clone();
up[j] += eps;
um[j] -= eps;
let (fp, _) =
internal_force_and_tangent(&fe, &coords, &up, svk.as_ref(), None).unwrap();
let (fm, _) =
internal_force_and_tangent(&fe, &coords, &um, svk.as_ref(), None).unwrap();
let col = (fp - fm) / (2.0 * eps);
k_fd.set_column(j, &col);
}
let rel = (&k - &k_fd).norm() / k.norm();
assert!(
rel < 1e-7,
"{name}: tangent vs finite-difference derivative of f_int: relative {rel:.3e}"
);
let asym = (&k - k.transpose()).norm() / k.norm();
assert!(asym < 1e-12, "{name}: tangent not symmetric: {asym:.3e}");
}
}
/// 3. Finite rigid rotation: no internal force in the TL routine; the
/// small-strain routine sees strain and pushes back (negative control).
#[test]
fn rigid_rotation_produces_no_internal_force_and_the_small_strain_routine_fails_this() {
let coords = quad8_coords();
let fe = StandardFiniteElement::new(ElementType::Quad8, coords.clone());
let theta: f64 = 0.6; // 34 degrees
let (s, c) = theta.sin_cos();
let mut u = DVector::zeros(16);
for (a, x) in coords.iter().enumerate() {
u[2 * a] = c * x.x - s * x.y - x.x;
u[2 * a + 1] = s * x.x + c * x.y - x.y;
}
let (lambda, mu) = lame();
let svk = saint_venant_kirchhoff(lambda, mu, 2);
let (f_tl, _) = internal_force_and_tangent(&fe, &coords, &u, svk.as_ref(), None).unwrap();
let d = plane_strain_d();
let linear = move |strain: &DVector<f64>| Ok((&d * strain, d.clone()));
let (f_small, _) =
ElementMatrixComputer::compute_internal_force_and_tangent(&fe, &coords, &u, &linear, None)
.unwrap();
let scale = E_MOD * u.norm();
assert!(
f_tl.norm() < 1e-10 * scale,
"TL internal force under a rigid rotation: {:.3e} (scale {scale:.3e})",
f_tl.norm()
);
assert!(
f_small.norm() > 1e-2 * scale,
"the small-strain routine should see a rigid rotation as strain; got {:.3e}",
f_small.norm()
);
}
// ---------------------------------------------------------------------------
// Manufactured solution at finite strain
// ---------------------------------------------------------------------------
const AMP: f64 = 0.03;
fn u_exact(p: Vector3<f64>) -> Vector3<f64> {
let (x, y) = (p.x, p.y);
Vector3::new(
AMP * (PI * x).sin() * (PI * y).sin(),
AMP * (PI * x).sin() * (PI * y).sin() * 0.5 + AMP * 0.3 * x * y,
0.0,
)
}
fn grad_u(p: Vector3<f64>) -> DMatrix<f64> {
let (x, y) = (p.x, p.y);
let sx = (PI * x).sin();
let cx = (PI * x).cos();
let sy = (PI * y).sin();
let cy = (PI * y).cos();
let mut h = DMatrix::zeros(2, 2);
h[(0, 0)] = AMP * PI * cx * sy;
h[(0, 1)] = AMP * PI * sx * cy;
h[(1, 0)] = 0.5 * AMP * PI * cx * sy + AMP * 0.3 * y;
h[(1, 1)] = 0.5 * AMP * PI * sx * cy + AMP * 0.3 * x;
h
}
/// First PiolaKirchhoff stress `P = F S` of the manufactured field.
fn piola(p: Vector3<f64>) -> DMatrix<f64> {
let (lambda, mu) = lame();
let f = DMatrix::identity(2, 2) + grad_u(p);
let e = 0.5 * (f.transpose() * &f - DMatrix::identity(2, 2));
let s = lambda * e.trace() * DMatrix::identity(2, 2) + 2.0 * mu * &e;
f * s
}
/// Body force per unit reference volume `b = Div P`, by central
/// differences of the analytic `P` (step 1e-6: FD error ~1e-12 relative).
fn body_force(p: Vector3<f64>) -> Vector3<f64> {
let eps = 1e-6;
let mut div = Vector3::zeros();
for j in 0..2 {
let mut dp = Vector3::zeros();
dp[j] = eps;
let plus = piola(p + dp);
let minus = piola(p - dp);
for i in 0..2 {
div[i] += (plus[(i, j)] - minus[(i, j)]) / (2.0 * eps);
}
}
-div
}
fn on_unit_square_boundary(p: Vector3<f64>) -> bool {
(0..2).any(|d| p[d].abs() < 1e-12 || (p[d] - 1.0).abs() < 1e-12)
}
fn quad4_mesh(n: usize) -> Mesh {
let mut mesh = Mesh::new(2).unwrap();
let mut grid = vec![vec![NodeId(0); n + 1]; n + 1];
for (i, column) in grid.iter_mut().enumerate() {
for (j, slot) in column.iter_mut().enumerate() {
*slot = mesh.add_node(Node::new_2d(i as f64 / n as f64, j as f64 / n as f64));
}
}
for i in 0..n {
for j in 0..n {
let nodes = vec![
grid[i][j],
grid[i + 1][j],
grid[i + 1][j + 1],
grid[i][j + 1],
];
mesh.add_element(Element::new(ElementType::Quad4, nodes, MaterialId(0)).unwrap())
.unwrap();
}
}
mesh
}
/// `nx` by `ny` Quad8 mesh of `[x0, x1] x [y0, y1]` (serendipity lattice with
/// cell centres left out), corners counter-clockwise then mid-edges.
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), None);
db
}
fn dirichlet(
nodes: Vec<NodeId>,
component: DofComponent,
f: impl Fn(Vector3<f64>) -> f64 + Send + Sync + 'static,
) -> BoundaryCondition {
BoundaryCondition::Dirichlet(DirichletBC {
nodes,
components: vec![component],
condition_type: DirichletType::Spatial(SpatialFunction(Box::new(move |p| f(*p)))),
time_range: None,
ramping_factor: 1.0,
gradual_enforcement: false,
})
}
fn exact_boundary_conditions(mesh: &Mesh) -> BoundaryConditionSet {
let boundary: Vec<NodeId> = mesh
.nodes
.iter()
.filter(|(_, node)| on_unit_square_boundary(node.position()))
.map(|(&id, _)| id)
.collect();
let mut set = BoundaryConditionSet::new();
set.add_condition(dirichlet(
boundary.clone(),
DofComponent::DisplacementX,
|p| u_exact(p).x,
));
set.add_condition(dirichlet(boundary, DofComponent::DisplacementY, |p| {
u_exact(p).y
}));
set
}
/// Quadrature-integrated L2 error against the manufactured field.
fn l2_error(mesh: &Mesh, dof_numbering: &AdvancedDofNumbering, solution: &DVector<f64>) -> f64 {
let mut squared = 0.0;
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 rule = fe.quadrature_rule(Some(4)).unwrap();
let dofs: Vec<usize> = element
.nodes
.iter()
.flat_map(|node| dof_numbering.get_node_dofs(*node))
.collect();
for point in &rule.points {
let shape = fe.shape_functions(&point.coords).unwrap();
let jac = fe.jacobian(&point.coords, &coords).unwrap();
let physical = fe.map_to_physical(&point.coords, &coords).unwrap();
let mut uh = Vector3::zeros();
for a in 0..element.nodes.len() {
let n = shape.value(a).unwrap();
uh.x += n * solution[dofs[2 * a]];
uh.y += n * solution[dofs[2 * a + 1]];
}
let exact = u_exact(physical.coords);
squared += (uh - exact).norm_squared() * point.weight * jac.determinant().abs();
}
}
squared.sqrt()
}
fn solve_mms(mesh: Mesh) -> f64 {
let dof_numbering =
AdvancedDofNumbering::displacement_only(&mesh, DofMappingStrategy::Sequential).unwrap();
let bcs = exact_boundary_conditions(&mesh);
let config = NonlinearConfig {
max_load_steps: 5,
..NonlinearConfig::default()
};
let mut analysis = NonlinearStaticAnalysis::new(
mesh.clone(),
materials(),
bcs,
config,
AnalysisConfig::default(),
)
.with_total_lagrangian();
analysis.set_body_force(body_force);
let results = analysis.run().unwrap();
assert!(
results.convergence.converged,
"Newton did not converge on the manufactured problem"
);
l2_error(&mesh, &dof_numbering, &results.displacements)
}
fn observed_order(errors: &[f64]) -> Vec<f64> {
errors.windows(2).map(|w| (w[0] / w[1]).log2()).collect()
}
/// 4. Manufactured finite-strain solution: Quad4 at order 2, Quad8 at 3.
#[test]
fn manufactured_finite_strain_solution_converges_at_the_element_orders() {
// Sanity on the manufactured data: max |grad u| ~ AMP*pi ~ 0.25 — finite.
let quad4_errors: Vec<f64> = [4usize, 8, 16]
.iter()
.map(|&n| solve_mms(quad4_mesh(n)))
.collect();
let quad8_errors: Vec<f64> = [2usize, 4, 8, 16]
.iter()
.map(|&n| solve_mms(quad8_rect_mesh(0.0, 1.0, 0.0, 1.0, n, n)))
.collect();
let o4 = observed_order(&quad4_errors);
let o8 = observed_order(&quad8_errors);
println!(" Quad4 L2 errors {quad4_errors:?} orders {o4:?}");
println!(" Quad8 L2 errors {quad8_errors:?} orders {o8:?}");
assert!(o4.last().unwrap() > &1.8, "Quad4 order {o4:?}");
assert!(o8.last().unwrap() > &2.7, "Quad8 order {o8:?}");
}
// ---------------------------------------------------------------------------
// TurekHron CSM1 / CSM2
// ---------------------------------------------------------------------------
struct Csm {
ux_a: f64,
uy_a: f64,
iterations: usize,
}
/// The flag `[0.25, 0.6] x [0.19, 0.21]`, clamped at `x = 0.25`, under
/// gravity `g = 2` downward, density 1000, plane-strain SVK with the given
/// shear modulus and `nu = 0.4`. Returns the displacement of
/// `A = (0.6, 0.2)`.
fn run_csm(mu_s: f64, nx: usize, ny: usize, load_steps: usize) -> Csm {
let e_mod = 2.0 * mu_s * (1.0 + NU);
let mesh = quad8_rect_mesh(0.25, 0.6, 0.19, 0.21, nx, ny);
let clamped: Vec<NodeId> = mesh
.nodes
.iter()
.filter(|(_, node)| (node.position().x - 0.25).abs() < 1e-12)
.map(|(&id, _)| id)
.collect();
let point_a = mesh
.nodes
.iter()
.find(|(_, node)| {
(node.position().x - 0.6).abs() < 1e-12 && (node.position().y - 0.2).abs() < 1e-12
})
.map(|(&id, _)| id)
.expect("point A (0.6, 0.2) must be a mesh node");
let mut bcs = BoundaryConditionSet::new();
bcs.add_condition(dirichlet(
clamped.clone(),
DofComponent::DisplacementX,
|_| 0.0,
));
bcs.add_condition(dirichlet(clamped, DofComponent::DisplacementY, |_| 0.0));
let mut db = MaterialDatabase::new();
db.add_material(MaterialId(0), LinearElastic::new(e_mod, NU), None);
let dof_numbering =
AdvancedDofNumbering::displacement_only(&mesh, DofMappingStrategy::Sequential).unwrap();
let config = NonlinearConfig {
max_load_steps: load_steps,
..NonlinearConfig::default()
};
let mut analysis =
NonlinearStaticAnalysis::new(mesh, db, bcs, config, AnalysisConfig::default())
.with_total_lagrangian();
analysis.set_body_force(|_| Vector3::new(0.0, -1000.0 * 2.0, 0.0));
let results = analysis.run().unwrap();
assert!(results.convergence.converged, "CSM Newton did not converge");
let dofs = dof_numbering.get_node_dofs(point_a);
Csm {
ux_a: results.displacements[dofs[0]],
uy_a: results.displacements[dofs[1]],
iterations: results.convergence.iterations,
}
}
#[test]
fn turek_hron_csm1_and_csm2_deflections() {
// (mu_s, reference ux, reference uy) in metres.
let cases = [
("CSM1", 0.5e6, -7.18777e-3, -66.1029e-3),
("CSM2", 2.0e6, -0.469006e-3, -16.9740e-3),
];
// Measured (Quad8, plane-strain SVK, 5 load steps): CSM1 35x2
// (-7.006e-3, -65.14e-3), 70x4 (-7.060e-3, -65.43e-3) — converging
// from below onto the reference (-7.188e-3, -66.10e-3), 1.0% short in
// u_y and 1.8% in u_x at 5 mm elements. `TL_FINE=1` runs 140x8 as well
// (too slow for the suite) for the convergence record.
let fine_mesh = if std::env::var("TL_FINE").is_ok() {
(140, 8)
} else {
(70, 4)
};
for (name, mu_s, ref_ux, ref_uy) in cases {
let coarse = run_csm(mu_s, 35, 2, 5);
let fine = run_csm(mu_s, fine_mesh.0, fine_mesh.1, 5);
println!(
" {name}: 35x2 Quad8 u(A) = ({:.5e}, {:.5e}); {}x{} Quad8 u(A) = ({:.5e}, {:.5e}) \
[{} Newton iterations]; reference ({ref_ux:.5e}, {ref_uy:.5e})",
coarse.ux_a,
coarse.uy_a,
fine_mesh.0,
fine_mesh.1,
fine.ux_a,
fine.uy_a,
fine.iterations
);
let rel = |a: f64, b: f64| ((a - b) / b).abs();
assert!(
rel(fine.uy_a, ref_uy) < 0.015,
"{name}: u_y(A) = {:.5e} vs reference {ref_uy:.5e}",
fine.uy_a
);
assert!(
rel(fine.ux_a, ref_ux) < 0.03,
"{name}: u_x(A) = {:.5e} vs reference {ref_ux:.5e}",
fine.ux_a
);
assert!(
rel(fine.uy_a, ref_uy) <= rel(coarse.uy_a, ref_uy) + 1e-4,
"{name}: refinement moved away from the reference"
);
}
}