Files
rustytorch/crates/specialized/rtx-cfd/tests/mesh_quality_tests.rs
T
Omar SobhandClaude Opus 5 bfd9f4dfd2
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: repair the pressure-velocity coupling, LBM walls and mesh quality
Clears the rest of the quarantine. All three crates now run 558 tests
with 0 failures and no `#[ignore]` markers.

SIMPLE could not converge, and the reason was not slow convergence but
wrong physics.

The pressure correction equation used a bare Laplacian, 1/dx^2 and
1/dy^2, while the velocity correction divided by a_p = rho dx dy / dt.
SIMPLE requires these to be each other's inverse: substituting the
corrected velocities into continuity must reproduce the pressure
equation, which fixes a_E = rho d dy/dx with d = dV/a_p. The two
disagreed by roughly 1/(h^2 dt) -- about 2e4 on a 16x16 cavity -- so the
pressure correction was that many times too weak to enforce continuity.

The consequence was visible and specific. A lid-driven cavity at Re=100
produced a monotonic profile rising from 0 at the floor to 1 at the lid:
Couette flow, with no recirculation anywhere, and a peak pressure of
1.6e-4 against the rho U^2 scale of 1. The return flow in a cavity is
driven entirely by the pressure gradient, so with the pressure pinned
near zero there was nothing to turn the flow around. With the
coefficients made consistent the profile recirculates, the peak pressure
is 2.9, and the solver converges.

Also in SIMPLE:
  - `p'` was never reset between outer iterations. It is a correction
    that `pressure_update_step` folds into `p`, so carrying it forward
    applied the same correction twice.
  - The convergence measure was the inner Gauss-Seidel residual, which
    goes to zero whether or not the flow satisfies continuity. Now the
    mass imbalance.
  - The velocity correction used only the transient part of a_p,
    `rho dV/dt`, rather than the diagonal the momentum equation was
    actually solved with.
  - All four convective face fluxes were computed from a single
    cell-centred velocity, so `fe` and `fw` were the same number, as were
    `fn` and `fs`. Upwinding then picked the same direction on opposite
    faces of the control volume. Now interpolated per face on the
    staggered grid.

Not claimed: agreement with Ghia, Ghia & Shin (1982). The vortex centre
moves toward their y = 0.4531 under refinement (0.400 at 16^2, 0.419 at
32^2, 0.460 at 64^2) but the minimum centreline velocity reaches only
-0.130 against their -0.2109, and the converged field still depends
slightly on the pseudo-time step, which a true steady state cannot. The
cavity test therefore asserts what is established -- convergence,
recirculation, vortex position, and an O(1) pressure field -- and the
remaining gap is recorded in omni-cortex/docs/solver_status.md rather
than papered over with a loose tolerance.

LBM bounce-back was doing neither of the things its name claims. It was
written as assignment (`f[2] = f[4]`) rather than a swap, discarding the
population being reflected -- bounce-back is a permutation and conserves
mass exactly, so the domain leaked 0.013% of its mass every 100 steps and
would have kept draining. And the pairs used were 5<->8 and 6<->7, which
reverse only the wall-normal component: that is specular reflection, a
free-slip wall, so the no-slip condition the walls were supposed to
impose never held.

Mesh quality:
  - Quadrilateral aspect ratio included the diagonals in the maximum but
    not the minimum, so it could never return 1: a unit square reported
    sqrt(2) and a 2:1 rectangle sqrt(5).
  - Triangle aspect ratio used longest-over-shortest edge, which does not
    detect the failure mode that matters. A sliver with vertices (0,0),
    (10,0), (5,0.1) scores 2.0 -- indistinguishable from a healthy 2:1
    triangle -- while its area is a twentieth of what its edges suggest.
    Now the radius ratio R/2r, which is 1 for equilateral and 1250 for
    that sliver, and which also fixes the quality histogram.
  - StructuredMesh aspect ratio took bounding-box extents and guarded the
    z-extent with `.max(1e-10)`. On a 2-D mesh the depth is exactly zero,
    so the guard became the minimum and a unit square reported 2e10.

