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
@@ -37,6 +37,7 @@
//! - Convective face fluxes fell back to the centre value at the sweep edges
//! instead of using the prescribed boundary faces that exist there.
use super::poisson::{MultigridParameters, PoissonProblem, PoissonSolverKind, solve_multigrid_pcg};
use super::{BoundaryConditions, FlowField, IncompressibleSolver, SolverResult};
use crate::{CfdConfig, CfdResult};
use async_trait::async_trait;
@@ -54,6 +55,10 @@ pub struct PisoParameters {
/// Convergence tolerance on the normalised mass imbalance after
/// correction.
pub tolerance: f64,
/// Inner solver of the pressure-correction system (default
/// [`PoissonSolverKind::Sor`]). Both solve the same system to the same
/// true-residual stop; multigrid's cost is mesh-independent.
pub poisson_solver: PoissonSolverKind,
}
impl Default for PisoParameters {
@@ -62,6 +67,7 @@ impl Default for PisoParameters {
corrector_steps: 2,
time_step: 0.001,
tolerance: 1e-6,
poisson_solver: PoissonSolverKind::Sor,
}
}
}
@@ -368,51 +374,94 @@ impl PisoSolver {
let reference_flux = rho * self.config.reference_velocity * self.config.reference_length;
let inner_stop =
(1e-2 * source_scale).max(0.1 * self.parameters.tolerance * reference_flux) + 1e-14;
let omega = 2.0 / (1.0 + (std::f64::consts::PI / nx.max(ny) as f64).sin());
for _sweep in 0..2000 {
let mut residual = 0.0;
let mut multigrid_converged = false;
if self.parameters.poisson_solver == PoissonSolverKind::Multigrid {
// The same five-point system the SOR loop below sweeps — the
// same coefficients, right-hand side, anchor cell and stop —
// handed to the multigrid-preconditioned CG solver. The SOR
// loop pins `p'(1, 1) = 0` and solves the remaining equations;
// on the compatible (closed-box) source that is the singular
// system's solution shifted to `p'(1, 1) = 0`, which is what
// `anchor` requests.
let mut problem = PoissonProblem::new(nx, ny);
for j in 0..ny {
for i in 0..nx {
if i == 1 && j == 1 {
flow_field.p_prime[(j, i)] = 0.0;
continue;
}
let ae = if i + 1 == nx { 0.0 } else { ae_interior };
let aw = if i == 0 { 0.0 } else { ae_interior };
let an = if j + 1 == ny { 0.0 } else { an_interior };
let as_ = if j == 0 { 0.0 } else { an_interior };
let ap = ae + aw + an + as_;
let east = if i + 1 < nx {
ae * flow_field.p_prime[(j, i + 1)]
} else {
0.0
};
let west = if i > 0 {
aw * flow_field.p_prime[(j, i - 1)]
} else {
0.0
};
let north = if j + 1 < ny {
an * flow_field.p_prime[(j + 1, i)]
} else {
0.0
};
let south = if j > 0 {
as_ * flow_field.p_prime[(j - 1, i)]
} else {
0.0
};
let rhs = flow_field.sp[(j, i)] + east + west + north + south;
let p_old = flow_field.p_prime[(j, i)];
residual += (rhs - ap * p_old).abs();
flow_field.p_prime[(j, i)] = (1.0 - omega) * p_old + omega * rhs / ap;
let idx = j * nx + i;
problem.ae[idx] = if i + 1 == nx { 0.0 } else { ae_interior };
problem.aw[idx] = if i == 0 { 0.0 } else { ae_interior };
problem.an[idx] = if j + 1 == ny { 0.0 } else { an_interior };
problem.as_[idx] = if j == 0 { 0.0 } else { an_interior };
problem.rhs[idx] = flow_field.sp[(j, i)];
}
}
if residual < inner_stop {
break;
let mut p_prime = vec![0.0; nx * ny];
let solution = solve_multigrid_pcg(
&problem,
&mut p_prime,
&MultigridParameters::default(),
inner_stop,
Some(nx + 1),
);
// An unconverged multigrid solve (iteration cap, rounding floor,
// inconsistent system) is not applied: the SOR sweeps below take
// over for this projection, so the worst case is the old cost,
// never a silently wrong correction.
multigrid_converged = solution.converged;
if multigrid_converged {
for j in 0..ny {
for i in 0..nx {
flow_field.p_prime[(j, i)] = p_prime[j * nx + i];
}
}
}
}
if !multigrid_converged {
let omega = 2.0 / (1.0 + (std::f64::consts::PI / nx.max(ny) as f64).sin());
for _sweep in 0..2000 {
let mut residual = 0.0;
for j in 0..ny {
for i in 0..nx {
if i == 1 && j == 1 {
flow_field.p_prime[(j, i)] = 0.0;
continue;
}
let ae = if i + 1 == nx { 0.0 } else { ae_interior };
let aw = if i == 0 { 0.0 } else { ae_interior };
let an = if j + 1 == ny { 0.0 } else { an_interior };
let as_ = if j == 0 { 0.0 } else { an_interior };
let ap = ae + aw + an + as_;
let east = if i + 1 < nx {
ae * flow_field.p_prime[(j, i + 1)]
} else {
0.0
};
let west = if i > 0 {
aw * flow_field.p_prime[(j, i - 1)]
} else {
0.0
};
let north = if j + 1 < ny {
an * flow_field.p_prime[(j + 1, i)]
} else {
0.0
};
let south = if j > 0 {
as_ * flow_field.p_prime[(j - 1, i)]
} else {
0.0
};
let rhs = flow_field.sp[(j, i)] + east + west + north + south;
let p_old = flow_field.p_prime[(j, i)];
residual += (rhs - ap * p_old).abs();
flow_field.p_prime[(j, i)] = (1.0 - omega) * p_old + omega * rhs / ap;
}
}
if residual < inner_stop {
break;
}
}
}