Files
rustytorch/crates/specialized/rtx-cfd/tests/mms_piso.rs
T
Omar SobhandClaude Fable 5 9b097fca0d
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
rtx-cfd: PISO validated by manufactured solution — after fixing the inverted projection
PisoSolver was the only major solver in the workspace with no verification
of any kind. Writing the MMS harness for it (tests/mms_piso.rs) and
inspecting the implementation found the census's defect species again:

- The pressure correction had its SIGN inverted: it solved
  -lap(p') = +rho div(u*)/dt and then corrected with u = u* - (dt/rho)
  grad(p'), so each projection DOUBLED the divergence instead of removing
  it.
- The momentum sweeps froze the near-wall lines (1..ny-1) and the pressure
  correction skipped the outer ring of cells (1..nx-1) — both exactly the
  defects repaired in SIMPLE.
- The "explicit" predictor read neighbours the same sweep had already
  overwritten, so the step depended on sweep order.
- The pressure gradient was dropped entirely on the last interior face.

Rewritten as a genuinely explicit predictor plus anchored-Neumann
projection on the staggered grid, with the conventions SIMPLE now embodies:
near-wall lines are unknowns with half-cell wall diffusion, continuity on
every cell, boundary faces are prescribed data. Momentum-source and
wall-velocity hooks added so the manufactured solution can reach it.

Measured (16 -> 32 -> 64): L2 velocity 3.516214e-2, 1.953750e-2,
1.037512e-2 — orders 0.85 and 0.91, first-order upwind's rate — with
max |div u| ~ 1e-9 in every cell. The errors agree with SIMPLE's on the
same meshes to six or seven significant figures: an implicit under-relaxed
outer iteration and an explicit time-marching projection land on the same
discrete steady solution, which is what sharing a spatial discretisation
must produce and is very hard for two independently wrong solvers to fake.

285 tests, 0 failing.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-19 19:28:39 -07:00

217 lines
7.4 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,
};
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;
if steady_residual < 1e-7 {
break;
}
}
assert!(
steady_residual < 1e-7,
"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(())
}