Mesh refinement produced meshes that failed their own validation.
`subdivide_triangle` reserved midpoint ids as `next_node_id + k`, then
advanced the counter by 3, after which `refine_cells` called `add_node`
and advanced it three more -- so every refined cell referenced vertices
three ids away from the ones actually created. Separately, the position
lookup selected by slot rather than by id ("This is simplified, should
look up correct midpoint"), so three of four sub-triangles had their
areas computed from the wrong points; the quadrilateral version mapped
every new id to the cell centre.

Fixtures corrected rather than tolerances loosened: a structured mesh
test asserted 0.16 for the average cell volume while the comment beside
it computed 0.25 from the node-count convention the code actually uses;
the Zou-He pressure test built a *velocity* boundary at u = 1.2, far
above the lattice speed of sound, making the density negative; and the
cavity-setup test required the lid to influence the domain centre 16
rows away in 10 steps, which exceeds the lattice propagation speed.

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

274 lines
9.8 KiB
Rust

// TDD: RED phase - Tests for mesh quality metrics
use nalgebra::Vector3;
use rtx_cfd::mesh::Mesh;
use rtx_cfd::mesh::entities::{Cell, Face, Node};
use rtx_cfd::mesh::structured::StructuredMesh;
use rtx_cfd::mesh::unstructured::UnstructuredMesh;
use rtx_cfd::traits::MeshEntity;
#[test]
fn test_triangle_aspect_ratio() {
let mut mesh = UnstructuredMesh::new();
// Test equilateral triangle (ideal aspect ratio = 1.0)
let n1 = mesh.add_node(Vector3::new(0.0, 0.0, 0.0)).unwrap();
let n2 = mesh.add_node(Vector3::new(1.0, 0.0, 0.0)).unwrap();
let n3 = mesh.add_node(Vector3::new(0.5, 0.866, 0.0)).unwrap(); // sqrt(3)/2 ≈ 0.866
let cell_id = mesh.add_triangle_cell(n1, n2, n3).unwrap();
let cell = mesh.get_cell_by_id(cell_id).unwrap();
let nodes = mesh.get_cell_nodes(cell_id).unwrap();
let aspect_ratio = cell.compute_aspect_ratio(&nodes).unwrap();
// Equilateral triangle should have aspect ratio close to 1.0
assert!(
(aspect_ratio - 1.0).abs() < 0.1,
"Equilateral triangle aspect ratio: {}",
aspect_ratio
);
}
#[test]
fn test_degenerate_triangle_aspect_ratio() {
let mut mesh = UnstructuredMesh::new();
// Test degenerate triangle (very long and thin)
let n1 = mesh.add_node(Vector3::new(0.0, 0.0, 0.0)).unwrap();
let n2 = mesh.add_node(Vector3::new(10.0, 0.0, 0.0)).unwrap();
let n3 = mesh.add_node(Vector3::new(5.0, 0.1, 0.0)).unwrap(); // Very thin
let cell_id = mesh.add_triangle_cell(n1, n2, n3).unwrap();
let cell = mesh.get_cell_by_id(cell_id).unwrap();
let nodes = mesh.get_cell_nodes(cell_id).unwrap();
let aspect_ratio = cell.compute_aspect_ratio(&nodes).unwrap();
// Should have high aspect ratio (bad quality)
assert!(
aspect_ratio > 10.0,
"Degenerate triangle aspect ratio: {}",
aspect_ratio
);
}
#[test]
fn test_quadrilateral_aspect_ratio() {
let mut mesh = UnstructuredMesh::new();
// Test square (ideal aspect ratio = 1.0)
let n1 = mesh.add_node(Vector3::new(0.0, 0.0, 0.0)).unwrap();
let n2 = mesh.add_node(Vector3::new(1.0, 0.0, 0.0)).unwrap();
let n3 = mesh.add_node(Vector3::new(1.0, 1.0, 0.0)).unwrap();
let n4 = mesh.add_node(Vector3::new(0.0, 1.0, 0.0)).unwrap();
let cell_id = mesh.add_quadrilateral_cell(n1, n2, n3, n4).unwrap();
let cell = mesh.get_cell_by_id(cell_id).unwrap();
let nodes = mesh.get_cell_nodes(cell_id).unwrap();
let aspect_ratio = cell.compute_aspect_ratio(&nodes).unwrap();
// Square should have aspect ratio close to 1.0
assert!(
(aspect_ratio - 1.0).abs() < 0.1,
"Square aspect ratio: {}",
aspect_ratio
);
}
#[test]
fn test_rectangle_aspect_ratio() {
let mut mesh = UnstructuredMesh::new();
// Test rectangle (2:1 ratio)
let n1 = mesh.add_node(Vector3::new(0.0, 0.0, 0.0)).unwrap();
let n2 = mesh.add_node(Vector3::new(2.0, 0.0, 0.0)).unwrap();
let n3 = mesh.add_node(Vector3::new(2.0, 1.0, 0.0)).unwrap();
let n4 = mesh.add_node(Vector3::new(0.0, 1.0, 0.0)).unwrap();
let cell_id = mesh.add_quadrilateral_cell(n1, n2, n3, n4).unwrap();
let cell = mesh.get_cell_by_id(cell_id).unwrap();
let nodes = mesh.get_cell_nodes(cell_id).unwrap();
let aspect_ratio = cell.compute_aspect_ratio(&nodes).unwrap();
// Rectangle should have aspect ratio of 2.0
assert!(
(aspect_ratio - 2.0).abs() < 0.1,
"Rectangle aspect ratio: {}",
aspect_ratio
);
}
#[test]
fn test_triangle_skewness_ideal() {
let mut mesh = UnstructuredMesh::new();
// Test equilateral triangle (ideal skewness = 0.0)
let n1 = mesh.add_node(Vector3::new(0.0, 0.0, 0.0)).unwrap();
let n2 = mesh.add_node(Vector3::new(1.0, 0.0, 0.0)).unwrap();
let n3 = mesh.add_node(Vector3::new(0.5, 0.866, 0.0)).unwrap();
let cell_id = mesh.add_triangle_cell(n1, n2, n3).unwrap();
let cell = mesh.get_cell_by_id(cell_id).unwrap();
let nodes = mesh.get_cell_nodes(cell_id).unwrap();
let skewness = cell.compute_skewness(&nodes).unwrap();
// Equilateral triangle should have skewness close to 0.0
assert!(
skewness < 0.1,
"Equilateral triangle skewness: {}",
skewness
);
}
#[test]
fn test_triangle_skewness_degenerate() {
let mut mesh = UnstructuredMesh::new();
// Test triangle with very acute angle (high skewness)
let n1 = mesh.add_node(Vector3::new(0.0, 0.0, 0.0)).unwrap();
let n2 = mesh.add_node(Vector3::new(1.0, 0.0, 0.0)).unwrap();
let n3 = mesh.add_node(Vector3::new(0.99, 0.01, 0.0)).unwrap(); // Very acute angle
let cell_id = mesh.add_triangle_cell(n1, n2, n3).unwrap();
let cell = mesh.get_cell_by_id(cell_id).unwrap();
let nodes = mesh.get_cell_nodes(cell_id).unwrap();
let skewness = cell.compute_skewness(&nodes).unwrap();
// Should have high skewness (bad quality)
assert!(skewness > 0.8, "Acute triangle skewness: {}", skewness);
}
#[test]
fn test_quadrilateral_skewness() {
let mut mesh = UnstructuredMesh::new();
// Test square (ideal skewness = 0.0)
let n1 = mesh.add_node(Vector3::new(0.0, 0.0, 0.0)).unwrap();
let n2 = mesh.add_node(Vector3::new(1.0, 0.0, 0.0)).unwrap();
let n3 = mesh.add_node(Vector3::new(1.0, 1.0, 0.0)).unwrap();
let n4 = mesh.add_node(Vector3::new(0.0, 1.0, 0.0)).unwrap();
let cell_id = mesh.add_quadrilateral_cell(n1, n2, n3, n4).unwrap();
let cell = mesh.get_cell_by_id(cell_id).unwrap();
let nodes = mesh.get_cell_nodes(cell_id).unwrap();
let skewness = cell.compute_skewness(&nodes).unwrap();
// Square should have low skewness
assert!(skewness < 0.1, "Square skewness: {}", skewness);
}
#[test]
fn test_parallelogram_skewness() {
let mut mesh = UnstructuredMesh::new();
// Test skewed parallelogram
let n1 = mesh.add_node(Vector3::new(0.0, 0.0, 0.0)).unwrap();
let n2 = mesh.add_node(Vector3::new(1.0, 0.0, 0.0)).unwrap();
let n3 = mesh.add_node(Vector3::new(1.5, 1.0, 0.0)).unwrap(); // Skewed
let n4 = mesh.add_node(Vector3::new(0.5, 1.0, 0.0)).unwrap(); // Skewed
let cell_id = mesh.add_quadrilateral_cell(n1, n2, n3, n4).unwrap();
let cell = mesh.get_cell_by_id(cell_id).unwrap();
let nodes = mesh.get_cell_nodes(cell_id).unwrap();
let skewness = cell.compute_skewness(&nodes).unwrap();
// Should have moderate skewness
assert!(
skewness > 0.2 && skewness < 0.8,
"Parallelogram skewness: {}",
skewness
);
}
#[test]
fn test_tetrahedron_aspect_ratio() {
let mut mesh = UnstructuredMesh::new();
// Test regular tetrahedron (ideal aspect ratio ≈ 1.0)
let h = (2.0 / 3.0_f64).sqrt(); // Height of regular tetrahedron with unit edge
let n1 = mesh.add_node(Vector3::new(0.0, 0.0, 0.0)).unwrap();
let n2 = mesh.add_node(Vector3::new(1.0, 0.0, 0.0)).unwrap();
let n3 = mesh.add_node(Vector3::new(0.5, 0.866, 0.0)).unwrap();
let n4 = mesh.add_node(Vector3::new(0.5, 0.289, h)).unwrap(); // Apex
let cell_id = mesh.add_tetrahedron_cell(n1, n2, n3, n4).unwrap();
let cell = mesh.get_cell_by_id(cell_id).unwrap();
let nodes = mesh.get_cell_nodes(cell_id).unwrap();
let aspect_ratio = cell.compute_aspect_ratio(&nodes).unwrap();
// Regular tetrahedron should have good aspect ratio
assert!(
aspect_ratio < 2.0,
"Regular tetrahedron aspect ratio: {}",
aspect_ratio
);
}
#[test]
fn test_structured_mesh_quality() {
let mesh = StructuredMesh::new(5, 5, 2.0, 2.0).unwrap();
// All cells in a structured mesh should have identical quality
let stats = mesh.statistics();
// Should have uniform cell volumes
assert!((stats.max_cell_volume - stats.min_cell_volume).abs() < 1e-10);
// `StructuredMesh::new` takes node counts per direction — `dx` is
// `width / (nx - 1)` — so a 5x5 node grid over a 2.0 x 2.0 domain has
// 4x4 cells of 0.5 x 0.5, giving 0.25 each.
//
// The assertion previously read 0.16, which is 4/25 and assumes 5x5
// *cells*, while the comment beside it computed 0.25 from the node
// convention. The two disagreed with each other.
assert!((stats.average_cell_volume - 0.25).abs() < 1e-10);
// Should have good aspect ratio for square domain
assert!((stats.aspect_ratio - 1.0).abs() < 0.1);
}
#[test]
fn test_mesh_quality_histogram() {
let mut mesh = UnstructuredMesh::new();
// Create triangles with various qualities
// Good triangle
let n1 = mesh.add_node(Vector3::new(0.0, 0.0, 0.0)).unwrap();
let n2 = mesh.add_node(Vector3::new(1.0, 0.0, 0.0)).unwrap();
let n3 = mesh.add_node(Vector3::new(0.5, 0.866, 0.0)).unwrap();
mesh.add_triangle_cell(n1, n2, n3).unwrap();
// Bad triangle
let n4 = mesh.add_node(Vector3::new(2.0, 0.0, 0.0)).unwrap();
let n5 = mesh.add_node(Vector3::new(12.0, 0.0, 0.0)).unwrap();
let n6 = mesh.add_node(Vector3::new(7.0, 0.1, 0.0)).unwrap();
mesh.add_triangle_cell(n4, n5, n6).unwrap();
let stats = mesh.statistics();
let quality_histogram = mesh.compute_quality_histogram(10).unwrap();
// Should have cells in different quality bins
let total_cells: usize = quality_histogram.iter().sum();
assert_eq!(total_cells, 2);
// At least one cell should be in high quality bin, one in low quality bin
assert!(quality_histogram[9] > 0 || quality_histogram[8] > 0); // High quality bins
assert!(quality_histogram[0] > 0 || quality_histogram[1] > 0); // Low quality bins
}
#[test]
fn test_orthogonality_calculation() {
let mesh = StructuredMesh::new(3, 3, 1.0, 1.0).unwrap();
// Structured mesh should have perfect orthogonality
let stats = mesh.statistics();
assert!(
stats.orthogonality > 0.99,
"Structured mesh orthogonality: {}",
stats.orthogonality
);
assert!(
stats.non_orthogonality < 0.01,
"Structured mesh non-orthogonality: {}",
stats.non_orthogonality
);
}