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); 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 j in 0..ny {
for i in 0..nx { for i in 0..nx {
let divergence_flux = rho let divergence_flux = rho
* ((flow_field.u_star[(j, i + 1)] - flow_field.u_star[(j, i)]) * dy * ((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.v_star[(j + 1, i)] - flow_field.v_star[(j, i)]) * dx);
flow_field.sp[(j, i)] = -divergence_flux; 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 ae_interior = dt * dy / dx;
let an_interior = dt * dx / dy; 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; let mut residual = 0.0;
for j in 0..ny { for j in 0..ny {
for i in 0..nx { for i in 0..nx {
@@ -377,13 +405,13 @@ impl PisoSolver {
0.0 0.0
}; };
let p_new = (flow_field.sp[(j, i)] + east + west + north + south) / ap; let rhs = flow_field.sp[(j, i)] + east + west + north + south;
let correction = p_new - flow_field.p_prime[(j, i)]; let p_old = flow_field.p_prime[(j, i)];
residual += correction * correction; residual += (rhs - ap * p_old).abs();
flow_field.p_prime[(j, i)] = p_new; flow_field.p_prime[(j, i)] = (1.0 - omega) * p_old + omega * rhs / ap;
} }
} }
if residual.sqrt() < 1e-12 { if residual < inner_stop {
break; break;
} }
} }
+7 -2
View File
@@ -100,12 +100,17 @@ async fn measure(n: usize) -> CfdResult<Measurement> {
max_change = max_change.max((a - b).abs()); max_change = max_change.max((a - b).abs());
} }
steady_residual = max_change / dt; steady_residual = max_change / dt;
if steady_residual < 1e-7 { // 1e-6, not tighter: each step's projection is converged to the
// solver's mass tolerance, not to machine zero, and the leftover
// per-step noise floors |du/dt| just below 1e-6. The L2 errors being
// measured are 1e-2 to 1e-3, so a 1e-6 stationarity floor
// contributes nothing to them.
if steady_residual < 1e-6 {
break; break;
} }
} }
assert!( assert!(
steady_residual < 1e-7, steady_residual < 1e-6,
"PISO did not reach a steady state: |du/dt| = {steady_residual:.3e}" "PISO did not reach a steady state: |du/dt| = {steady_residual:.3e}"
); );
@@ -0,0 +1,260 @@
//! Decaying TaylorGreen vortex: the transient benchmark for PISO.
//!
//! With wavenumber `k = pi` on the unit square,
//!
//! ```text
//! u = A(t) sin(pi x) cos(pi y) A(t) = e^(-2 nu pi^2 t)
//! v = -A(t) cos(pi x) sin(pi y)
//! p = -(rho A^2 / 4)(cos 2pi x + cos 2pi y)
//! ```
//!
//! is an exact unsteady NavierStokes solution with **zero body force**: the
//! convective term is balanced identically by the pressure gradient and the
//! decay comes from viscosity alone. The normal velocity vanishes on all
//! four walls for all time (`u(0,y) = u(1,y) = 0`, `v(x,0) = v(x,1) = 0`),
//! so the closed staggered box fits it exactly; only the *tangential* wall
//! velocity decays in time, and it reaches the solver by re-setting the
//! wall-velocity hook with the current amplitude before every step.
//!
//! This is the same spatial field as `tests/mms_piso.rs`, which pinned the
//! steady spatial discretisation with a manufactured source. What this adds
//! is exactly the transient machinery that test cannot see: the time
//! derivative, the unsteady pressurevelocity coupling and the projection's
//! splitting error, exercised with no source hook at all. Two checks:
//!
//! 1. The L2 velocity error at `T` falls under simultaneous spacetime
//! refinement (`dt ~ h^2`, matching first-order upwind's spatial error
//! against explicit Euler's temporal one).
//! 2. The kinetic-energy decay rate matches the closed form
//! `E(T)/E(0) = e^(-4 nu pi^2 T)` — a scalar with an exact answer, and a
//! check no steady measurement can make at all.
use rtx_cfd::solvers::incompressible::{
BoundaryConditions, FlowField, IncompressibleSolver, PisoParameters, PisoSolver,
};
use rtx_cfd::{CfdConfig, CfdResult};
use std::f64::consts::PI;
const RHO: f64 = 1.0;
const NU: f64 = 0.02;
const T_END: f64 = 0.25;
fn amplitude(t: f64) -> f64 {
(-2.0 * NU * PI * PI * t).exp()
}
fn u_exact(x: f64, y: f64, t: f64) -> f64 {
amplitude(t) * (PI * x).sin() * (PI * y).cos()
}
fn v_exact(x: f64, y: f64, t: f64) -> f64 {
-amplitude(t) * (PI * x).cos() * (PI * y).sin()
}
fn p_exact(x: f64, y: f64, t: f64) -> f64 {
let a = amplitude(t);
-RHO * a * a / 4.0 * ((2.0 * PI * x).cos() + (2.0 * PI * y).cos())
}
struct Measurement {
l2_velocity: f64,
energy_ratio: f64,
max_div: f64,
steps: usize,
}
async fn measure(n: usize) -> CfdResult<Measurement> {
let dx = 1.0 / n as f64;
// Explicit predictor: dt under the diffusion limit, so dt ~ h^2 and the
// temporal error refines together with the spatial one.
let dt = 0.4 * dx * dx / (4.0 * NU);
let steps = (T_END / dt).ceil() as usize;
let dt = T_END / steps as f64;
let config = CfdConfig::new()
.with_density(RHO)
.with_viscosity(RHO * NU)
.with_reference_velocity(1.0)
.with_reference_length(1.0);
// Enough correctors that every step is driven to the divergence
// tolerance the corrector loop itself measures — 2 is not enough on the
// finer grids, where 400 Gauss-Seidel sweeps per projection leave a
// residual the next corrector must mop up.
let params = PisoParameters {
corrector_steps: 60,
time_step: dt,
tolerance: 1e-9,
};
let mut solver = PisoSolver::new(config, params)?;
let mut field = FlowField::new(n, n, dx, dx)?;
// Exact initial condition on every face, boundary faces included (the
// normal boundary values are zero and stay zero).
for j in 0..n {
let y = (j as f64 + 0.5) * dx;
for i in 0..=n {
field.u[(j, i)] = u_exact(i as f64 * dx, y, 0.0);
}
}
for j in 0..=n {
let y = j as f64 * dx;
for i in 0..n {
field.v[(j, i)] = v_exact((i as f64 + 0.5) * dx, y, 0.0);
}
}
for j in 0..n {
for i in 0..n {
field.p[(j, i)] = p_exact((i as f64 + 0.5) * dx, (j as f64 + 0.5) * dx, 0.0);
}
}
let initial_energy = kinetic_energy(&field, n, dx);
let empty = BoundaryConditions::new();
for step in 0..steps {
// The tangential wall velocity decays with the solution; the
// predictor differentiates the state at t_n, so the wall belongs to
// t_n as well.
let t = step as f64 * dt;
solver.set_wall_velocity(move |x, y| (u_exact(x, y, t), v_exact(x, y, t)));
let result = solver.solve_time_step(&mut field, &empty, dt).await?;
assert!(
result.solver_result.converged,
"step {step}: projection left mass residual {:.3e}",
result.solver_result.final_residual
);
}
let mut squared = 0.0;
let mut volume = 0.0;
for j in 0..n {
let y = (j as f64 + 0.5) * dx;
for i in 1..n {
let e = field.u[(j, i)] - u_exact(i as f64 * dx, y, T_END);
squared += e * e * dx * dx;
volume += dx * dx;
}
}
for j in 1..n {
let y = j as f64 * dx;
for i in 0..n {
let e = field.v[(j, i)] - v_exact((i as f64 + 0.5) * dx, y, T_END);
squared += e * e * dx * dx;
volume += dx * dx;
}
}
let mut max_div: f64 = 0.0;
for j in 0..n {
for i in 0..n {
let div = (field.u[(j, i + 1)] - field.u[(j, i)]) / dx
+ (field.v[(j + 1, i)] - field.v[(j, i)]) / dx;
max_div = max_div.max(div.abs());
}
}
Ok(Measurement {
l2_velocity: squared.sqrt() / volume.sqrt(),
energy_ratio: kinetic_energy(&field, n, dx) / initial_energy,
max_div,
steps,
})
}
/// Discrete kinetic energy over the interior faces.
fn kinetic_energy(field: &FlowField, n: usize, dx: f64) -> f64 {
let mut energy = 0.0;
for j in 0..n {
for i in 1..n {
energy += 0.5 * RHO * field.u[(j, i)] * field.u[(j, i)] * dx * dx;
}
}
for j in 1..n {
for i in 0..n {
energy += 0.5 * RHO * field.v[(j, i)] * field.v[(j, i)] * dx * dx;
}
}
energy
}
#[tokio::test]
async fn taylor_green_decays_at_the_exact_rate() -> CfdResult<()> {
let resolutions = [16usize, 32, 64];
let exact_ratio = (-4.0 * NU * PI * PI * T_END).exp();
let mut measurements = Vec::new();
for &n in &resolutions {
measurements.push(measure(n).await?);
}
let errors: Vec<f64> = measurements.iter().map(|m| m.l2_velocity).collect();
let rates: Vec<f64> = errors
.windows(2)
.map(|pair| (pair[0] / pair[1]).log2())
.collect();
for (i, &n) in resolutions.iter().enumerate() {
let rate = if i == 0 {
String::from(" -")
} else {
format!("{:5.2}", rates[i - 1])
};
println!(
" n = {n:3} ({:4} steps) L2 = {:.6e} order = {rate} E(T)/E(0) = {:.5} \
(exact {exact_ratio:.5}) max div = {:.2e}",
measurements[i].steps, errors[i], measurements[i].energy_ratio, measurements[i].max_div
);
}
assert!(
errors.windows(2).all(|pair| pair[1] < pair[0]),
"the error must fall under refinement; got {errors:?}"
);
// Measured: L2 = 2.267e-2, 1.153e-2, 5.841e-3 — orders 0.97 and 0.98,
// first-order upwind's rate, with dt ~ h^2 keeping the temporal error
// subordinate.
for (i, &rate) in rates.iter().enumerate() {
assert!(
(0.85..1.5).contains(&rate),
"refinement {} -> {}: observed order {rate:.3}, expected ~1 from \
first-order upwind. Errors: {errors:?}",
resolutions[i],
resolutions[i + 1]
);
}
// The projection must keep every step divergence-free. Measured 1e-10,
// 7e-9, 4.5e-7 with the SOR inner solve; the 400-sweep Gauss-Seidel this
// test originally ran against left 1e-2 here, growing with mesh size.
for m in &measurements {
assert!(m.max_div < 1e-6, "max divergence {:.3e}", m.max_div);
}
// Energy decay: the deficit against the exact ratio is upwind's excess
// numerical dissipation and must halve per refinement. Measured deficits
// 0.0690, 0.0360, 0.0185 (ratios 1.92, 1.95); the finest mesh sits
// within 2.3% of the closed form.
let deficits: Vec<f64> = measurements
.iter()
.map(|m| exact_ratio - m.energy_ratio)
.collect();
for pair in deficits.windows(2) {
let ratio = pair[0] / pair[1];
assert!(
(1.6..2.4).contains(&ratio),
"energy-deficit refinement ratio {ratio:.2}, expected ~2; \
deficits {deficits:?}"
);
}
assert!(
deficits[deficits.len() - 1] < 0.03 * exact_ratio,
"finest-mesh energy ratio {:.5} is more than 3% from the exact \
{exact_ratio:.5}",
measurements[measurements.len() - 1].energy_ratio
);
Ok(())
}