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
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]>
262 lines
8.8 KiB
Rust
262 lines
8.8 KiB
Rust
//! Decaying Taylor–Green 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 Navier–Stokes 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 pressure–velocity 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 space–time
|
||
//! 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,
|
||
..PisoParameters::default()
|
||
};
|
||
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(())
|
||
}
|