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]>
222 lines
7.7 KiB
Rust
222 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,
|
|
};
|
|
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(())
|
|
}
|