rtx-cfd: multigrid-PCG projection — 30x faster, same answers — and the CFD1 refinement study
Performance Benchmarks / Run Benchmarks (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
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

Falsifier 4 of the Turek–Hron geometry decision fired (the SOR projection
cost 0.09 s/step at 250x41 and an hour per run at 5 mm); this answers it.

solvers::incompressible::poisson: PoissonProblem (cell-centred five-point
SPD operator as per-cell face coefficients + Dirichlet diagonal extra +
active mask) and solve_multigrid_pcg — conjugate gradient preconditioned
by one V-cycle of geometric multigrid: aggregation by 2 per direction (odd
sizes absorbed, coarse cell active iff any child is), the Galerkin coarse
operator for piecewise-constant prolongation / summation restriction,
symmetric Gauss–Seidel smoothing, coarse correction scaled by 2 (Braess's
under-correction of unsmoothed aggregation; scalar, so the preconditioner
stays symmetric and positive on range(A)), L1 TRUE-residual stop with a
stagnation guard. Singular systems are handled per connected component of
the active cells (mean projection and level per pure-Neumann component;
the anchor's component to p[anchor] = 0). PoissonSolverKind::{Sor,
Multigrid} on PisoParameters / EmbeddedParameters; Sor is the default and
its code is byte-for-byte untouched; an unconverged multigrid solve falls
back to the SOR sweeps for that projection.

Verified (poisson/tests.rs, tests/poisson_equivalence.rs):
- PCG iterations to cut the residual 1e-8 on the closed Neumann box at
  32^2..256^2: 4, 4, 4, 4; ragged masked domains 8/8/8;
- manufactured recoveries to ~1e-14; Galerkin identity A_c v = R A P v to
  7e-15 on every level (masked, outlet column, non-uniform conductances);
  V-cycle symmetric to 1e-14; NaN-poisoned inactive cells untouched;
- two Neumann components with opposite imbalances, and a Dirichlet
  component beside an imbalanced Neumann one (review scenarios): converge,
  each component right up to its own constant;
- speed vs plain SOR at the same stop: 22.7x (128^2), 41x (256^2);
- same answers as SOR: PISO MMS 4.6e-8 relative, Taylor–Green divergence
  1.4e-9 every step, embedded-circle MMS 7e-8, no-body bit-identity with MG
  on both solvers, channel+outlet+circle 1.4e-10; CFD1 loads identical to
  four digits at 0.003 s/step vs 0.094 (30x).

CFD1 refinement study (tests/turek_hron_cfd.rs, three grids, 257 s):
h = 10 / 6.6 / 5 mm -> control-volume drag 15.6156 / 15.2829 / 15.0988 vs
14.2929 (+9.25 / +6.93 / +5.64%), apparent order 0.71, Richardson
extrapolate 14.04; surface route and lift not monotone (flag 2/3/4 cells
thick) — the test asserts the measured band at the finest grid.

Built with a 4-agent workflow (core, integration, refinement study,
adversarial review); the review found no defects and four risks, three
fixed here (per-component projection, one symmetric smoother-sweep
parameter, acting on `converged` with an SOR fallback) and one recorded
(isotropic aggregation loses grid-independence on anisotropic cells).

rtx-cfd 301 -> 318 green.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-20 10:20:25 -07:00
co-authored by Claude Fable 5
parent c25f15b3c4
commit 327da7ff47
13 changed files with 2785 additions and 153 deletions
@@ -14,19 +14,48 @@
//! the grid scale here, and filled in the benchmark's own meshes.
//!
//! This is the first quantitative claim of the embedded solver against an
//! external reference. The in-suite resolution is bounded by the dev
//! profile's speed (the SOR projection: ~0.1 s/step at h = 10 mm, an hour
//! per run at 5 mm); the assertion is correspondingly the measured band,
//! not an accuracy claim, with the two load routes required to agree with
//! each other as well. Measured at h = 10 mm (flag two cells thick), fully
//! settled (drag stagnant to four digits): surface route drag 15.71, lift
//! 0.936 (3 junction samples skipped); control-volume route drag 15.62,
//! lift 1.079 — drag routes agree to 0.6%, both +9.5% on the reference.
//! The refinement study that turns this into a claim waits on a multigrid
//! Poisson solver (falsifier 4 of the geometry decision).
//! external reference, and the refinement study that was falsifier 4 of the
//! geometry decision. The projection runs the multigrid-preconditioned CG
//! solver; with the SOR projection the in-suite resolution was bounded at
//! h = 10 mm (~0.1 s/step, 609 s for the run below; an hour at 5 mm).
//!
//! Measured, dev profile, each run settled (control-volume drag stagnant to
//! `1e-4` relative over 200 steps after one flow-through time, 12.5 s):
//!
//! | ny | h (mm) | cells | dt (s) | steps | wall s | surface drag / lift (skipped) | CV drag / lift | CV drag vs ref |
//! |----|--------|-------|---------|-------|--------|-------------------------------|----------------|----------------|
//! | 41 | 10.0 | 10250 | 1.92e-3 | 6500 | 21 | 15.7126 / 0.9355 (3) | 15.6156 / 1.0785 | +9.25% |
//! | 62 | 6.61 | 23436 | 1.10e-3 | 11400 | 64 | 15.3450 / 0.7818 (2) | 15.2829 / 0.9195 | +6.93% |
//! | 82 | 5.0 | 41000 | 7.35e-4 | 17000 | 173 | 15.3944 / 1.0178 (3) | 15.0988 / 1.0673 | +5.64% |
//!
//! Reference drag 14.2929, lift 1.11905. The SOR projection at ny = 41 gave
//! exactly the same four digits (surface 15.7126 / 0.9355, CV 15.6156 /
//! 1.0785) in 609 s — the same discrete system, a different inner solver —
//! and the multigrid run is required to reproduce it.
//!
//! What the three points say: the control-volume drag error falls
//! monotonically with h at an apparent order of 0.71 against the reference
//! (0.70 from the 10 -> 6.6 mm pair, 0.74 from 6.6 -> 5 mm; 0.57 from the
//! reference-free three-grid estimate, whose Richardson extrapolate is
//! 14.04, 1.8% under the reference). Sub-first-order is what a sharp
//! embedded boundary sampled on a Cartesian grid delivers for a blunt body
//! whose cut cells change with every h; the drag has not reached the
//! asymptotic range at 5 mm. The surface-integral route agrees with the
//! control volume to 0.6 / 0.4 / 2.0% but is not monotone (it samples the
//! pressure half a cell off the body and skips the junction samples), and
//! the lift — a 1 N difference of two 100 N-scale pressure integrals over a
//! flag that is 2 / 3 / 4 cells thick — is not monotone either (-3.6%,
//! -17.8%, -4.6% on the control-volume route). The assertions are the
//! measured bands: routes within 3%, CV drag error strictly decreasing with
//! apparent order above 0.5, the finest CV drag within 7% and CV lift
//! within 10% of the reference, and the ny = 41 run reproducing the SOR
//! loads to four digits. `RTX_CFD1_NY=41,62` (comma list) overrides the
//! resolution list for studies; the reference bands apply only when the
//! finest grid is at least ny = 82.
use rtx_cfd::solvers::incompressible::{
AleBoundaries, EmbeddedBody, EmbeddedParameters, EmbeddedPisoSolver, FlowField, SideBoundary,
AleBoundaries, EmbeddedBody, EmbeddedParameters, EmbeddedPisoSolver, FlowField,
PoissonSolverKind, SideBoundary,
};
use rtx_cfd::{CfdConfig, CfdResult};
@@ -89,6 +118,7 @@ async fn run_cfd1(ny: usize) -> CfdResult<Cfd1> {
bottom: SideBoundary::Velocity,
top: SideBoundary::Velocity,
},
poisson_solver: PoissonSolverKind::Multigrid,
};
let mut solver = EmbeddedPisoSolver::new(config, params)?;
solver.set_boundary_velocity(|x, y, _| {
@@ -212,9 +242,20 @@ async fn run_cfd1(ny: usize) -> CfdResult<Cfd1> {
#[tokio::test]
async fn cfd1_drag_and_lift_against_the_featflow_reference() -> CfdResult<()> {
// One resolution until the projection has a multigrid solver: at the
// SOR cost a 62-cell run takes over an hour in the test profile.
let resolutions = [41usize];
// h = 10, 6.6, 5 mm: 21 + 64 + 173 s in the dev profile, sequential.
// `RTX_CFD1_NY` (comma-separated ny list) overrides for studies.
let resolutions: Vec<usize> = std::env::var("RTX_CFD1_NY").ok().map_or_else(
|| vec![41usize, 62, 82],
|list| {
list.split(',')
.map(|t| {
t.trim()
.parse()
.expect("RTX_CFD1_NY: comma-separated ny list")
})
.collect()
},
);
let mut results = Vec::new();
for &ny in &resolutions {
let r = run_cfd1(ny).await?;
@@ -232,27 +273,112 @@ async fn cfd1_drag_and_lift_against_the_featflow_reference() -> CfdResult<()> {
);
results.push(r);
}
let fine = results.last().unwrap();
let rel = |a: f64, b: f64| ((a - b) / b).abs();
// Both routes within the measured band of the reference (9.5% at this
// grid) and of each other.
assert!(
rel(fine.drag_surface, REF_DRAG) < 0.12 && rel(fine.drag_cv, REF_DRAG) < 0.12,
"drag: surface {:.4}, control volume {:.4}, reference {REF_DRAG}",
fine.drag_surface,
fine.drag_cv
);
assert!(
rel(fine.drag_surface, fine.drag_cv) < 0.05,
"the two drag routes disagree: surface {:.4} vs control volume {:.4}",
fine.drag_surface,
fine.drag_cv
);
assert!(
rel(fine.lift_surface, REF_LIFT) < 0.25 && rel(fine.lift_cv, REF_LIFT) < 0.25,
"lift: surface {:.4}, control volume {:.4}, reference {REF_LIFT}",
fine.lift_surface,
fine.lift_cv
);
// The two load routes agree at every resolution (measured 0.6 / 0.4 /
// 2.0%): a surface integral that samples the wrong side of the body or
// a control volume that drops a flux term moves one route and not the
// other.
for (ny, r) in resolutions.iter().zip(&results) {
assert!(
rel(r.drag_surface, r.drag_cv) < 3e-2,
"ny = {ny}: the two drag routes disagree: surface {:.4} vs control volume {:.4}",
r.drag_surface,
r.drag_cv
);
}
// The same discrete system, a different inner solver: the settled
// loads at ny = 41 must reproduce the SOR-projection values (see the
// module docs) to four significant digits. A projection solving a
// different system — wrong coefficient on the outlet column, wrong
// anchor, a stop that is not the true residual — moves the drag far
// more.
const SOR_DRAG_CV: f64 = 15.6156;
const SOR_DRAG_SURFACE: f64 = 15.7126;
if let Some(coarse) = resolutions
.iter()
.position(|&ny| ny == 41)
.map(|k| &results[k])
{
assert!(
rel(coarse.drag_cv, SOR_DRAG_CV) < 5e-4
&& rel(coarse.drag_surface, SOR_DRAG_SURFACE) < 5e-4,
"multigrid projection does not reproduce the SOR-projection loads: control volume {:.4} \
vs {SOR_DRAG_CV}, surface {:.4} vs {SOR_DRAG_SURFACE}",
coarse.drag_cv,
coarse.drag_surface
);
// Cost: SOR took 609 s for 6500 steps (0.094 s/step) on the
// development machine in the dev profile. Wall time is machine- and
// profile-bound, so it is reported rather than asserted; the
// multigrid run measured 0.003 s/step.
println!(
" ny = 41 multigrid projection: {:.4} s/step ({} steps, {:.0} s); SOR baseline \
0.0937 s/step (6500 steps, 609 s)",
coarse.seconds / coarse.steps as f64,
coarse.steps,
coarse.seconds
);
}
// Refinement: the control-volume drag error against the reference
// falls strictly with h (measured +9.25%, +6.93%, +5.64%), at an
// apparent order above 0.5 between the coarsest and finest grids
// (measured 0.71). A discretisation that is not converging — a ghost
// correction with the wrong sign, a body sampled on the wrong side —
// keeps the error flat or growing.
let errors: Vec<f64> = results.iter().map(|r| rel(r.drag_cv, REF_DRAG)).collect();
for w in errors.windows(2) {
assert!(
w[1] < w[0],
"control-volume drag error is not falling with h: {errors:?} (resolutions {resolutions:?})"
);
}
if resolutions.len() > 1 {
let (n0, n1) = (
resolutions[0] as f64,
resolutions[resolutions.len() - 1] as f64,
);
let order = (errors[0] / errors[errors.len() - 1]).ln() / (n1 / n0).ln();
println!(
" control-volume drag error vs reference: {:?} apparent order {order:.2} \
(ny {n0} -> {n1})",
errors
.iter()
.map(|e| format!("{e:+.4}"))
.collect::<Vec<_>>()
);
assert!(
order > 0.5,
"apparent order of the control-volume drag error is {order:.2} (errors {errors:?})"
);
}
// The finest grid against the reference: the measured bands at h = 5 mm
// (CV drag +5.64%, CV lift -4.62%), applied only when the study reaches
// that grid.
if let Some((ny, fine)) = resolutions.iter().zip(&results).next_back() {
if *ny >= 82 {
assert!(
rel(fine.drag_cv, REF_DRAG) < 0.07,
"ny = {ny}: control-volume drag {:.4} vs reference {REF_DRAG}",
fine.drag_cv
);
assert!(
rel(fine.lift_cv, REF_LIFT) < 0.10,
"ny = {ny}: control-volume lift {:.4} vs reference {REF_LIFT}",
fine.lift_cv
);
// Surface-route lift at the finest grid: measured -9.05% at
// ny = 82 (the flag is four cells thick; lift is a ~1 N
// difference of ~100 N-scale integrals) — the measured band.
assert!(
rel(fine.lift_surface, REF_LIFT) < 0.12,
"ny = {ny}: surface-route lift {:.4} vs reference {REF_LIFT}",
fine.lift_surface
);
}
}
Ok(())
}