Files
rustytorch/crates/specialized/rtx-fea/tests/mms_elastostatics.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

957 lines
36 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.
//! Code verification by the Method of Manufactured Solutions.
//!
//! Every other test in this crate asserts that some quantity is *close enough*
//! to a value someone believed. MMS asserts something stronger and more
//! objective: that the discretisation converges to the exact solution at the
//! rate the theory predicts.
//!
//! The method needs no benchmark data. Choose any sufficiently smooth field
//! `u`, substitute it into the governing equations, and whatever they fail to
//! balance is the body force `f = -∇·σ(u)` that makes `u` the exact solution of
//! the problem with that load. Solve with `f` applied and `u` imposed on the
//! boundary, measure the error, refine, and read off the observed order
//! `log(e_h / e_{h/2}) / log(2)`.
//!
//! This matters here specifically. Every defect found in this crate returned a
//! plausible wrong answer and passed the tests written for it — element
//! matrices of zeros, a quadrature rule with no points, an assembly transposed
//! per node. None of them survives an order-of-accuracy measurement: a wrong
//! discretisation either fails to converge or converges at the wrong rate, and
//! neither can be tuned away.
//!
//! # What is measured
//!
//! Four element types, each against **its own** theoretical L2 displacement
//! rate — `h^(p+1)` for a space complete to polynomial degree `p`:
//!
//! | element | space | expected L2 order |
//! |---------|-----------------------------|-------------------|
//! | `Tri3` | linear triangle, 2-D | 2 |
//! | `Quad4` | bilinear quadrilateral, 2-D | 2 |
//! | `Quad8` | serendipity quad, 2-D | 3 |
//! | `Hex8` | trilinear hexahedron, 3-D | 2 |
//!
//! A blanket "order ≥ 1.8" across all four would pass for `Quad8` while it was
//! silently no better than `Quad4`, so each element asserts its own rate.
//!
//! # The manufactured solutions
//!
//! Two dimensions (plane stress), shared by `Tri3`, `Quad4` and `Quad8`:
//!
//! ```text
//! u(x, y) = sin(pi x) sin(pi y)
//! v(x, y) = x^2 (1 - x) y (1 - y)
//! ```
//!
//! Three dimensions (`Hex8`):
//!
//! ```text
//! u(x, y, z) = sin(pi x) sin(pi y) sin(pi z)
//! v(x, y, z) = cos(pi x) sin(2 pi y) z^2
//! w(x, y, z) = x^2 y sin(pi z)
//! ```
//!
//! Both are chosen so the field is smooth but **not** in any of the element
//! spaces, so the error measured is genuinely discretisation error rather than
//! round-off. The components are deliberately different in form and every
//! mixed derivative `du_i/dx_j` is non-zero, so all three shear rows and the
//! full off-diagonal `lambda` block of the 3-D constitutive matrix are
//! exercised rather than silently skipped.
//!
//! Neither field vanishes on the boundary. That is deliberate: the exact
//! solution is imposed as a **non-zero** Dirichlet condition, which is the
//! case a solver that drops the `K_fc u_c` coupling term gets wrong.
use nalgebra::{DVector, Vector3};
use rtx_fea::assembly::{
AdvancedDofNumbering, AssemblyOptions, DofComponent, DofMappingStrategy, GlobalAssembler,
};
use rtx_fea::elements::{ElementMatrixComputer, FiniteElement, StandardFiniteElement};
use rtx_fea::materials::{LinearElastic, MaterialDatabase};
use rtx_fea::mesh::{Element, ElementType, MaterialId, Mesh, Node, NodeId};
use rtx_fea::solvers::{SolverFactory, SolverOptions};
use std::f64::consts::PI;
const E: f64 = 1.0;
const NU: f64 = 0.3;
/// Lamé's first parameter, as `elasticity_matrix` builds it for `spatial_dim = 3`.
const LAMBDA: f64 = E * NU / ((1.0 + NU) * (1.0 - 2.0 * NU));
/// Shear modulus. Also the plane-stress shear entry `c (1 - nu) / 2`.
const MU: f64 = E / (2.0 * (1.0 + NU));
// ---------------------------------------------------------------------------
// Two-dimensional manufactured solution (plane stress)
// ---------------------------------------------------------------------------
/// Exact displacement field.
fn exact(x: f64, y: f64) -> (f64, f64) {
(
(PI * x).sin() * (PI * y).sin(),
x * x * (1.0 - x) * y * (1.0 - y),
)
}
/// Body force `f = -div(sigma(u))` for plane stress.
///
/// Derived by hand from the manufactured field above with
/// `sigma_xx = c(e_xx + nu e_yy)`, `sigma_yy = c(nu e_xx + e_yy)`,
/// `sigma_xy = mu gamma_xy`, where `c = E / (1 - nu^2)` and
/// `mu = E / (2(1 + nu))` — the same plane-stress constitutive matrix
/// `ElementMatrixComputer::elasticity_matrix` builds.
///
/// Strains:
/// ```text
/// e_xx = pi cos(pi x) sin(pi y)
/// e_yy = x^2 (1 - x) (1 - 2y)
/// gamma_xy = pi sin(pi x) cos(pi y) + (2x - 3x^2) y (1 - y)
/// ```
fn body_force(x: f64, y: f64) -> (f64, f64) {
let c = E / (1.0 - NU * NU);
let mu = MU;
let (sx, cx) = ((PI * x).sin(), (PI * x).cos());
let (sy, cy) = ((PI * y).sin(), (PI * y).cos());
// d(e_xx)/dx, d(e_yy)/dx, d(e_xx)/dy, d(e_yy)/dy
let dexx_dx = -PI * PI * sx * sy;
let deyy_dx = (2.0 * x - 3.0 * x * x) * (1.0 - 2.0 * y);
let dexx_dy = PI * PI * cx * cy;
let deyy_dy = -2.0 * x * x * (1.0 - x);
// d(gamma_xy)/dy and d(gamma_xy)/dx
let dgxy_dy = -PI * PI * sx * sy + (2.0 * x - 3.0 * x * x) * (1.0 - 2.0 * y);
let dgxy_dx = PI * PI * cx * cy + (2.0 - 6.0 * x) * y * (1.0 - y);
let dsxx_dx = c * (dexx_dx + NU * deyy_dx);
let dsyy_dy = c * (NU * dexx_dy + deyy_dy);
let dsxy_dy = mu * dgxy_dy;
let dsxy_dx = mu * dgxy_dx;
(-(dsxx_dx + dsxy_dy), -(dsxy_dx + dsyy_dy))
}
/// The 2-D exact field as a spatial function.
fn exact_2d(p: Vector3<f64>) -> Vector3<f64> {
let (u, v) = exact(p.x, p.y);
Vector3::new(u, v, 0.0)
}
/// The 2-D body force as a spatial function.
fn force_2d(p: Vector3<f64>) -> Vector3<f64> {
let (fx, fy) = body_force(p.x, p.y);
Vector3::new(fx, fy, 0.0)
}
// ---------------------------------------------------------------------------
// Three-dimensional manufactured solution (isotropic, lambda/mu form)
// ---------------------------------------------------------------------------
/// Exact 3-D displacement field.
///
/// ```text
/// u1 = sin(pi x) sin(pi y) sin(pi z)
/// u2 = cos(pi x) sin(2 pi y) z^2
/// u3 = x^2 y sin(pi z)
/// ```
fn exact_3d(p: Vector3<f64>) -> Vector3<f64> {
let (x, y, z) = (p.x, p.y, p.z);
Vector3::new(
(PI * x).sin() * (PI * y).sin() * (PI * z).sin(),
(PI * x).cos() * (2.0 * PI * y).sin() * z * z,
x * x * y * (PI * z).sin(),
)
}
/// Body force `f = -div(sigma(u))` for 3-D isotropic elasticity.
///
/// For `sigma = lambda tr(eps) I + 2 mu eps` — which is exactly the 6x6 matrix
/// `elasticity_matrix` builds for `spatial_dim = 3`, with `lambda + 2 mu` on
/// the first three diagonal entries, `lambda` off-diagonal and `mu` on the
/// three shear entries — the divergence collapses to the Navier form
///
/// ```text
/// div sigma = (lambda + mu) grad(div u) + mu laplacian(u)
/// ```
///
/// so `f = -[(lambda + mu) grad(div u) + mu laplacian(u)]`. Both terms are
/// written out below directly from the field above; nothing here is
/// differentiated numerically, and `body_force_3d_matches_stress_divergence`
/// checks the whole expression against an independent numerical divergence of
/// `sigma`.
fn body_force_3d(p: Vector3<f64>) -> Vector3<f64> {
let (x, y, z) = (p.x, p.y, p.z);
let (sx, cx) = ((PI * x).sin(), (PI * x).cos());
let (sy, cy) = ((PI * y).sin(), (PI * y).cos());
let (sz, cz) = ((PI * z).sin(), (PI * z).cos());
let (s2y, c2y) = ((2.0 * PI * y).sin(), (2.0 * PI * y).cos());
// Laplacians.
// u1 is a product of three sines of argument pi * (coordinate), so each
// second derivative contributes -pi^2 u1.
let lap_u1 = -3.0 * PI * PI * sx * sy * sz;
// u2 = cos(pi x) sin(2 pi y) z^2: -pi^2 u2 - 4 pi^2 u2 + 2 cos sin
let lap_u2 = cx * s2y * (2.0 - 5.0 * PI * PI * z * z);
// u3 = x^2 y sin(pi z): 2 y sin(pi z) + 0 - pi^2 x^2 y sin(pi z)
let lap_u3 = y * sz * (2.0 - PI * PI * x * x);
// grad(div u), where
// div u = pi cos(pi x) sin(pi y) sin(pi z)
// + 2 pi cos(pi x) cos(2 pi y) z^2
// + pi x^2 y cos(pi z)
let ddiv_dx =
-PI * PI * sx * sy * sz - 2.0 * PI * PI * sx * c2y * z * z + 2.0 * PI * x * y * cz;
let ddiv_dy = PI * PI * cx * cy * sz - 4.0 * PI * PI * cx * s2y * z * z + PI * x * x * cz;
let ddiv_dz = PI * PI * cx * sy * cz + 4.0 * PI * cx * c2y * z - PI * PI * x * x * y * sz;
Vector3::new(
-((LAMBDA + MU) * ddiv_dx + MU * lap_u1),
-((LAMBDA + MU) * ddiv_dy + MU * lap_u2),
-((LAMBDA + MU) * ddiv_dz + MU * lap_u3),
)
}
// ---------------------------------------------------------------------------
// Meshes
// ---------------------------------------------------------------------------
/// Unit square meshed with `n` by `n` `Quad4` elements.
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() {
let x = i as f64 / n as f64;
let y = j as f64 / n as f64;
*slot = mesh.add_node(Node::new_2d(x, y));
}
}
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
}
/// Unit square meshed with `2 n^2` `Tri3` elements.
///
/// Each cell of the same `n` by `n` grid is split along its diagonal, so the
/// node set — and therefore the DOF count — is identical to the `Quad4` mesh
/// at the same `n`. Both triangles are listed counter-clockwise, which keeps
/// the Jacobian determinant positive; a clockwise listing gives a negative
/// determinant that `det.abs()` would hide while the strain-displacement
/// matrix silently changed sign.
fn tri3_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() {
let x = i as f64 / n as f64;
let y = j as f64 / n as f64;
*slot = mesh.add_node(Node::new_2d(x, y));
}
}
for i in 0..n {
for j in 0..n {
for nodes in [
vec![grid[i][j], grid[i + 1][j], grid[i + 1][j + 1]],
vec![grid[i][j], grid[i + 1][j + 1], grid[i][j + 1]],
] {
mesh.add_element(Element::new(ElementType::Tri3, nodes, MaterialId(0)).unwrap())
.unwrap();
}
}
}
mesh
}
/// Unit square meshed with `n` by `n` `Quad8` serendipity elements.
///
/// Nodes sit on a `(2n+1)` by `(2n+1)` lattice with the cell centres left out,
/// which is what makes the element serendipity rather than `Quad9`. Ordering
/// per element is the standard one the shape functions assume: four corners
/// counter-clockwise from `(-1, -1)`, then the four mid-edge nodes starting
/// with the edge between corners 0 and 1.
fn quad8_mesh(n: usize) -> Mesh {
let mut mesh = Mesh::new(2).unwrap();
let lattice = 2 * n + 1;
let mut grid = vec![vec![None; lattice]; lattice];
for (i, column) in grid.iter_mut().enumerate() {
for (j, slot) in column.iter_mut().enumerate() {
if i % 2 == 1 && j % 2 == 1 {
continue; // cell centre: no node in a serendipity element
}
let x = i as f64 / (2 * n) as f64;
let y = j as f64 / (2 * n) as f64;
*slot = Some(mesh.add_node(Node::new_2d(x, y)));
}
}
for i in 0..n {
for j in 0..n {
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
}
/// Unit cube meshed with `n` by `n` by `n` `Hex8` elements.
///
/// Node ordering per element is the standard trilinear one: the four nodes of
/// the `zeta = -1` face counter-clockwise from `(-1, -1, -1)`, then the four
/// of the `zeta = +1` face in the same order.
fn hex8_mesh(n: usize) -> Mesh {
let mut mesh = Mesh::new(3).unwrap();
let mut grid = vec![vec![vec![NodeId(0); n + 1]; n + 1]; n + 1];
for (i, plane) in grid.iter_mut().enumerate() {
for (j, column) in plane.iter_mut().enumerate() {
for (k, slot) in column.iter_mut().enumerate() {
let x = i as f64 / n as f64;
let y = j as f64 / n as f64;
let z = k as f64 / n as f64;
*slot = mesh.add_node(Node::new_3d(x, y, z));
}
}
}
for i in 0..n {
for j in 0..n {
for k in 0..n {
let nodes = vec![
grid[i][j][k],
grid[i + 1][j][k],
grid[i + 1][j + 1][k],
grid[i][j + 1][k],
grid[i][j][k + 1],
grid[i + 1][j][k + 1],
grid[i + 1][j + 1][k + 1],
grid[i][j + 1][k + 1],
];
mesh.add_element(Element::new(ElementType::Hex8, nodes, MaterialId(0)).unwrap())
.unwrap();
}
}
}
mesh
}
// ---------------------------------------------------------------------------
// Solve and measure
// ---------------------------------------------------------------------------
/// Whether a node of the unit square/cube lies on its boundary.
fn on_boundary(p: Vector3<f64>, dim: usize) -> bool {
(0..dim).any(|d| p[d].abs() < 1e-12 || (p[d] - 1.0).abs() < 1e-12)
}
/// Solve the manufactured problem on `mesh` and return the L2 displacement
/// error, integrated with the elements' own quadrature.
///
/// `load_quadrature` and `error_quadrature` are passed straight through to
/// `quadrature_rule`. They are explicit because the right rule is a property
/// of the element: the consistent load vector must be integrated at least as
/// accurately as the stiffness or the load error, not the element, sets the
/// observed order.
fn solve_mms(
mesh: &Mesh,
exact_field: &dyn Fn(Vector3<f64>) -> Vector3<f64>,
force_field: &dyn Fn(Vector3<f64>) -> Vector3<f64>,
load_quadrature: Option<usize>,
error_quadrature: Option<usize>,
) -> f64 {
let dim = mesh.spatial_dimension;
let mut materials = MaterialDatabase::new();
materials.add_material(MaterialId(0), LinearElastic::new(E, NU), None);
let mut dof_numbering =
AdvancedDofNumbering::displacement_only(mesh, DofMappingStrategy::Sequential).unwrap();
let components = [
DofComponent::DisplacementX,
DofComponent::DisplacementY,
DofComponent::DisplacementZ,
];
// Impose the exact solution on the whole boundary.
let mut prescribed = Vec::new();
for (&node_id, node) in &mesh.nodes {
let position = node.position();
if !on_boundary(position, dim) {
continue;
}
let value = exact_field(position);
for (component_index, &component) in components[..dim].iter().enumerate() {
let dof = dof_numbering.get_dof(node_id, component).unwrap();
dof_numbering.constrain_dof(dof).unwrap();
prescribed.push((dof, value[component_index]));
}
}
let simple = dof_numbering.to_dof_numbering();
let assembler = GlobalAssembler::new(AssemblyOptions::default(), materials);
let mut system = assembler.assemble_system(mesh, &simple, 0.0).unwrap();
// Consistent body force: integral of N_i f over each element.
for element in mesh.elements.values() {
let node_coords: Vec<Vector3<f64>> = element
.nodes
.iter()
.map(|id| mesh.get_node(*id).unwrap().position())
.collect();
let fe = StandardFiniteElement::new(element.element_type, node_coords.clone());
let local = ElementMatrixComputer::compute_body_force_vector(
&fe,
&node_coords,
force_field,
load_quadrature,
)
.unwrap();
let mut global_dofs = Vec::new();
for node in &element.nodes {
global_dofs.extend_from_slice(simple.get_node_dofs(*node).unwrap());
}
for (local_index, &global_dof) in global_dofs.iter().enumerate() {
system.force_vector[global_dof] += local[local_index];
}
}
for (dof, value) in prescribed {
system.set_prescribed_value(dof, value).unwrap();
}
let (free_stiffness, free_force) = system.extract_free_system().unwrap();
let properties = SolverFactory::analyze_matrix(&free_stiffness);
let options = SolverOptions::default();
let mut solver = SolverFactory::create_linear_solver(&properties, &options);
let (free_solution, _) = solver
.solve(&free_stiffness, &free_force, &options)
.unwrap();
// Expand to the full DOF vector, with the prescribed values in place.
let mut solution = DVector::zeros(system.dof_numbering.total_dofs);
for (i, &dof) in system.dof_numbering.free_dofs.iter().enumerate() {
solution[dof] = free_solution[i];
}
for (&dof, &value) in system.prescribed_values() {
solution[dof] = value;
}
// L2 error by the elements' own quadrature.
let mut squared = 0.0;
for element in mesh.elements.values() {
let node_coords: Vec<Vector3<f64>> = element
.nodes
.iter()
.map(|id| mesh.get_node(*id).unwrap().position())
.collect();
let fe = StandardFiniteElement::new(element.element_type, node_coords.clone());
let rule = fe.quadrature_rule(error_quadrature).unwrap();
let dofs: Vec<usize> = element
.nodes
.iter()
.flat_map(|node| simple.get_node_dofs(*node).unwrap().to_vec())
.collect();
for point in &rule.points {
let shape = fe.shape_functions(&point.coords).unwrap();
let jacobian = fe.jacobian(&point.coords, &node_coords).unwrap();
let physical = fe.map_to_physical(&point.coords, &node_coords).unwrap();
let mut uh: Vector3<f64> = Vector3::zeros();
for node_index in 0..element.nodes.len() {
let n = shape.value(node_index).unwrap();
for d in 0..dim {
uh[d] += n * solution[dofs[node_index * dim + d]];
}
}
let exact_value = exact_field(physical.coords);
let weight = point.weight * jacobian.determinant().abs();
let mut pointwise = 0.0;
for d in 0..dim {
pointwise += (uh[d] - exact_value[d]).powi(2);
}
squared += pointwise * weight;
}
}
squared.sqrt()
}
/// L2 error of the **nodal interpolant** of the exact field on `mesh`.
///
/// This is the best the element space can do at this resolution, up to a
/// constant: no choice of nodal values reproduces the exact field more closely
/// than a few times this. It touches no DOF numbering, no assembly and no
/// solver, so comparing the computed error against it isolates "how well can
/// this space represent the answer" from "did the machinery find it".
fn interpolation_error(
mesh: &Mesh,
exact_field: &dyn Fn(Vector3<f64>) -> Vector3<f64>,
error_quadrature: Option<usize>,
) -> f64 {
let dim = mesh.spatial_dimension;
let mut squared = 0.0;
for element in mesh.elements.values() {
let node_coords: Vec<Vector3<f64>> = element
.nodes
.iter()
.map(|id| mesh.get_node(*id).unwrap().position())
.collect();
let fe = StandardFiniteElement::new(element.element_type, node_coords.clone());
let rule = fe.quadrature_rule(error_quadrature).unwrap();
for point in &rule.points {
let shape = fe.shape_functions(&point.coords).unwrap();
let jacobian = fe.jacobian(&point.coords, &node_coords).unwrap();
let physical = fe.map_to_physical(&point.coords, &node_coords).unwrap();
let mut interpolated: Vector3<f64> = Vector3::zeros();
for (node_index, coord) in node_coords.iter().enumerate() {
let n = shape.value(node_index).unwrap();
let nodal = exact_field(*coord);
for d in 0..dim {
interpolated[d] += n * nodal[d];
}
}
let exact_value = exact_field(physical.coords);
let weight = point.weight * jacobian.determinant().abs();
let mut pointwise = 0.0;
for d in 0..dim {
pointwise += (interpolated[d] - exact_value[d]).powi(2);
}
squared += pointwise * weight;
}
}
squared.sqrt()
}
/// Observed order of accuracy between consecutive refinement levels.
///
/// Computed from the mesh-size ratio rather than assuming successive halving,
/// so a non-doubling ladder still reads correctly.
fn observed_orders(resolutions: &[usize], errors: &[f64]) -> Vec<f64> {
errors
.windows(2)
.zip(resolutions.windows(2))
.map(|(e, n)| (e[0] / e[1]).ln() / (n[1] as f64 / n[0] as f64).ln())
.collect()
}
/// Print the whole ladder so the measurement is auditable rather than merely
/// asserted.
fn report(label: &str, resolutions: &[usize], errors: &[f64], rates: &[f64]) {
println!(" {label}");
for (i, &n) in resolutions.iter().enumerate() {
let rate = if i == 0 {
String::from(" -")
} else {
format!("{:5.2}", rates[i - 1])
};
println!(
" n = {n:3} L2 error = {:.6e} observed order = {rate}",
errors[i]
);
}
}
/// Run a refinement ladder and assert every observed order lies in `window`.
fn assert_order(
label: &str,
resolutions: &[usize],
errors: &[f64],
expected: f64,
window: (f64, f64),
) {
let rates = observed_orders(resolutions, errors);
report(label, resolutions, errors, &rates);
for (i, &rate) in rates.iter().enumerate() {
assert!(
rate > window.0 && rate < window.1,
"{label}: refinement {} -> {}: observed order {rate:.3}, expected {expected} \
(accepted {:.2}..{:.2}). Errors: {errors:?}",
resolutions[i],
resolutions[i + 1],
window.0,
window.1,
);
}
}
// ---------------------------------------------------------------------------
// Fixture verification: the body force really is -div(sigma(u))
// ---------------------------------------------------------------------------
//
// A wrong body force is the failure mode that looks most like a wrong solver:
// the solution converges, just not to the field being compared against, and
// the observed order drops. These two tests pin the fixture independently of
// every line of library code, by differentiating the *stress* numerically and
// comparing with the analytic expressions above. Fourth-order central
// differences at h = 1e-2 leave a truncation error near 1e-5 on quantities of
// order 10-100 and a round-off error near 1e-12, so a genuine mismatch — a
// dropped term, a sign, a wrong Lamé constant — is orders of magnitude larger
// than the noise floor.
/// Fourth-order central difference of `f` along axis `dir`.
fn d_dx(f: &dyn Fn(Vector3<f64>) -> f64, p: Vector3<f64>, dir: usize, h: f64) -> f64 {
let at = |steps: f64| {
let mut q = p;
q[dir] += steps * h;
f(q)
};
(at(-2.0) - 8.0 * at(-1.0) + 8.0 * at(1.0) - at(2.0)) / (12.0 * h)
}
/// `-div(sigma)` computed by numerical differentiation of a stress function.
fn numerical_force(
stress: &dyn Fn(Vector3<f64>, usize, usize) -> f64,
p: Vector3<f64>,
dim: usize,
h: f64,
) -> Vector3<f64> {
let mut f = Vector3::zeros();
for i in 0..dim {
let mut divergence = 0.0;
for j in 0..dim {
divergence += d_dx(&|q| stress(q, i, j), p, j, h);
}
f[i] = -divergence;
}
f
}
#[test]
fn body_force_2d_matches_stress_divergence() {
let c = E / (1.0 - NU * NU);
let strain = |q: Vector3<f64>, i: usize, j: usize| {
let ui = |r: Vector3<f64>| exact_2d(r)[i];
let uj = |r: Vector3<f64>| exact_2d(r)[j];
0.5 * (d_dx(&ui, q, j, 1e-2) + d_dx(&uj, q, i, 1e-2))
};
// Plane stress, exactly as `elasticity_matrix` builds it for dim 2.
let stress = |q: Vector3<f64>, i: usize, j: usize| {
let (exx, eyy) = (strain(q, 0, 0), strain(q, 1, 1));
match (i, j) {
(0, 0) => c * (exx + NU * eyy),
(1, 1) => c * (NU * exx + eyy),
_ => 2.0 * MU * strain(q, i, j),
}
};
let mut worst: f64 = 0.0;
for &x in &[0.13, 0.37, 0.5, 0.71, 0.94] {
for &y in &[0.09, 0.28, 0.5, 0.66, 0.87] {
let p = Vector3::new(x, y, 0.0);
let numeric = numerical_force(&stress, p, 2, 1e-2);
let analytic = force_2d(p);
worst = worst.max((numeric - analytic).norm());
}
}
assert!(
worst < 1e-4,
"analytic plane-stress body force disagrees with the numerical \
divergence of sigma by {worst:.3e}; the manufactured fixture is wrong"
);
}
#[test]
fn body_force_3d_matches_stress_divergence() {
let strain = |q: Vector3<f64>, i: usize, j: usize| {
let ui = |r: Vector3<f64>| exact_3d(r)[i];
let uj = |r: Vector3<f64>| exact_3d(r)[j];
0.5 * (d_dx(&ui, q, j, 1e-2) + d_dx(&uj, q, i, 1e-2))
};
// sigma = lambda tr(eps) I + 2 mu eps, the lambda/mu form of the 6x6
// matrix `elasticity_matrix` builds for spatial_dim = 3.
let stress = |q: Vector3<f64>, i: usize, j: usize| {
let trace: f64 = (0..3).map(|k| strain(q, k, k)).sum();
let volumetric = if i == j { LAMBDA * trace } else { 0.0 };
volumetric + 2.0 * MU * strain(q, i, j)
};
let mut worst: f64 = 0.0;
for &x in &[0.17, 0.5, 0.83] {
for &y in &[0.11, 0.5, 0.79] {
for &z in &[0.23, 0.5, 0.91] {
let p = Vector3::new(x, y, z);
let numeric = numerical_force(&stress, p, 3, 1e-2);
let analytic = body_force_3d(p);
worst = worst.max((numeric - analytic).norm());
}
}
}
assert!(
worst < 1e-3,
"analytic 3-D body force disagrees with the numerical divergence of \
sigma by {worst:.3e}; the manufactured fixture is wrong"
);
}
/// The triangle rule actually used below integrates the reference triangle.
///
/// `QuadratureRule::triangle(2)` is the rule the `Tri3` ladder integrates its
/// error with, and its weights must sum to the reference area 1/2. This is
/// checked because the *order-3* triangle rule in the same function does not:
/// its weights sum to 1/4, half of what they should be. That defect is
/// reported rather than fixed here — this file owns no library code — and the
/// `Tri3` ladder avoids order 3 for that reason.
#[test]
fn triangle_error_quadrature_integrates_the_reference_area() {
let element = StandardFiniteElement::new(
ElementType::Tri3,
vec![
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(1.0, 0.0, 0.0),
Vector3::new(0.0, 1.0, 0.0),
],
);
let rule = element.quadrature_rule(Some(2)).unwrap();
let area: f64 = rule.points.iter().map(|p| p.weight).sum();
assert!(
(area - 0.5).abs() < 1e-12,
"the 3-point triangle rule integrates the reference triangle to {area}, \
not 1/2, so every integral taken with it is scaled wrongly"
);
}
// ---------------------------------------------------------------------------
// Order-of-accuracy ladders
// ---------------------------------------------------------------------------
/// `Quad4`: bilinear, theoretical L2 displacement rate 2.
///
/// A rate near 1 means something in the chain is only first-order accurate; a
/// rate near 0 means the solution is not converging to the exact one at all,
/// which is what every stub found in this crate would produce.
#[test]
fn quad4_displacement_converges_at_second_order() {
// 8 -> 16 -> 32 gives two independent rate estimates, which is enough to
// establish the order. A 64 x 64 rung adds a third at roughly ten times
// the cost, and it reads 2.00 as well.
let resolutions = [8usize, 16, 32];
let errors: Vec<f64> = resolutions
.iter()
.map(|&n| solve_mms(&quad4_mesh(n), &exact_2d, &force_2d, None, Some(3)))
.collect();
assert_order(
"Quad4 (bilinear quad, 2-D)",
&resolutions,
&errors,
2.0,
(1.8, 2.2),
);
}
/// `Tri3`: linear, theoretical L2 displacement rate 2.
///
/// The same node set as the `Quad4` ladder, so the two are directly
/// comparable: a constant-strain triangle should reach the same rate with a
/// larger constant.
///
/// The load vector is integrated with the 3-point (degree-2) triangle rule
/// rather than the 1-point default. `Tri3` stiffness is exact at one point
/// because `B` is constant, but the consistent load `∫ N_i f` is not: the
/// centroid rule is exact only through degree 1, and a load integrated less
/// accurately than the element interpolates is a variational crime that caps
/// the observed order below the element's own.
#[test]
fn tri3_displacement_converges_at_second_order() {
let resolutions = [8usize, 16, 32];
let errors: Vec<f64> = resolutions
.iter()
.map(|&n| solve_mms(&tri3_mesh(n), &exact_2d, &force_2d, Some(2), Some(2)))
.collect();
assert_order(
"Tri3 (linear triangle, 2-D)",
&resolutions,
&errors,
2.0,
(1.8, 2.2),
);
}
/// `Quad8`: quadratic serendipity, theoretical L2 displacement rate 3.
///
/// This is the rung that a blanket "second order" assertion would not catch.
/// The serendipity space is complete to degree 2, so the L2 displacement error
/// falls as `h^3`; an eight-node element that had, say, mis-ordered its
/// mid-edge nodes would still converge — at rate 2, or worse — and every
/// symmetry and patch check would still pass.
#[test]
fn quad8_displacement_converges_at_third_order() {
// Third order burns through the error fast: by n = 16 the discretisation
// error is near 1e-6, so a fourth rung would start competing with the
// solver's own round-off rather than measuring the element.
let resolutions = [4usize, 8, 16];
let errors: Vec<f64> = resolutions
.iter()
.map(|&n| solve_mms(&quad8_mesh(n), &exact_2d, &force_2d, Some(4), Some(4)))
.collect();
assert_order(
"Quad8 (serendipity quad, 2-D)",
&resolutions,
&errors,
3.0,
(2.7, 3.3),
);
}
/// `Hex8`: trilinear, theoretical L2 displacement rate 2, in three dimensions.
///
/// This exercises the parts of the chain the 2-D ladders cannot reach: the
/// 6-component strain-displacement matrix, the 6x6 `lambda`/`mu` constitutive
/// matrix with its full off-diagonal block, the 3x3 Jacobian and its inverse,
/// and three displacement components per node through DOF numbering and
/// assembly.
#[test]
fn hex8_displacement_converges_at_second_order() {
// Four rungs, because the cost is dominated by the last one and the cheap
// rungs are nearly free. `SolverFactory` picks a **dense** LDL^T
// factorisation of the free-DOF block, which in three dimensions grows as
// 3 (n-1)^3: 81 unknowns at n = 4, 1029 at n = 8, 2187 at n = 10 — and
// 3993 at n = 12, which alone takes 73 s against 6 s for this entire
// ladder. That cubic wall, not the measurement, is what caps the mesh; the
// observed order is already within 2% of the theoretical value by n = 10.
let resolutions = [4usize, 6, 8, 10];
let errors: Vec<f64> = resolutions
.iter()
.map(|&n| solve_mms(&hex8_mesh(n), &exact_3d, &body_force_3d, Some(3), Some(3)))
.collect();
assert_order(
"Hex8 (trilinear hexahedron, 3-D)",
&resolutions,
&errors,
2.0,
(1.8, 2.2),
);
}
// ---------------------------------------------------------------------------
// Magnitude checks
// ---------------------------------------------------------------------------
/// The error must actually be small, not merely converging at the right rate.
///
/// A scheme converging at second order toward the wrong answer would satisfy
/// the rate test alone.
#[test]
fn quad4_error_is_small_on_a_fine_mesh() {
let error = solve_mms(&quad4_mesh(32), &exact_2d, &force_2d, None, Some(3));
assert!(
error < 1e-3,
"L2 error {error:.3e} on a 32x32 mesh is too large for a second-order \
method on a smooth solution"
);
}
/// The quadratic element must be materially more accurate than the linear one
/// on the same number of elements.
///
/// Rate alone does not establish this: an element could converge at order 3
/// from a starting error so large that it is still worse than `Quad4` on every
/// mesh anyone would use. On a 8 x 8 grid the serendipity element should be at
/// least an order of magnitude closer.
#[test]
fn quad8_beats_quad4_on_the_same_grid() {
let serendipity = solve_mms(&quad8_mesh(8), &exact_2d, &force_2d, Some(4), Some(4));
let bilinear = solve_mms(&quad4_mesh(8), &exact_2d, &force_2d, None, Some(3));
println!(
" Quad8 = {serendipity:.6e} Quad4 = {bilinear:.6e} ratio = {:.1}x",
bilinear / serendipity
);
assert!(
serendipity < bilinear / 10.0,
"Quad8 L2 error {serendipity:.3e} is not an order of magnitude below \
Quad4's {bilinear:.3e} on the same 8x8 grid"
);
}
/// The computed 3-D solution must be near the best its own space allows.
///
/// A rate test alone is satisfied by a scheme converging at second order
/// toward the wrong field. The 2-D ladder pins that down with an absolute
/// threshold, but an absolute threshold in three dimensions is just a number
/// someone believed — this manufactured field is larger and rougher than the
/// 2-D one, and 12 x 12 x 12 is a much coarser mesh than 32 x 32.
///
/// The theoretical statement is stronger and needs no such constant. Céa's
/// lemma plus the AubinNitsche duality argument bound the computed error by a
/// **mesh-independent** multiple of the best approximation in the space, of
/// which the nodal interpolant is a representative:
///
/// ```text
/// ||u - u_h||_L2 <= C ||u - I_h u||_L2, C independent of h
/// ```
///
/// So this checks the ratio itself, at two resolutions, and requires that it
/// stay small and stop growing. A solver converging to the wrong field, or one
/// that only limps toward the right one, shows up as a ratio that climbs with
/// refinement; a zeroed element matrix or a dropped Dirichlet coupling shows
/// up as a ratio far above 1 at both.
#[test]
fn hex8_error_stays_near_the_best_its_space_allows() {
let mut ratios = Vec::new();
for n in [4usize, 8] {
let mesh = hex8_mesh(n);
let computed = solve_mms(&mesh, &exact_3d, &body_force_3d, Some(3), Some(3));
let best = interpolation_error(&mesh, &exact_3d, Some(3));
println!(
" n = {n:3} computed = {computed:.6e} interpolant = {best:.6e} \
ratio = {:.3}",
computed / best
);
ratios.push(computed / best);
}
for (n, &ratio) in [4usize, 8].iter().zip(&ratios) {
assert!(
ratio < 3.0,
"Hex8 at n = {n}: computed L2 error is {ratio:.2}x the nodal \
interpolation error. Quasi-optimality bounds this by a constant of \
order one; a value this large means the solve, not the element \
space, is the limit"
);
}
assert!(
ratios[1] < ratios[0] * 1.2,
"Hex8: the computed-to-interpolant error ratio grew from {:.3} to \
{:.3} under refinement. That constant is h-independent in theory, so a \
growing one means the discretisation is losing ground as the mesh is \
refined",
ratios[0],
ratios[1]
);
}