rtx-cfd: Taylor-Green validates PISO's transient path — and fixes the projection's inner solve
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

With k = pi the decaying Taylor-Green vortex has zero normal velocity on
the unit box for all time, so it fits the closed staggered domain exactly,
with ZERO body force: convection is balanced identically by the true TG
pressure and the decay comes from viscosity alone. This exercises exactly
what the steady MMS harness cannot see — the time derivative, the unsteady
pressure coupling and the projection's splitting error. The time-decaying
tangential wall velocity enters by re-setting the wall hook each step.

Measured (16/32/64, dt ~ h^2): L2 velocity 2.267e-2, 1.153e-2, 5.841e-3 —
orders 0.97 and 0.98, first-order upwind's rate — and the kinetic-energy
deficit against the exact e^(-4 nu pi^2 T) halves per refinement
(0.0690, 0.0360, 0.0185; ratios 1.92, 1.95), within 2.3% on the finest
mesh. Every step divergence-free to ~1e-7.

Its first run caught two defects in the projection's inner solver:

- The inner Gauss-Seidel stop summed the per-sweep iterate CHANGE — the
  same movement-not-residual pseudo-criterion the SIMPLE census flagged:
  slow modes move little per sweep while their residual is still large.
- Plain GS contracts smooth modes by only 1 - O(h^2) per sweep, so the
  400-sweep cap left max |div u| ~ 1e-2, GROWING with mesh size (8e-3 at
  16^2 to 2e-2 at 64^2).

The inner stop now measures the true equation residual, the sweep is SOR
at the optimal Poisson factor omega = 2/(1 + sin(pi h)), and it converges
relative to each projection's own source with a floor tied to the outer
mass tolerance — so a long steady march no longer burns a hundred sweeps
per step polishing negligible corrections. The steady MMS harness had
masked all of this: a march to steady state iterates the projection to
death regardless, which is why its divergence read 1e-9 while a 205-step
transient left 1e-2.

mms_piso's steady-state criterion is 1e-6 (was 1e-7): per-step projection
noise at the mass tolerance floors |du/dt| just below 1e-6, and the L2
errors under measurement are 1e-2 to 1e-3. Its results are unchanged to
six figures and still match SIMPLE's.

288 rtx-cfd tests, 0 failing.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-20 00:35:06 -07:00
co-authored by Claude Fable 5
parent d8a30db155
commit b321a9aba7
3 changed files with 302 additions and 9 deletions
@@ -325,13 +325,16 @@ impl PisoSolver {
flow_field.p_prime.fill(0.0);
// Mass imbalance of the predicted field, per cell, as a flux.
// Mass imbalance of the predicted field, per cell, as a flux. Its
// absolute sum is the scale the inner solve converges relative to.
let mut source_scale = 0.0;
for j in 0..ny {
for i in 0..nx {
let divergence_flux = rho
* ((flow_field.u_star[(j, i + 1)] - flow_field.u_star[(j, i)]) * dy
+ (flow_field.v_star[(j + 1, i)] - flow_field.v_star[(j, i)]) * dx);
flow_field.sp[(j, i)] = -divergence_flux;
source_scale += divergence_flux.abs();
}
}
@@ -341,7 +344,32 @@ impl PisoSolver {
let ae_interior = dt * dy / dx;
let an_interior = dt * dx / dy;
for _sweep in 0..400 {
// Successive over-relaxation at the optimal Poisson factor
// `omega = 2 / (1 + sin(pi h))`. Plain Gauss-Seidel contracts the
// smooth modes by only ~(1 - O(h^2)) per sweep, so on a 64^2 grid a
// 400-sweep cap left a divergence of ~1e-2 that *grew* with mesh
// size; SOR brings the contraction to ~(1 - O(h)) and the same
// tolerance costs tens of sweeps instead of thousands.
//
// The inner stop measures the TRUE residual of the pressure-correction
// equation, `|b + sum(a_nb p'_nb) - a_p p'_P|` summed over cells (one
// half-sweep lagged). An earlier version summed the per-sweep iterate
// CHANGE instead — the same movement-not-residual pseudo-criterion the
// SIMPLE census flagged: slow modes move little per sweep while their
// residual is still large, so the loop declared victory with an
// unremoved divergence. The Taylor-Green benchmark caught both.
// Converge relative to this projection's own source, floored at a
// tenth of the divergence level the outer corrector loop is checking
// for: the corrector loop measures the true post-correction
// divergence and re-projects (compounding the reduction), so the
// inner solve only needs a solid contraction per pass, not machine zero — which on
// a long steady march would spend a hundred sweeps per step
// polishing a correction that is already far below tolerance.
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;
for j in 0..ny {
for i in 0..nx {
@@ -377,13 +405,13 @@ impl PisoSolver {
0.0
};
let p_new = (flow_field.sp[(j, i)] + east + west + north + south) / ap;
let correction = p_new - flow_field.p_prime[(j, i)];
residual += correction * correction;
flow_field.p_prime[(j, i)] = p_new;
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.sqrt() < 1e-12 {
if residual < inner_stop {
break;
}
}