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

123 lines
3.7 KiB
Rust

//! Every quadrature rule must integrate 1 to the measure of its reference
//! domain.
//!
//! This is the cheapest possible invariant on a quadrature rule, it holds for
//! every order and every element family, and it is decisive: a rule whose
//! weights do not sum correctly scales every integral computed with it by a
//! constant factor. Nothing downstream can detect that — the stiffness matrix
//! stays symmetric, the mass matrix stays positive definite, rigid-body modes
//! stay exact. Only the total is wrong.
//!
//! Two rules failed it when it was first written: `triangle(3)` summed to 0.25
//! against a reference area of 0.5, and `tetrahedron(3)` to 1/36 against a
//! reference volume of 1/6. Both carried a spurious division by the reference
//! measure applied to weights that were already tabulated for it.
use rtx_fea::elements::quadrature::QuadratureRule;
fn weight_sum(rule: &QuadratureRule) -> f64 {
rule.points.iter().map(|p| p.weight).sum()
}
fn check(name: &str, rule: &QuadratureRule, expected: f64) {
assert!(
!rule.points.is_empty(),
"{name}: rule has no points — integration loops over it would silently \
produce zero rather than fail"
);
let sum = weight_sum(rule);
assert!(
(sum - expected).abs() < 1e-12,
"{name}: weights sum to {sum}, expected the reference measure {expected} \
(ratio {:.6})",
sum / expected,
);
}
/// The 1-D reference domain is [-1, 1], measure 2.
#[test]
fn line_rules_integrate_the_reference_length() {
for order in 1..=5 {
check(
&format!("line({order})"),
&QuadratureRule::line(order).unwrap(),
2.0,
);
}
}
/// The reference triangle has area 1/2.
#[test]
fn triangle_rules_integrate_the_reference_area() {
for order in 1..=3 {
check(
&format!("triangle({order})"),
&QuadratureRule::triangle(order).unwrap(),
0.5,
);
}
}
/// The reference quadrilateral is [-1, 1]^2, area 4.
#[test]
fn quadrilateral_rules_integrate_the_reference_area() {
for order in 1..=5 {
check(
&format!("quadrilateral({order})"),
&QuadratureRule::quadrilateral(order).unwrap(),
4.0,
);
}
}
/// The reference tetrahedron has volume 1/6.
#[test]
fn tetrahedron_rules_integrate_the_reference_volume() {
for order in 1..=3 {
check(
&format!("tetrahedron({order})"),
&QuadratureRule::tetrahedron(order).unwrap(),
1.0 / 6.0,
);
}
}
/// The reference hexahedron is [-1, 1]^3, volume 8.
#[test]
fn hexahedron_rules_integrate_the_reference_volume() {
for order in 1..=5 {
check(
&format!("hexahedron({order})"),
&QuadratureRule::hexahedron(order).unwrap(),
8.0,
);
}
}
/// Gauss-Legendre of `n` points integrates polynomials of degree `2n - 1`
/// exactly, which the weight sum alone does not check.
#[test]
fn gauss_legendre_is_exact_to_its_nominal_degree() {
for n in 1..=5usize {
let rule = QuadratureRule::gauss_legendre_1d(n).unwrap();
let degree = 2 * n - 1;
// Integral of x^d over [-1, 1] is 0 for odd d, 2/(d+1) for even d.
for d in 0..=degree {
let numeric: f64 = rule
.points
.iter()
.map(|p| p.weight * p.coords.xi().powi(d as i32))
.sum();
let exact = if d % 2 == 1 {
0.0
} else {
2.0 / (d as f64 + 1.0)
};
assert!(
(numeric - exact).abs() < 1e-12,
"gauss_legendre_1d({n}) integrating x^{d}: got {numeric}, exact {exact}"
);
}
}
}