Files
rustytorch/crates/specialized/rtx-fea/tests/incompatible_modes.rs
T
Omar SobhandClaude Opus 5 698c844926
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
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
solvers: near-wall momentum, Newmark dynamics, QM6, and MMS across elements
Four parallel work items plus two defects found while integrating them.
561 -> 592 tests, 0 failing, verified stable over repeated runs.

## rtx-cfd: solve the near-wall velocity lines

Every u row sits at y = (j+0.5) dy and every v column at x = (i+0.5) dx --
strictly interior. The sweeps froze rows 0 and ny-1 and columns 0 and
nx-1 and treated whatever was stored there as a boundary condition, which
imposed wall values half a cell inside the domain. They are now unknowns,
with the wall entering through the control volume's half-cell conductance
(mu dx / (dy/2)), zero convective flux through the wall, and the wall's
tangential velocity in the source.

That in turn makes continuity enforceable on every cell, with a neighbour
coefficient zero only for a genuine boundary face. Extending continuity
had been tried before and broke convergence; it works now because the
near-wall lines are no longer frozen. Order matters here.

Manufactured solutions, which is how any of this is known:

    n     L2 velocity   order      max |p - p_exact|
    16    3.516212e-2      -          9.245576e-2
    32    1.953751e-2    0.85         5.225739e-2
    64    1.037523e-2    0.91         2.796415e-2

Velocity error is 7.4x smaller at n=16, and the observed order rises from
0.48 toward 1. The pressure error was 0.408 -> 0.624 -> 0.756, *growing*
with refinement; it now falls. Divergence on the outer ring of cells goes
from 1.0e1 to 2.5e-10.

A separate defect found on the way: u_source_term was computed and never
called, so the x-momentum equation carried no body force at all while the
y-momentum one did. That is exactly the u-versus-v asymmetry the earlier
diagnosis had flagged as an unexplained clue.

Cavity at 65^2, against Ghia's u_min = -0.2109 at y = 0.4531:
-0.1792 at 0.3906 before, -0.1932 at 0.5000 after, in 733 iterations
rather than 971.

The cavity test now sets FreeSlipWall on all four sides plus the lid
through the new set_wall_velocity hook. That is not a weakened benchmark:
on a staggered grid the only velocity component living *on* a boundary is
the normal one, which is what FreeSlipWall prescribes, and the tangential
no-slip arrives through the half-cell wall term with wall velocity zero on
the three stationary walls. Prescribing whole u rows and v columns, as
before, pins lines half a cell inside the domain and over-determines the
cells beside them once every cell has a continuity equation.

## rtx-fea: DynamicAnalysis, previously a stub returning zeros

Newmark-beta in acceleration form -- the displacement form divides by
beta dt^2, singular at beta = 0 -- with Rayleigh damping, the effective
matrix Cholesky-factorised once and reused. Initial acceleration is solved
from M a0 = F0 - C v0 - K u0 rather than assumed zero, which would destroy
the second-order rate.

Verified two ways that cannot both be faked: against the closed-form
single-degree-of-freedom response, undamped and damped, with the measured
order of accuracy; and against the free-vibration period of the same bar
whose modal frequencies are already validated. Time domain and frequency
domain come from different code paths.

## rtx-fea: QM6 incompatible modes

Wilson's Q6 with Taylor's correction, added alongside compute_stiffness_
matrix rather than replacing it -- the existing method is byte-identical,
which matters because the manufactured-solution verification depends on
it. Internal modes statically condensed; the incompatible strain block
evaluated at the element centre, which is what makes the patch test pass
on distorted elements.

## rtx-fea: manufactured solutions across the element library

    Quad4  order 2.00      Tri3   order 1.98
    Quad8  order 3.00      Hex8   order 1.96  (new 3-D solution)

Each element asserts its own theoretical rate.

## Two defects found while integrating

Reverse Cuthill-McKee node ordering was nondeterministic. All three of its
orderings -- seed selection, neighbour ordering, and the trailing sweep --
were decided by HashMap/HashSet iteration order, which std randomises per
process. On a rectangular mesh every corner ties at minimum degree, so two
calls to displacement_only on the same mesh in the same process returned
different DOF indices for the same node, agreeing in only 5 of 20 measured
runs. Ties now break by node id. This surfaced as a coin-flip test failure
-- 12 in 25 runs -- and would have been dismissed as flaky rather than
diagnosed had the integration pass not re-run it.

