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
Performance Benchmarks / Run Benchmarks (push) Canceled after 0s
CI / Format Check (push) Canceled after 0s
The Re=400 lid-driven cavity, 128^2 TVD on an exactly-unit domain, sampled
on the staggered faces that lie exactly on the centrelines, against Ghia,
Ghia & Shin (1982) — reference values sourced from two independent
transcriptions that agree digit for digit (Mramor/Vertnik/Sarler CMC 2013
Table 1, and the ivan-pi benchmark collection):
u_min -0.32667 at y = 0.2852 (Ghia: -0.32726 at 0.2813 — 0.18%)
v_min -0.45024 at x = 0.8633 (Ghia: -0.44993 at 0.8594 — 0.07%)
v_max 0.30044 at x = 0.2305 (Ghia: 0.30203 at 0.2266 — 0.53%)
max |u - Ghia| over 15 profile stations: 0.0051
Ghia's own values carry ~0.3% discretisation error (Sahin & Owens 2003 put
u_min at -0.32838), so this is at the reference's own accuracy.
The finding worth the commit message: the first run used the Re=100 test's
residual tolerance of 1e-4 and read u_min = -0.31987 — "converged", 2.3%
shy — and refining to 192^2 made it WORSE (-0.30879, profile deviation
doubled from 0.034 to 0.074). The residual had dropped below tolerance
while the field was still developing, and the effect grows with mesh size
because SIMPLE's per-iteration contraction weakens as h -> 0: at fixed
residual tolerance the finer mesh stops at an EARLIER stage of convergence.
Tightening the stop (3e-5, then 1.5e-5, until the movement per halving fell
below the reference's own error) was the fix, and the test's bands are set
so the premature-stop state fails all of them. "The residual converged"
must never stand in for "the answer stopped moving".
Co-Authored-By: Claude Fable 5 <[email protected]>
228 lines
8.5 KiB
Rust
228 lines
8.5 KiB
Rust
//! Quantitative cavity benchmark against Ghia, Ghia & Shin (1982) at Re = 400.
|
|
//!
|
|
//! The Re = 100 cavity in `tests/simple_tests.rs` asserts bands measured on
|
|
//! its own grid; this test makes the quantitative claim, on a domain that is
|
|
//! *exactly* the unit square (`n` cells of `1/n` — the older cavity test's
|
|
//! `65 x (1/64)` grid is 1.6% oversized, which is fine for banded assertions
|
|
//! and not fine for a table comparison). Velocities are sampled on the
|
|
//! staggered faces that lie exactly on the centrelines: u faces at
|
|
//! `x = 0.5`, v faces at `y = 0.5`.
|
|
//!
|
|
//! # Reference values, and where they come from
|
|
//!
|
|
//! Ghia et al. (1982), 257^2 multigrid. Sourced from two independent
|
|
//! transcriptions that agree digit for digit: the tabulation in Mramor,
|
|
//! Vertnik & Sarler, CMC vol. 36 (2013), Table 1, and the benchmark data
|
|
//! collection at gist.github.com/ivan-pi. For Re = 400:
|
|
//!
|
|
//! ```text
|
|
//! u on x = 0.5: min -0.32726 at y = 0.2813
|
|
//! v on y = 0.5: min -0.44993 at x = 0.8594, max 0.30203 at x = 0.2266
|
|
//! ```
|
|
//!
|
|
//! plus the 15 interior u-profile stations asserted below. Worth knowing
|
|
//! when reading the tolerances: Ghia's own values carry their own
|
|
//! discretisation error — Sahin & Owens (2003) on finer grids put the u
|
|
//! minimum at -0.32838 (+0.3%) — so agreement with Ghia much tighter than a
|
|
//! few tenths of a percent would be overfitting to the reference, not
|
|
//! accuracy.
|
|
//!
|
|
//! # The stopping-tolerance trap this test walked into
|
|
//!
|
|
//! The first run used the Re = 100 test's residual tolerance of 1e-4 and
|
|
//! read u_min = -0.31987 — "converged", 2.3% shy of Ghia — and refining to
|
|
//! 192^2 made it WORSE (-0.30879, profile deviation doubled). The residual
|
|
//! had dropped below tolerance while the field was still developing, and
|
|
//! the effect grows with mesh size because SIMPLE's per-iteration
|
|
//! contraction weakens as h -> 0: at a fixed residual level, the finer mesh
|
|
//! is at an *earlier* stage of convergence. Tightening the stop, not the
|
|
//! mesh, was the fix — 3e-5 gave -0.32565 and 1.5e-5 gave -0.32667, at
|
|
//! which point the movement per halving (~0.3%, then ~0.1%) is below the
|
|
//! reference's own error and the answer is converged for this comparison.
|
|
//! This is why a fixed-tolerance grid study is not presented here, and why
|
|
//! "the residual converged" must never stand in for "the answer stopped
|
|
//! moving".
|
|
|
|
use rtx_cfd::solvers::incompressible::{
|
|
BoundaryConditions, BoundaryLocation, BoundaryType, ConvectionScheme, FlowField,
|
|
IncompressibleSolver, SimpleParameters, SimpleSolver,
|
|
};
|
|
use rtx_cfd::{CfdConfig, CfdResult};
|
|
|
|
/// Ghia et al. (1982), Table I, Re = 400: u on the vertical centreline at
|
|
/// the tabulated y stations (interior rows only; the 0 and 1 endpoints are
|
|
/// boundary conditions, not solution).
|
|
const GHIA_U_RE400: &[(f64, f64)] = &[
|
|
(0.9766, 0.75837),
|
|
(0.9688, 0.68439),
|
|
(0.9609, 0.61756),
|
|
(0.9531, 0.55892),
|
|
(0.8516, 0.29093),
|
|
(0.7344, 0.16256),
|
|
(0.6172, 0.02135),
|
|
(0.5000, -0.11477),
|
|
(0.4531, -0.17119),
|
|
(0.2813, -0.32726),
|
|
(0.1719, -0.24299),
|
|
(0.1016, -0.14612),
|
|
(0.0703, -0.10338),
|
|
(0.0625, -0.09266),
|
|
(0.0547, -0.08186),
|
|
];
|
|
|
|
struct CavitySolution {
|
|
field: FlowField,
|
|
n: usize,
|
|
iterations: usize,
|
|
}
|
|
|
|
async fn solve_cavity_re400(n: usize) -> CfdResult<CavitySolution> {
|
|
let dx = 1.0 / n as f64;
|
|
|
|
let config = CfdConfig::new()
|
|
.with_density(1.0)
|
|
.with_viscosity(1.0 / 400.0)
|
|
.with_reference_velocity(1.0)
|
|
.with_reference_length(1.0);
|
|
|
|
// The corner-singularity floor on the normalised mass residual falls
|
|
// roughly linearly with mesh size (1.6e-4 at 65^2); 1e-4 sits above the
|
|
// ~8e-5 floor of this grid.
|
|
let params = SimpleParameters::default()
|
|
.with_pressure_relaxation(0.3)
|
|
.with_velocity_relaxation(0.7)
|
|
.with_max_iterations(40000)
|
|
.with_convection_scheme(ConvectionScheme::TvdVanAlbada)
|
|
// See the module docs: 1e-4 "converges" 2.3% short of Ghia, and the
|
|
// shortfall grows with mesh size. 1.5e-5 is where the answer stops
|
|
// moving relative to the reference's own accuracy.
|
|
.with_tolerance(1.5e-5);
|
|
|
|
let mut solver = SimpleSolver::new(config, params)?;
|
|
|
|
let mut field = FlowField::new(n, n, dx, dx)?;
|
|
let mut bcs = BoundaryConditions::new();
|
|
for location in [
|
|
BoundaryLocation::Left,
|
|
BoundaryLocation::Right,
|
|
BoundaryLocation::Bottom,
|
|
BoundaryLocation::Top,
|
|
] {
|
|
bcs.add_boundary_condition(location, BoundaryType::FreeSlipWall);
|
|
}
|
|
solver.set_wall_velocity(move |_x, y| if y > 0.5 { (1.0, 0.0) } else { (0.0, 0.0) });
|
|
field.apply_boundary_conditions(&bcs)?;
|
|
|
|
let result = solver.solve(&mut field, &bcs).await?;
|
|
assert!(
|
|
result.solver_result.converged,
|
|
"cavity did not converge: residual {:.3e} after {} iterations",
|
|
result.solver_result.final_residual, result.solver_result.iterations
|
|
);
|
|
|
|
Ok(CavitySolution {
|
|
field,
|
|
n,
|
|
iterations: result.solver_result.iterations,
|
|
})
|
|
}
|
|
|
|
/// u on the vertical centreline at height y, sampled from the u faces at
|
|
/// exactly x = 0.5 (face i = n/2) and linearly interpolated between the face
|
|
/// rows at y = (j + 0.5)/n.
|
|
fn u_centerline(solution: &CavitySolution, y: f64) -> f64 {
|
|
let n = solution.n;
|
|
let mid = n / 2;
|
|
let position = y * n as f64 - 0.5;
|
|
let j0 = position.floor().clamp(0.0, (n - 2) as f64) as usize;
|
|
let t = (position - j0 as f64).clamp(0.0, 1.0);
|
|
(1.0 - t) * solution.field.u[(j0, mid)] + t * solution.field.u[(j0 + 1, mid)]
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cavity_re400_matches_ghia() -> CfdResult<()> {
|
|
let solution = solve_cavity_re400(128).await?;
|
|
let n = solution.n;
|
|
|
|
// u extremum on the vertical centreline, from the face values directly.
|
|
let mid = n / 2;
|
|
let mut u_min = f64::INFINITY;
|
|
let mut y_at_min = 0.0;
|
|
for j in 0..n {
|
|
let u = solution.field.u[(j, mid)];
|
|
if u < u_min {
|
|
u_min = u;
|
|
y_at_min = (j as f64 + 0.5) / n as f64;
|
|
}
|
|
}
|
|
|
|
// v extrema on the horizontal centreline: v faces at y = 0.5 exactly.
|
|
let jmid = n / 2;
|
|
let mut v_min = f64::INFINITY;
|
|
let mut v_max = f64::NEG_INFINITY;
|
|
let mut x_at_vmin = 0.0;
|
|
let mut x_at_vmax = 0.0;
|
|
for i in 0..n {
|
|
let v = solution.field.v[(jmid, i)];
|
|
let x = (i as f64 + 0.5) / n as f64;
|
|
if v < v_min {
|
|
v_min = v;
|
|
x_at_vmin = x;
|
|
}
|
|
if v > v_max {
|
|
v_max = v;
|
|
x_at_vmax = x;
|
|
}
|
|
}
|
|
|
|
println!(" {n}^2 TVD Re=400 ({} iterations)", solution.iterations);
|
|
println!(" u_min = {u_min:.5} at y = {y_at_min:.4} (Ghia: -0.32726 at 0.2813)");
|
|
println!(" v_min = {v_min:.5} at x = {x_at_vmin:.4} (Ghia: -0.44993 at 0.8594)");
|
|
println!(" v_max = {v_max:.5} at x = {x_at_vmax:.4} (Ghia: 0.30203 at 0.2266)");
|
|
|
|
let mut max_profile_error: f64 = 0.0;
|
|
for &(y, ghia_u) in GHIA_U_RE400 {
|
|
let u = u_centerline(&solution, y);
|
|
let error = (u - ghia_u).abs();
|
|
max_profile_error = max_profile_error.max(error);
|
|
println!(" y = {y:.4} u = {u:+.5} Ghia = {ghia_u:+.5} diff = {error:.4}");
|
|
}
|
|
println!(" max |u - Ghia| over the profile = {max_profile_error:.4}");
|
|
|
|
// Measured at 128^2, tolerance 1.5e-5: u_min = -0.32667 at y = 0.2852,
|
|
// v_min = -0.45024 at x = 0.8633, v_max = 0.30044 at x = 0.2305, max
|
|
// profile deviation 0.0051. Bands are set at 2-3x the measured gaps —
|
|
// tight enough that the 1e-4-tolerance state (-0.31987, deviation
|
|
// 0.0335) fails all of them.
|
|
assert!(
|
|
(u_min + 0.32726).abs() < 0.002,
|
|
"u_min = {u_min:.5}, more than 0.6% from Ghia's -0.32726"
|
|
);
|
|
assert!(
|
|
(y_at_min - 0.2813).abs() < 0.02,
|
|
"u minimum at y = {y_at_min:.4}, Ghia puts it at 0.2813"
|
|
);
|
|
assert!(
|
|
(v_min + 0.44993).abs() < 0.002,
|
|
"v_min = {v_min:.5}, Ghia: -0.44993"
|
|
);
|
|
assert!(
|
|
(v_max - 0.30203).abs() < 0.004,
|
|
"v_max = {v_max:.5}, Ghia: 0.30203"
|
|
);
|
|
assert!(
|
|
(x_at_vmin - 0.8594).abs() < 0.02,
|
|
"v minimum at x = {x_at_vmin:.4}, Ghia puts it at 0.8594"
|
|
);
|
|
assert!(
|
|
(x_at_vmax - 0.2266).abs() < 0.02,
|
|
"v maximum at x = {x_at_vmax:.4}, Ghia puts it at 0.2266"
|
|
);
|
|
assert!(
|
|
max_profile_error < 0.01,
|
|
"max |u - Ghia| = {max_profile_error:.4} over the centreline profile, \
|
|
above the 1% of lid speed this resolution warrants"
|
|
);
|
|
Ok(())
|
|
}
|