Files
rustytorch/crates/specialized/rtx-cfd/tests/mms_piso.rs
T
Omar SobhandClaude Fable 5 327da7ff47
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
rtx-cfd: multigrid-PCG projection — 30x faster, same answers — and the CFD1 refinement study
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]>
2026-08-20 10:20:25 -07:00

223 lines
7.7 KiB
Rust

//! Code verification of the PISO solver by manufactured solution.
//!
//! The manufactured field, its momentum source and the grid convention are
//! exactly those of `tests/mms_navier_stokes.rs` — see that file for the
//! derivation. PISO is a transient stepper, so instead of iterating an outer
//! loop it is marched in time under the steady forcing until the field stops
//! changing; the steady state it lands on satisfies the same spatial
//! discretisation (first-order upwind convection, second-order diffusion,
//! half-cell wall treatment), so the observed order should match SIMPLE's:
//! approaching 1, limited by upwind's `O(h)` numerical viscosity.
//!
//! Until this file existed PISO had no verification of any kind — not a unit
//! test, not a benchmark. The first run of this measurement, against the old
//! implementation, is what confirmed the inverted pressure-correction sign
//! and the frozen near-wall lines recorded in `piso.rs`'s module docs.
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 MU: f64 = 0.05;
fn u_exact(x: f64, y: f64) -> f64 {
(PI * x).sin() * (PI * y).cos()
}
fn v_exact(x: f64, y: f64) -> f64 {
-(PI * x).cos() * (PI * y).sin()
}
fn source(x: f64, y: f64) -> (f64, f64) {
let fx = RHO * 0.5 * PI * (2.0 * PI * x).sin()
+ 2.0 * PI * PI * MU * u_exact(x, y)
+ PI * (PI * x).cos() * (PI * y).sin();
let fy = RHO * 0.5 * PI * (2.0 * PI * y).sin()
+ 2.0 * PI * PI * MU * v_exact(x, y)
+ PI * (PI * x).sin() * (PI * y).cos();
(fx, fy)
}
struct Measurement {
l2_velocity: f64,
max_div: f64,
}
/// March the manufactured problem on an `n` by `n` grid to steady state.
async fn measure(n: usize) -> CfdResult<Measurement> {
let dx = 1.0 / n as f64;
let dy = dx;
// Explicit predictor: dt must respect the diffusion limit `dx^2 / (4 nu)`
// (the binding one here, with nu = 0.05 and |u| <= 1).
let nu = MU / RHO;
let dt = 0.4 * (dx * dx / (4.0 * nu)).min(dx);
let config = CfdConfig::new()
.with_density(RHO)
.with_viscosity(MU)
.with_reference_velocity(1.0)
.with_reference_length(1.0);
let params = PisoParameters {
corrector_steps: 2,
time_step: dt,
tolerance: 1e-8,
..PisoParameters::default()
};
let mut solver = PisoSolver::new(config, params)?;
solver.set_momentum_source(source);
solver.set_wall_velocity(|x, y| (u_exact(x, y), v_exact(x, y)));
let mut field = FlowField::new(n, n, dx, dy)?;
for j in 0..n {
let y = (j as f64 + 0.5) * dy;
field.u[(j, 0)] = u_exact(0.0, y);
field.u[(j, n)] = u_exact(1.0, y);
}
for i in 0..n {
let x = (i as f64 + 0.5) * dx;
field.v[(0, i)] = v_exact(x, 0.0);
field.v[(n, i)] = v_exact(x, 1.0);
}
// March to steady state: stop when the field stops moving, measured as
// `max |u^{n+1} - u^n| / dt`, the discrete time derivative.
let empty = BoundaryConditions::new();
let mut steady_residual = f64::INFINITY;
for _step in 0..200_000 {
let u_before = field.u.clone();
let v_before = field.v.clone();
solver.solve_time_step(&mut field, &empty, dt).await?;
let mut max_change: f64 = 0.0;
for (a, b) in field.u.iter().zip(u_before.iter()) {
max_change = max_change.max((a - b).abs());
}
for (a, b) in field.v.iter().zip(v_before.iter()) {
max_change = max_change.max((a - b).abs());
}
steady_residual = max_change / dt;
// 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;
}
}
assert!(
steady_residual < 1e-6,
"PISO did not reach a steady state: |du/dt| = {steady_residual:.3e}"
);
let mut squared = 0.0;
let mut volume = 0.0;
for j in 0..n {
for i in 1..n {
let e = field.u[(j, i)] - u_exact(i as f64 * dx, (j as f64 + 0.5) * dy);
squared += e * e * dx * dy;
volume += dx * dy;
}
}
for j in 1..n {
for i in 0..n {
let e = field.v[(j, i)] - v_exact((i as f64 + 0.5) * dx, j as f64 * dy);
squared += e * e * dx * dy;
volume += dx * dy;
}
}
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)]) / dy;
max_div = max_div.max(div.abs());
}
}
Ok(Measurement {
l2_velocity: squared.sqrt() / volume.sqrt(),
max_div,
})
}
/// The steady state PISO marches to must converge to the exact solution at
/// the rate the spatial discretisation dictates — order approaching 1 for
/// first-order upwind — and must be divergence-free in every cell.
///
/// Measured (16 -> 32 -> 64): L2 velocity 3.516214e-2, 1.953750e-2,
/// 1.037512e-2, orders 0.85 and 0.91, max |div u| ~ 1e-9 everywhere. The
/// errors agree with SIMPLE's on the same meshes (3.516212e-2, 1.953751e-2,
/// 1.037523e-2) to six or seven significant figures: two different
/// algorithms — implicit under-relaxed outer iteration against explicit time
/// marching with projection — land on the same discrete steady solution,
/// which is exactly what sharing a spatial discretisation must produce and
/// is very hard for two independently wrong solvers to fake.
#[tokio::test]
async fn piso_observed_order_matches_the_convection_scheme() -> CfdResult<()> {
let resolutions = [16usize, 32, 64];
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} L2 velocity error = {:.6e} observed order = {rate} \
max |div u| = {:.6e}",
errors[i], measurements[i].max_div
);
}
assert!(
errors.windows(2).all(|pair| pair[1] < pair[0]),
"the error must fall under refinement; got {errors:?}"
);
for (i, &rate) in rates.iter().enumerate() {
assert!(
rate > 0.75,
"refinement {} -> {}: observed order {rate:.3}, below the order 1 \
first-order upwind must deliver. Errors: {errors:?}",
resolutions[i],
resolutions[i + 1]
);
assert!(
rate < 2.3,
"refinement {} -> {}: observed order {rate:.3}, above what this \
scheme can deliver — suspect the error measure. Errors: {errors:?}",
resolutions[i],
resolutions[i + 1]
);
}
// Every cell, outer ring included, must satisfy continuity: the
// projection exists for no other reason.
for (m, &n) in measurements.iter().zip(&resolutions) {
assert!(
m.max_div < 1e-5,
"max |div u| = {:.3e} at n = {n}: the projection is not removing \
the divergence",
m.max_div
);
}
Ok(())
}