Quadrature: triangle(3) weights summed to 0.25 against a reference area of
0.5, and tetrahedron(3) to 1/36 against a volume of 1/6. Both divided
weights that were already tabulated for the reference measure by that
measure again, so both rules integrated everything to a fraction of its
value -- invisibly, since a scaled quadrature leaves the stiffness matrix
symmetric, the mass matrix positive definite and the rigid-body modes
exact. New test asserts every rule integrates 1 to its reference measure,
across every family and order, plus Gauss-Legendre exactness to degree
2n-1.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 15:39:20 -07:00

724 lines
27 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 incompatible-modes quadrilateral (Wilson Q6 in
//! Taylor's QM6 form).
//!
//! # Why the element exists
//!
//! A bilinear `Quad4` interpolates displacement as `u = a + bx + cy + dxy`.
//! Put pure bending into it — the exact field `u = -kxy`, `v = k(x² + νy²)/2`
//! — and the `Quad4` can represent `u` but not `v`, because `v` is quadratic
//! in `x`. What it produces instead is a field whose shear strain
//! `γ = ∂u/∂y + ∂v/∂x` is non-zero everywhere except along the element
//! midlines, even though the exact bending field has `γ ≡ 0`. That spurious
//! shear absorbs strain energy the beam should not store, so the element is
//! far too stiff in bending — *shear locking*. The error grows with the square
//! of the element's length-to-height ratio, so it is worst on exactly the
//! coarse meshes one wants to use.
//!
//! Wilson's remedy is to add two internal *incompatible* modes,
//! `N₅ = 1 - ξ²` and `N₆ = 1 - η²`, each with an `x` and a `y` amplitude.
//! `N₅` supplies the missing `x²` term, so the element can now bend without
//! shearing. The four amplitudes are internal to the element and are removed
//! by static condensation before assembly, so the element still presents an
//! 8×8 stiffness to the mesh.
//!
//! # Why QM6 rather than Q6
//!
//! The added modes are *incompatible*: they do not vanish on the element
//! boundary, so displacement continuity between neighbours is broken and
//! convergence is no longer automatic. It has to be earned by passing the
//! patch test, which for the internal block requires
//!
//! ```text
//! ∫ B_a dΩ = 0
//! ```
//!
//! Wilson's original Q6 evaluates `B_a` with the Jacobian at each quadrature
//! point. For a general quadrilateral `|J|` varies bilinearly over the
//! element, that integral is not zero, and Q6 fails the patch test on any
//! element that is not a parallelogram. Taylor, Beresford and Wilson's QM6
//! evaluates the incompatible-mode Jacobian **at the element centre** and
//! scales by `|J₀|/|J|`; the weight `|J|` then cancels and the integral
//! reduces to `|J₀| ∫ B_a⁰ dΩ`, whose integrand is linear in `ξ` and `η` and
//! therefore integrates to zero exactly on the symmetric 2×2 Gauss rule. That
//! is the whole content of the correction, and it is what the distorted-mesh
//! tests below check.
//!
//! # What each test rules out
//!
//! Every test here is written against a closed form or an algebraic invariant
//! that a plausible-but-wrong implementation violates:
//!
//! * A stiffness of zeros, or one that ignores the internal block entirely,
//! is caught by [`qm6_is_strictly_softer_than_quad4`] — the difference
//! `K_Q4 - K_QM6` must be positive semi-definite *and* non-zero.
//! * A sign error in the condensation (`Kuu + Kua Kaa⁻¹ Kau`) leaves the rigid
//! body modes intact and the matrix symmetric, so only the semi-definiteness
//! of that same difference, and the cantilever, catch it.
//! * Evaluating the incompatible block at the quadrature point instead of the
//! centre — i.e. Q6 rather than QM6 — passes every rigid-body and symmetry
//! check and still gives a good cantilever answer. Only the distorted-mesh
//! patch tests catch it.
use nalgebra::{DMatrix, DVector, Vector3};
use rtx_fea::elements::{
ElementMatrixComputer, FiniteElement, NaturalCoords, StandardFiniteElement,
};
use rtx_fea::mesh::ElementType;
/// Which quadrilateral formulation to build the element stiffness with.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Formulation {
/// The existing bilinear element, 2×2 Gauss.
Quad4,
/// Incompatible modes with static condensation, Taylor's QM6 form.
Qm6,
}
impl Formulation {
fn name(self) -> &'static str {
match self {
Formulation::Quad4 => "Quad4",
Formulation::Qm6 => "QM6",
}
}
}
/// Build the 8×8 element stiffness for a four-node quadrilateral.
fn element_stiffness(coords: &[Vector3<f64>], e: f64, nu: f64, form: Formulation) -> DMatrix<f64> {
let fe = StandardFiniteElement::new(ElementType::Quad4, coords.to_vec());
let result = match form {
Formulation::Quad4 => {
ElementMatrixComputer::compute_stiffness_matrix(&fe, coords, e, nu, None)
}
Formulation::Qm6 => {
ElementMatrixComputer::compute_stiffness_matrix_incompatible(&fe, coords, e, nu, None)
}
};
result
.unwrap_or_else(|err| panic!("{} stiffness failed: {err}", form.name()))
.matrix
}
fn node(x: f64, y: f64) -> Vector3<f64> {
Vector3::new(x, y, 0.0)
}
/// A quadrilateral that is neither a rectangle nor a parallelogram.
///
/// Both pairs of opposite edges are non-parallel, so `|J|` genuinely varies
/// over the element and the Q6/QM6 distinction has something to bite on.
fn distorted_element() -> Vec<Vector3<f64>> {
vec![
node(0.0, 0.0),
node(2.0, 0.0),
node(2.2, 1.0),
node(0.6, 1.2),
]
}
/// Nodal values of the linear field `u = a₀ + a₁x + a₂y`, `v = b₀ + b₁x + b₂y`,
/// interleaved `[u₀, v₀, u₁, v₁, …]`.
fn linear_field(coords: &[Vector3<f64>], a: [f64; 3], b: [f64; 3]) -> DVector<f64> {
let mut u = DVector::zeros(2 * coords.len());
for (i, c) in coords.iter().enumerate() {
u[2 * i] = a[0] + a[1] * c.x + a[2] * c.y;
u[2 * i + 1] = b[0] + b[1] * c.x + b[2] * c.y;
}
u
}
// ---------------------------------------------------------------------------
// Premise check
// ---------------------------------------------------------------------------
/// The physical shape-function derivatives must be complete to first order on
/// a distorted element.
///
/// `Σ ∂Nᵢ/∂x = 0`, `Σ (∂Nᵢ/∂x) xᵢ = 1`, `Σ (∂Nᵢ/∂x) yᵢ = 0`, and the
/// transposes. These are the identities that make constant strain
/// representable, and every claim below rests on them. If the Jacobian
/// transform were transposed — a mistake that is invisible on rectangles,
/// where `J` is diagonal — the cross terms here would be non-zero and no patch
/// test could pass.
#[test]
fn physical_derivatives_are_first_order_complete_on_a_distorted_element() {
let coords = distorted_element();
let fe = StandardFiniteElement::new(ElementType::Quad4, coords.clone());
for &(xi, eta) in &[(0.0, 0.0), (-0.6, 0.3), (0.577, -0.577)] {
let nat = NaturalCoords::new_2d(xi, eta);
let shape = fe.shape_functions(&nat).unwrap();
let jac = fe.jacobian(&nat, &coords).unwrap();
let d = jac.transform_derivatives(&shape.derivatives).unwrap();
let mut sum_dx = 0.0;
let mut sum_dy = 0.0;
let mut dx_x = 0.0;
let mut dx_y = 0.0;
let mut dy_x = 0.0;
let mut dy_y = 0.0;
for i in 0..4 {
sum_dx += d[(i, 0)];
sum_dy += d[(i, 1)];
dx_x += d[(i, 0)] * coords[i].x;
dx_y += d[(i, 0)] * coords[i].y;
dy_x += d[(i, 1)] * coords[i].x;
dy_y += d[(i, 1)] * coords[i].y;
}
let tol = 1e-12;
assert!(
sum_dx.abs() < tol,
"at ({xi}, {eta}): sum dN/dx = {sum_dx:e}"
);
assert!(
sum_dy.abs() < tol,
"at ({xi}, {eta}): sum dN/dy = {sum_dy:e}"
);
assert!(
(dx_x - 1.0).abs() < tol,
"at ({xi}, {eta}): sum (dN/dx) x = {dx_x}, expected 1"
);
assert!(
(dy_y - 1.0).abs() < tol,
"at ({xi}, {eta}): sum (dN/dy) y = {dy_y}, expected 1"
);
assert!(
dx_y.abs() < tol,
"at ({xi}, {eta}): sum (dN/dx) y = {dx_y:e}, expected 0"
);
assert!(
dy_x.abs() < tol,
"at ({xi}, {eta}): sum (dN/dy) x = {dy_x:e}, expected 0"
);
}
}
// ---------------------------------------------------------------------------
// 1. Patch test
// ---------------------------------------------------------------------------
/// Single-element patch test in its sharpest algebraic form.
///
/// Static condensation gives `K = Kuu - Kua Kaa⁻¹ Kau`, so for any nodal
/// vector `u`
///
/// ```text
/// K u = Kuu u - Kua Kaa⁻¹ (Kau u)
/// ```
///
/// and `Kuu` *is* the ordinary `Quad4` stiffness — same `B`, same quadrature.
/// So `K_QM6 u = K_Q4 u` holds **exactly** whenever `Kau u = 0`, and `Kau u`
/// is zero for a linear displacement field precisely when `∫ B_a dΩ = 0`,
/// which is the patch-test condition. Comparing the two force vectors
/// therefore tests the internal block directly, with no solver in the way.
///
/// Wilson's Q6 fails this on a non-parallelogram; QM6 passes it for any shape.
/// Three independent linear fields are used so the check covers all three
/// constant-strain states.
#[test]
fn qm6_patch_test_single_distorted_element() {
let coords = distorted_element();
let e = 1000.0;
let nu = 0.3;
let k_q4 = element_stiffness(&coords, e, nu, Formulation::Quad4);
let k_qm6 = element_stiffness(&coords, e, nu, Formulation::Qm6);
// Uniform x-stretch, uniform y-stretch, pure shear, and a general mix.
let fields: [([f64; 3], [f64; 3]); 4] = [
([0.0, 1.0, 0.0], [0.0, 0.0, 0.0]),
([0.0, 0.0, 0.0], [0.0, 0.0, 1.0]),
([0.0, 0.0, 1.0], [0.0, 1.0, 0.0]),
([0.003, 0.011, -0.007], [-0.002, 0.005, 0.013]),
];
for (a, b) in fields {
let u = linear_field(&coords, a, b);
let f_q4 = &k_q4 * &u;
let f_qm6 = &k_qm6 * &u;
let scale = f_q4.amax().max(1e-30);
let diff = (&f_qm6 - &f_q4).amax();
assert!(
diff / scale < 1e-11,
"linear field u={a:?} v={b:?}: QM6 nodal forces differ from the \
constant-stress forces by {diff:e} (relative {:e}). The internal \
modes were activated by a linear field, so the patch test fails — \
this is what Q6 does on a non-parallelogram.",
diff / scale
);
// And those forces really are a self-equilibrated constant-stress
// state: zero resultant force and zero resultant moment.
let fx: f64 = (0..4).map(|i| f_qm6[2 * i]).sum();
let fy: f64 = (0..4).map(|i| f_qm6[2 * i + 1]).sum();
let m: f64 = (0..4)
.map(|i| coords[i].x * f_qm6[2 * i + 1] - coords[i].y * f_qm6[2 * i])
.sum();
assert!(fx.abs() / scale < 1e-12, "resultant Fx = {fx:e}");
assert!(fy.abs() / scale < 1e-12, "resultant Fy = {fy:e}");
assert!(m.abs() / scale < 1e-12, "resultant moment = {m:e}");
}
}
/// The classical multi-element patch test: a patch of four distorted
/// quadrilaterals around one interior node.
///
/// The exact linear field is imposed on all eight boundary nodes and the two
/// interior degrees of freedom are solved for. A convergent element must
/// return the interior node to the exact field — no element in the patch may
/// generate any residual force under a constant-stress state. The interior
/// node sits well off centre so every one of the four elements is a distinct
/// non-parallelogram.
#[test]
fn qm6_patch_test_four_distorted_elements() {
let model = patch_model();
let e = 1000.0;
let nu = 0.3;
let a = [0.003, 0.011, -0.007];
let b = [-0.002, 0.005, 0.013];
let interior = model.nodes.len() - 1;
let exact = linear_field(&model.nodes, a, b);
for form in [Formulation::Quad4, Formulation::Qm6] {
let k = model.assemble(e, nu, form);
let f = DVector::zeros(k.nrows());
let prescribed: Vec<(usize, f64)> = (0..interior)
.flat_map(|n| [(2 * n, exact[2 * n]), (2 * n + 1, exact[2 * n + 1])])
.collect();
let u = solve_with_constraints(&k, &f, &prescribed);
let du = (u[2 * interior] - exact[2 * interior]).abs();
let dv = (u[2 * interior + 1] - exact[2 * interior + 1]).abs();
let scale = exact.amax();
assert!(
du / scale < 1e-11 && dv / scale < 1e-11,
"{}: interior node came out at ({:.15e}, {:.15e}), exact is \
({:.15e}, {:.15e}) — errors {du:e}, {dv:e}. The patch test fails.",
form.name(),
u[2 * interior],
u[2 * interior + 1],
exact[2 * interior],
exact[2 * interior + 1],
);
}
}
// ---------------------------------------------------------------------------
// 2. Rigid body modes and the condensation itself
// ---------------------------------------------------------------------------
/// The condensed stiffness must keep exactly three zero eigenvalues in 2-D.
///
/// Two translations and one infinitesimal rotation — no more, no fewer. Fewer
/// means the condensation has locked out a rigid motion; more means it has
/// introduced a spurious zero-energy (hourglass) mode, which is the failure
/// mode of under-integration, the *other* common cure for locking.
#[test]
fn qm6_has_exactly_three_rigid_body_modes() {
let coords = distorted_element();
let e = 1000.0;
let nu = 0.3;
let k = element_stiffness(&coords, e, nu, Formulation::Qm6);
assert_eq!(k.nrows(), 8, "condensed stiffness must stay 8x8");
assert_eq!(k.ncols(), 8);
let asym = (&k - k.transpose()).amax();
assert!(
asym / k.amax() < 1e-12,
"condensed stiffness is not symmetric: max |K - K^T| = {asym:e}"
);
let mut eig: Vec<f64> = k.clone().symmetric_eigenvalues().iter().copied().collect();
eig.sort_by(|p, q| p.partial_cmp(q).unwrap());
let largest = eig[7];
let zeros = eig.iter().filter(|v| v.abs() < 1e-10 * largest).count();
assert_eq!(
zeros,
3,
"expected exactly 3 rigid body modes, found {zeros}. Eigenvalues \
(scaled by the largest, {largest:e}): {:?}",
eig.iter().map(|v| v / largest).collect::<Vec<_>>()
);
assert!(
eig[3] > 1e-6 * largest,
"the first deformation mode has eigenvalue {:e}, only {:e} of the \
largest — that is a spurious zero-energy mode",
eig[3],
eig[3] / largest
);
assert!(
eig[0] > -1e-10 * largest,
"stiffness is not positive semi-definite: smallest eigenvalue {:e}",
eig[0]
);
}
/// Rigid translation and infinitesimal rigid rotation must store no energy.
///
/// A zero matrix also passes the eigenvalue count above (it has eight zero
/// eigenvalues, not three, so in fact it does not — but a matrix that is
/// merely *scaled wrongly* would). This check pins the energy of the rigid
/// modes against the energy of a real deformation of the same amplitude, so it
/// is a ratio, not an absolute, and cannot be satisfied by a small matrix.
#[test]
fn qm6_stores_no_energy_in_rigid_body_motion() {
let coords = distorted_element();
let k = element_stiffness(&coords, 1000.0, 0.3, Formulation::Qm6);
let translate_x = linear_field(&coords, [1.0, 0.0, 0.0], [0.0, 0.0, 0.0]);
let translate_y = linear_field(&coords, [0.0, 0.0, 0.0], [1.0, 0.0, 0.0]);
// u = -theta y, v = theta x with theta = 1.
let rotate = linear_field(&coords, [0.0, 0.0, -1.0], [0.0, 1.0, 0.0]);
// A genuine deformation of comparable amplitude, for the scale.
let stretch = linear_field(&coords, [0.0, 1.0, 0.0], [0.0, 0.0, 0.0]);
let energy = |u: &DVector<f64>| (u.transpose() * &k * u)[(0, 0)];
let reference = energy(&stretch);
assert!(reference > 0.0, "reference stretch energy is {reference:e}");
for (name, u) in [
("translation in x", &translate_x),
("translation in y", &translate_y),
("infinitesimal rotation", &rotate),
] {
let w = energy(u);
assert!(
w.abs() / reference < 1e-12,
"{name} stores energy {w:e}, which is {:e} of the stretch energy \
{reference:e}",
w.abs() / reference
);
}
}
/// Static condensation can only remove stiffness, never add it — and here it
/// must actually remove some.
///
/// The condensed energy is the minimum of the augmented energy over the
/// internal amplitudes, and `a = 0` is always admissible, so
/// `uᵀK_QM6 u ≤ uᵀK_Q4 u` for every `u`. Equivalently `K_Q4 - K_QM6 =
/// Kua Kaa⁻¹ Kau` is positive semi-definite. This is the invariant that pins
/// the *sign* and the *form* of the condensation: flipping the sign makes the
/// difference negative semi-definite, and dropping the internal block
/// altogether makes it exactly zero. Both are caught here and by nothing else
/// in this file except the cantilever.
#[test]
fn qm6_is_strictly_softer_than_quad4() {
let coords = distorted_element();
let e = 1000.0;
let nu = 0.3;
let k_q4 = element_stiffness(&coords, e, nu, Formulation::Quad4);
let k_qm6 = element_stiffness(&coords, e, nu, Formulation::Qm6);
let diff = &k_q4 - &k_qm6;
let scale = k_q4.amax();
let mut eig: Vec<f64> = diff
.clone()
.symmetric_eigenvalues()
.iter()
.copied()
.collect();
eig.sort_by(|p, q| p.partial_cmp(q).unwrap());
assert!(
eig[0] > -1e-10 * scale,
"K_Q4 - K_QM6 has eigenvalue {:e} (scale {scale:e}); condensation made \
the element STIFFER, so the sign of Kua Kaa^-1 Kau is wrong",
eig[0]
);
assert!(
eig[7] > 1e-3 * scale,
"K_Q4 - K_QM6 is essentially zero (largest eigenvalue {:e} against a \
stiffness scale of {scale:e}); the internal modes are not doing \
anything",
eig[7]
);
// Rank of the difference is at most 4 — that is the size of the internal
// block. More than four non-negligible eigenvalues means the partition is
// wrong.
let nonzero = eig.iter().filter(|v| **v > 1e-10 * scale).count();
assert!(
nonzero <= 4,
"K_Q4 - K_QM6 has rank {nonzero}, but the internal block has only 4 \
degrees of freedom: {:?}",
eig
);
}
// ---------------------------------------------------------------------------
// 3. The point of the exercise: cantilever bending
// ---------------------------------------------------------------------------
/// Slender cantilever under an end load, against the Euler-Bernoulli closed
/// form.
///
/// ```text
/// delta = P L³ / (3 E I), I = h³ / 12 (unit thickness)
/// ```
///
/// With `L = 10`, `h = 1`, `E = 1e7`, `P = 1`: `I = 1/12` and
/// `delta = 1000 / (3 · 1e7 / 12) = 4.0e-4`.
///
/// Poisson's ratio is set to zero deliberately. Beam theory has no transverse
/// contraction, so any non-zero `ν` puts a modelling difference between the
/// closed form and the two-dimensional answer that has nothing to do with the
/// element formulation, and would muddy exactly the comparison being made.
///
/// The remaining known difference is transverse shear, which Euler-Bernoulli
/// omits and plane-stress elasticity includes:
///
/// ```text
/// delta_shear = 6 P L / (5 G A) = 6 · 1 · 10 / (5 · 5e6 · 1) = 2.4e-6
/// ```
///
/// — 0.6% of the bending deflection at `L/h = 10`. So the correct 2-D answer
/// sits slightly *above* Euler-Bernoulli, and the clamped end (which restrains
/// the transverse contraction and the warping that the exact solution has) pulls
/// it back down a little. A converged answer within a couple of percent of
/// `4.0e-4` is what the physics allows; anything far below it is locking.
#[test]
fn cantilever_tip_deflection_quad4_versus_qm6() {
const L: f64 = 10.0;
const H: f64 = 1.0;
const E: f64 = 1.0e7;
const NU: f64 = 0.0;
const P: f64 = 1.0;
let inertia = H * H * H / 12.0;
let euler_bernoulli = P * L * L * L / (3.0 * E * inertia);
let shear = 6.0 * P * L / (5.0 * (E / 2.0) * H);
let timoshenko = euler_bernoulli + shear;
println!("\ncantilever L={L} h={H} E={E:e} nu={NU} P={P}");
println!(" Euler-Bernoulli delta = {euler_bernoulli:.6e}");
println!(
" Timoshenko delta = {timoshenko:.6e} (shear adds {:.2}%)",
100.0 * shear / euler_bernoulli
);
// The coarse mesh the comparison is made on: ten square elements, one
// through the depth. This is the regime where locking is at its worst and
// where a practitioner would actually be.
let coarse = (10usize, 1usize);
let mut coarse_results = [0.0f64; 2];
println!("\n mesh Quad4 tip err QM6 tip err");
for &(nx, ny) in &[coarse, (20, 2), (40, 4), (80, 8)] {
let model = cantilever_model(L, H, nx, ny);
let mut row = [0.0f64; 2];
for (slot, form) in [Formulation::Quad4, Formulation::Qm6].iter().enumerate() {
let d = model.cantilever_tip_deflection(E, NU, *form, P, L);
row[slot] = d;
}
if (nx, ny) == coarse {
coarse_results = row;
}
println!(
" {:>2}x{:<2} {:.6e} {:+7.2}% {:.6e} {:+7.2}%",
nx,
ny,
row[0],
100.0 * (row[0] - euler_bernoulli) / euler_bernoulli,
row[1],
100.0 * (row[1] - euler_bernoulli) / euler_bernoulli,
);
}
let quad4 = coarse_results[0];
let qm6 = coarse_results[1];
let err_quad4 = (quad4 - euler_bernoulli) / euler_bernoulli;
let err_qm6 = (qm6 - euler_bernoulli) / euler_bernoulli;
println!(
"\n coarse mesh {}x{}: Quad4 {:.6e} ({:+.2}%), QM6 {:.6e} ({:+.2}%)",
coarse.0,
coarse.1,
quad4,
100.0 * err_quad4,
qm6,
100.0 * err_qm6
);
// Quad4 must be dramatically too stiff — that is the disease.
assert!(
err_quad4 < -0.25,
"Quad4 tip deflection {quad4:.6e} is {:+.2}% off beam theory \
{euler_bernoulli:.6e}; shear locking on a {}x{} mesh should be far \
worse than that, so either the mesh or the load is not what this test \
thinks it is",
100.0 * err_quad4,
coarse.0,
coarse.1
);
// QM6 must land on beam theory. Tolerance set from the physics above:
// Timoshenko shear adds 0.6%, the clamped end takes back a comparable
// amount, so 5% is generous but still an order tighter than the locking
// error it has to beat.
assert!(
err_qm6.abs() < 0.05,
"QM6 tip deflection {qm6:.6e} is {:+.2}% off beam theory \
{euler_bernoulli:.6e}; the incompatible modes are not curing the \
locking",
100.0 * err_qm6
);
assert!(
err_qm6.abs() * 5.0 < err_quad4.abs(),
"QM6 error {:+.2}% is not decisively better than Quad4's {:+.2}%",
100.0 * err_qm6,
100.0 * err_quad4
);
}
// ---------------------------------------------------------------------------
// Test harness: assembly and a direct solve
// ---------------------------------------------------------------------------
/// A tiny quadrilateral mesh with a dense assembler, kept local to this test
/// so that the Quad4 and QM6 numbers differ only in the element formulation.
struct Model {
nodes: Vec<Vector3<f64>>,
elems: Vec<[usize; 4]>,
}
impl Model {
/// Assemble the global stiffness with DOFs interleaved per node,
/// `[u₀, v₀, u₁, v₁, …]`, matching the element DOF layout.
fn assemble(&self, e: f64, nu: f64, form: Formulation) -> DMatrix<f64> {
let n = self.nodes.len();
let mut k = DMatrix::zeros(2 * n, 2 * n);
for conn in &self.elems {
let coords: Vec<Vector3<f64>> = conn.iter().map(|&i| self.nodes[i]).collect();
let ke = element_stiffness(&coords, e, nu, form);
for a in 0..4 {
for b in 0..4 {
for i in 0..2 {
for j in 0..2 {
k[(2 * conn[a] + i, 2 * conn[b] + j)] += ke[(2 * a + i, 2 * b + j)];
}
}
}
}
}
k
}
/// Clamp `x = 0`, hang a total load `p` off the tip, return the mean
/// vertical displacement of the tip edge.
fn cantilever_tip_deflection(
&self,
e: f64,
nu: f64,
form: Formulation,
p: f64,
length: f64,
) -> f64 {
let k = self.assemble(e, nu, form);
let mut f = DVector::zeros(k.nrows());
let tip: Vec<usize> = (0..self.nodes.len())
.filter(|&i| (self.nodes[i].x - length).abs() < 1e-12)
.collect();
let share = -p / tip.len() as f64;
for &i in &tip {
f[2 * i + 1] += share;
}
let prescribed: Vec<(usize, f64)> = (0..self.nodes.len())
.filter(|&i| self.nodes[i].x.abs() < 1e-12)
.flat_map(|i| [(2 * i, 0.0), (2 * i + 1, 0.0)])
.collect();
let u = solve_with_constraints(&k, &f, &prescribed);
let mean: f64 = tip.iter().map(|&i| u[2 * i + 1]).sum::<f64>() / tip.len() as f64;
-mean
}
}
/// `nx` by `ny` uniform mesh of the rectangle `[0, length] × [0, height]`,
/// nodes ordered counter-clockwise within each element.
fn cantilever_model(length: f64, height: f64, nx: usize, ny: usize) -> Model {
let mut nodes = Vec::with_capacity((nx + 1) * (ny + 1));
for j in 0..=ny {
for i in 0..=nx {
nodes.push(node(
length * i as f64 / nx as f64,
height * j as f64 / ny as f64,
));
}
}
let idx = |i: usize, j: usize| j * (nx + 1) + i;
let mut elems = Vec::with_capacity(nx * ny);
for j in 0..ny {
for i in 0..nx {
elems.push([idx(i, j), idx(i + 1, j), idx(i + 1, j + 1), idx(i, j + 1)]);
}
}
Model { nodes, elems }
}
/// Four distorted quadrilaterals filling `[0,2]²`, sharing one interior node
/// placed off centre. The interior node is the last entry so the boundary
/// nodes are `0..8`.
fn patch_model() -> Model {
let nodes = vec![
node(0.0, 0.0), // 0 corner
node(1.0, 0.0), // 1 edge
node(2.0, 0.0), // 2 corner
node(2.0, 1.0), // 3 edge
node(2.0, 2.0), // 4 corner
node(1.0, 2.0), // 5 edge
node(0.0, 2.0), // 6 corner
node(0.0, 1.0), // 7 edge
node(1.3, 0.9), // 8 interior, deliberately off centre
];
let elems = vec![[0, 1, 8, 7], [1, 2, 3, 8], [8, 3, 4, 5], [7, 8, 5, 6]];
Model { nodes, elems }
}
/// Solve `K u = f` with some degrees of freedom prescribed, by reduction.
fn solve_with_constraints(
k: &DMatrix<f64>,
f: &DVector<f64>,
prescribed: &[(usize, f64)],
) -> DVector<f64> {
let n = k.nrows();
let mut u = DVector::zeros(n);
let mut fixed = vec![false; n];
for &(dof, value) in prescribed {
fixed[dof] = true;
u[dof] = value;
}
let free: Vec<usize> = (0..n).filter(|&d| !fixed[d]).collect();
let mut kff = DMatrix::zeros(free.len(), free.len());
let mut rhs = DVector::zeros(free.len());
for (a, &da) in free.iter().enumerate() {
let mut r = f[da];
for d in 0..n {
if fixed[d] {
r -= k[(da, d)] * u[d];
}
}
rhs[a] = r;
for (b, &db) in free.iter().enumerate() {
kff[(a, b)] = k[(da, db)];
}
}
let sol = kff
.lu()
.solve(&rhs)
.expect("reduced stiffness must be non-singular");
for (a, &da) in free.iter().enumerate() {
u[da] = sol[a];
}
u
}