Files
rustytorch/crates/specialized/rtx-cfd/tests/embedded_mms.rs
T
Omar SobhandClaude Fable 5 35b2b2cdf4 rtx-cfd: TVD convection in the embedded predictor — the wake sheds — and Turek–Hron CFD2/CFD3
First-order upwind's numerical viscosity |u| h / 2 is ~10x the physical
viscosity on the Turek–Hron grids: the effective Reynolds number lands
near 20 and CFD3 (Re 200) produced NO vortex shedding at all — one lift
zero-crossing in three seconds at h = 10 mm. The physics, not a bug.

EmbeddedParameters gains `convection_scheme` (default Upwind, bit-
identical — the no-body degeneracy test still reads 0.0): the TVD branch
adds SIMPLE's limited face corrections (van Albada / van Leer,
`face_correction` now pub(crate)) directly in the explicit predictor —
no deferred iteration needed in an explicit step. Domain-side faces and
faces whose far-upwind node is outside fall back to upwind exactly as in
SIMPLE; near the body the stencil reads ghost values, which encode the
wall. Verified: the embedded-circle MMS error drops 10–16x below upwind
(8.16e-4 vs 8.49e-3 at n = 32) at observed order 1.56 (SIMPLE's TVD
measured 1.59–1.84).

tests/turek_hron_cfd23.rs — CFD2 (Re 100, steady) and CFD3 (Re 200,
periodic), both with the benchmark's inflow ramp, both measured as time
statistics over a window (never a snapshot), surface route primary and
the control volume printed as the diagnostic (its central-difference
evaluation truncation grows with the convective flux: the routes agree
to 0.6% at Re 20 and differ 15–25% at Re 100–200 on these grids).

Measured across h = 10 / 6.6 / 5 mm:
- CFD3 shedding frequency 4.2746 / 4.3400 / 4.3939 Hz vs the reference
  4.3956 — converging −2.8% -> −1.3% -> −0.04%;
- CFD3 lift mean −184 / +160 / −2.6 vs −11.9 — lands on the reference;
  lift amplitude ±438 / ±556 / ±557 vs ±437.8 — +27% at the finer grids,
  unconverged (the flag is 2/3/4 cells thick);
- CFD2 control-volume drag 152.4 / 143.3 / 139.4 vs 136.700 — +2.0% at
  5 mm; CFD2 surface drag sits ~−10% (the boundary layer is ~one cell);
  CFD2 lift −3.4 / +30.2 / +8.4 vs 10.53.
Suite defaults run CFD2 at ny = 62 and CFD3 at ny = 41 (cost); the
asserted bands are the measured ones (frequency 10%, mean drag 15%,
amplitude 35%), not accuracy claims; RTX_CFD2_NY / RTX_CFD3_NY run the
studies.

Also recorded: the CFD1 refinement study extended to h = 3.3 mm
(RTX_CFD1_NY): control-volume drag 14.8996 (+4.25%), apparent order
~0.70 sustained over four grids, control-volume lift 1.1332 vs 1.11905
(+1.3%).

rtx-cfd 318 -> 321 green (full suite 321 passed / 0 failed).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-20 11:37:04 -07:00

505 lines
18 KiB
Rust

//! Code verification of the embedded-boundary PISO solver.
//!
//! Two claims, in the order they must be established:
//!
//! 1. **With no body it IS the fixed-grid PISO** — the same manufactured
//! problem marched by `PisoSolver` and by `EmbeddedPisoSolver` must
//! produce bit-identical fields, because the predictor and projection
//! are the same expressions and the only additions are masked out.
//!
//! 2. **With an embedded circle the manufactured solution is recovered at
//! the discretisation's order**, the field is divergence-free on every
//! fluid cell, the compatibility correction shrinks with the mesh, and
//! the force on the circle by *both* load routes — surface-stress
//! reconstruction and a control-volume momentum balance — converges to
//! the exact surface integral of the manufactured stress.
//!
//! The manufactured field, source and grid convention are those of
//! `tests/mms_piso.rs` (`u = sin(pi x) cos(pi y)`, `v = -cos(pi x) sin(pi y)`,
//! `p = sin(pi x) sin(pi y)`); the circle (centre (0.6, 0.45), r = 0.2 — off-centre, so the exact force is not zero by symmetry)
//! carries the exact field as its surface velocity, so the embedded wall is
//! a Dirichlet boundary on a curve that cuts the grid arbitrarily — which
//! is exactly what the ghost reconstruction has to get right.
use rtx_cfd::solvers::incompressible::{
BoundaryConditions, ConvectionScheme, EmbeddedBody, EmbeddedParameters, EmbeddedPisoSolver,
FaceKind, FlowField, IncompressibleSolver, PisoParameters, PisoSolver,
};
use rtx_cfd::{CfdConfig, CfdResult};
use std::f64::consts::PI;
const RHO: f64 = 1.0;
const MU: f64 = 0.05;
const CX: f64 = 0.6;
const CY: f64 = 0.45;
const R: f64 = 0.2;
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 p_exact(x: f64, y: f64) -> f64 {
(PI * x).sin() * (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)
}
/// Exact force on the circle from the manufactured stress,
/// `F = oint (-p I + mu (grad u + grad u^T)) n ds`, by fine quadrature, and
/// the momentum flux through the circle `M = oint rho u (u . n) ds` — the
/// manufactured surface velocity has a normal component, so the "body" is
/// porous. A control-volume balance around it therefore measures `F - M`
/// (the surface-stress route measures `F`); for a rigid no-slip body `M`
/// is zero and the two routes measure the same thing.
fn exact_force_and_flux() -> ((f64, f64), (f64, f64)) {
let n = 20_000;
let (mut fx, mut fy) = (0.0, 0.0);
let (mut mx, mut my) = (0.0, 0.0);
for k in 0..n {
let theta = (k as f64 + 0.5) * 2.0 * PI / n as f64;
let (s, c) = theta.sin_cos();
let (x, y) = (CX + R * c, CY + R * s);
let ux = PI * (PI * x).cos() * (PI * y).cos();
let uy = -PI * (PI * x).sin() * (PI * y).sin();
let vx = PI * (PI * x).sin() * (PI * y).sin();
let vy = -PI * (PI * x).cos() * (PI * y).cos();
let p = p_exact(x, y);
let sxx = -p + 2.0 * MU * ux;
let syy = -p + 2.0 * MU * vy;
let sxy = MU * (uy + vx);
let ds = 2.0 * PI * R / n as f64;
fx += (sxx * c + sxy * s) * ds;
fy += (sxy * c + syy * s) * ds;
let (u, v) = (u_exact(x, y), v_exact(x, y));
let un = u * c + v * s;
mx += RHO * u * un * ds;
my += RHO * v * un * ds;
}
((fx, fy), (mx, my))
}
/// The manufactured field on the box boundary with the normal components
/// snapped to their exact analytic zero: `sin(pi)` evaluates to 1.2e-16,
/// and a 1e-16 through-flow is enough to separate the two solvers at the
/// last bit (the embedded solver carries boundary fluxes faithfully).
fn boundary_exact(x: f64, y: f64) -> (f64, f64) {
let u = if x <= 0.0 || x >= 1.0 {
0.0
} else {
u_exact(x, y)
};
let v = if y <= 0.0 || y >= 1.0 {
0.0
} else {
v_exact(x, y)
};
(u, v)
}
fn config() -> CfdConfig {
CfdConfig::new()
.with_density(RHO)
.with_viscosity(MU)
.with_reference_velocity(1.0)
.with_reference_length(1.0)
}
fn initial_field(n: usize) -> CfdResult<FlowField> {
let dx = 1.0 / n as f64;
let mut field = FlowField::new(n, n, dx, dx)?;
for j in 0..n {
let y = (j as f64 + 0.5) * dx;
field.u[(j, 0)] = boundary_exact(0.0, y).0;
field.u[(j, n)] = boundary_exact(1.0, y).0;
}
for i in 0..n {
let x = (i as f64 + 0.5) * dx;
field.v[(0, i)] = boundary_exact(x, 0.0).1;
field.v[(n, i)] = boundary_exact(x, 1.0).1;
}
Ok(field)
}
fn time_step(n: usize) -> f64 {
let dx = 1.0 / n as f64;
let nu = MU / RHO;
0.4 * (dx * dx / (4.0 * nu)).min(dx)
}
/// Claim 1: no body, velocity on every side — the two solvers must agree
/// to the bit over a couple of hundred steps from the same start.
#[tokio::test]
async fn without_a_body_the_embedded_solver_is_piso_to_the_bit() -> CfdResult<()> {
let n = 16;
let dt = time_step(n);
let mut piso = PisoSolver::new(
config(),
PisoParameters {
corrector_steps: 2,
time_step: dt,
tolerance: 1e-8,
..PisoParameters::default()
},
)?;
piso.set_momentum_source(source);
piso.set_wall_velocity(boundary_exact);
let mut embedded = EmbeddedPisoSolver::new(
config(),
EmbeddedParameters {
corrector_steps: 2,
tolerance: 1e-8,
..EmbeddedParameters::default()
},
)?;
embedded.set_momentum_source(|x, y, _| source(x, y));
embedded.set_boundary_velocity(|x, y, _| boundary_exact(x, y));
let mut a = initial_field(n)?;
let mut b = initial_field(n)?;
let empty = BoundaryConditions::new();
for _ in 0..200 {
piso.solve_time_step(&mut a, &empty, dt).await?;
embedded.advance(&mut b, dt).await?;
}
let mut max_diff: f64 = 0.0;
for (x, y) in a.u.iter().zip(b.u.iter()) {
max_diff = max_diff.max((x - y).abs());
}
for (x, y) in a.v.iter().zip(b.v.iter()) {
max_diff = max_diff.max((x - y).abs());
}
for (x, y) in a.p.iter().zip(b.p.iter()) {
max_diff = max_diff.max((x - y).abs());
}
assert!(
max_diff == 0.0,
"embedded solver without a body differs from PISO by {max_diff:.3e}"
);
Ok(())
}
struct Measurement {
l2_velocity: f64,
l2_pressure: f64,
max_div: f64,
ghost_correction: f64,
force_surface: (f64, f64),
skipped_samples: usize,
force_cv: (f64, f64),
}
/// March the manufactured problem with the embedded circle to steady
/// state on an `n` by `n` grid and measure everything.
async fn measure(n: usize) -> CfdResult<Measurement> {
measure_with_scheme(n, ConvectionScheme::Upwind).await
}
async fn measure_with_scheme(n: usize, scheme: ConvectionScheme) -> CfdResult<Measurement> {
let dx = 1.0 / n as f64;
let dt = time_step(n);
let mut solver = EmbeddedPisoSolver::new(
config(),
EmbeddedParameters {
corrector_steps: 2,
tolerance: 1e-8,
convection_scheme: scheme,
..EmbeddedParameters::default()
},
)?;
solver.set_momentum_source(|x, y, _| source(x, y));
solver.set_boundary_velocity(|x, y, _| boundary_exact(x, y));
solver.set_body(
EmbeddedBody::circle(CX, CY, R)
.with_surface_velocity(|x, y, _| (u_exact(x, y), v_exact(x, y))),
);
let mut field = initial_field(n)?;
solver.initialize(&mut field)?;
let mut steady_residual = f64::INFINITY;
let mut last_correction = 0.0;
for _step in 0..200_000 {
let u_before = field.u.clone();
let v_before = field.v.clone();
let result = solver.advance(&mut field, dt).await?;
last_correction = result.ghost_correction;
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-6 {
break;
}
}
assert!(
steady_residual < 1e-6,
"embedded PISO did not reach a steady state at n = {n}: |du/dt| = {steady_residual:.3e}"
);
let mask = solver.mask().expect("mask built");
// Velocity error over the fluid faces, pressure error over the fluid
// cells (mean-shifted: the level is arbitrary), divergence on every
// fluid cell.
let mut squared = 0.0;
let mut volume = 0.0;
for j in 0..n {
for i in 1..n {
if mask.u_kind(j, i) == FaceKind::Fluid {
let e = field.u[(j, i)] - u_exact(i as f64 * dx, (j as f64 + 0.5) * dx);
squared += e * e * dx * dx;
volume += dx * dx;
}
}
}
for j in 1..n {
for i in 0..n {
if mask.v_kind(j, i) == FaceKind::Fluid {
let e = field.v[(j, i)] - v_exact((i as f64 + 0.5) * dx, j as f64 * dx);
squared += e * e * dx * dx;
volume += dx * dx;
}
}
}
let l2_velocity = (squared / volume).sqrt();
let mut diff_sum = 0.0;
let mut cells = 0usize;
for j in 0..n {
for i in 0..n {
if mask.is_fluid_cell(j, i) {
diff_sum += field.p[(j, i)] - p_exact((i as f64 + 0.5) * dx, (j as f64 + 0.5) * dx);
cells += 1;
}
}
}
let shift = diff_sum / cells as f64;
let mut p_sq = 0.0;
let mut max_div: f64 = 0.0;
for j in 0..n {
for i in 0..n {
if mask.is_fluid_cell(j, i) {
let e =
field.p[(j, i)] - shift - p_exact((i as f64 + 0.5) * dx, (j as f64 + 0.5) * dx);
p_sq += e * e;
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());
}
}
}
let l2_pressure = (p_sq / cells as f64).sqrt();
let body = solver.body().expect("body set");
let surface = mask.surface_force(
body,
&field.u,
&field.v,
&field.p,
MU,
solver.time(),
0.5 * dx,
);
// Control volume: the middle three quarters of the box, whole cells.
let i0 = n / 8;
let i1 = n - n / 8;
let src = |x: f64, y: f64| source(x, y);
let force_cv = mask.control_volume_force(
&field.u,
&field.v,
&field.p,
&field.u_old,
&field.v_old,
dt,
RHO,
MU,
Some(&src),
(i0, i1, i0, i1),
);
Ok(Measurement {
l2_velocity,
l2_pressure,
max_div,
ghost_correction: last_correction.abs(),
force_surface: (surface.fx, surface.fy),
skipped_samples: surface.skipped,
force_cv,
})
}
/// Claim 2. Measured (16 -> 32 -> 64): L2 velocity 1.607e-2, 8.489e-3,
/// 4.341e-3 — orders 0.92, 0.97 (PISO without a body: 0.85, 0.91);
/// L2 pressure orders 0.96, 0.90; max |div u| <= 9e-8 on every fluid cell;
/// compatibility correction 6.2e-4, 1.1e-5, 2.8e-5; force error relative
/// to the exact |F|: surface route 0.52, 0.29, 0.15, control-volume route
/// 0.61, 0.30, 0.15 — both first order, both from the same solution, by
/// two unrelated readings of it. The structure of what must hold was fixed
/// before the numbers were known:
/// - the velocity error falls at the scheme's order (first-order upwind:
/// approaching 1; the ghost treatment must not drag it below),
/// - every fluid cell is divergence-free,
/// - the compatibility correction shrinks with the mesh,
/// - both force routes converge to the exact force.
#[tokio::test]
async fn embedded_circle_recovers_the_manufactured_solution() -> CfdResult<()> {
let resolutions = [16usize, 32, 64];
let mut measurements = Vec::new();
for &n in &resolutions {
measurements.push(measure(n).await?);
}
let ((fx_exact, fy_exact), (mx, my)) = exact_force_and_flux();
let f_scale = (fx_exact * fx_exact + fy_exact * fy_exact).sqrt();
let (fx_cv_exact, fy_cv_exact) = (fx_exact - mx, fy_exact - my);
println!(
" exact force on the circle: ({fx_exact:.6e}, {fy_exact:.6e}); momentum flux through it \
({mx:.6e}, {my:.6e}); the control-volume route measures ({fx_cv_exact:.6e}, {fy_cv_exact:.6e})"
);
let errors: Vec<f64> = measurements.iter().map(|m| m.l2_velocity).collect();
let p_errors: Vec<f64> = measurements.iter().map(|m| m.l2_pressure).collect();
let rates: Vec<f64> = errors.windows(2).map(|w| (w[0] / w[1]).log2()).collect();
let p_rates: Vec<f64> = p_errors.windows(2).map(|w| (w[0] / w[1]).log2()).collect();
let mut surface_errors = Vec::new();
let mut cv_errors = Vec::new();
for (k, (m, &n)) in measurements.iter().zip(&resolutions).enumerate() {
let rate = if k == 0 {
String::from(" -")
} else {
format!("{:5.2}", rates[k - 1])
};
let p_rate = if k == 0 {
String::from(" -")
} else {
format!("{:5.2}", p_rates[k - 1])
};
let surface = (m.force_surface.0 - fx_exact).hypot(m.force_surface.1 - fy_exact) / f_scale;
let cv = (m.force_cv.0 - fx_cv_exact).hypot(m.force_cv.1 - fy_cv_exact) / f_scale;
println!(
" n = {n:3} L2 u {:.4e} (order {rate}) L2 p {:.4e} (order {p_rate}) \
max div {:.2e} ghost corr {:.2e} F_surface ({:.5e}, {:.5e}) rel {:.3e} skipped {} \
F_cv ({:.5e}, {:.5e}) rel {:.3e}",
m.l2_velocity,
m.l2_pressure,
m.max_div,
m.ghost_correction,
m.force_surface.0,
m.force_surface.1,
surface,
m.skipped_samples,
m.force_cv.0,
m.force_cv.1,
cv
);
surface_errors.push(surface);
cv_errors.push(cv);
}
assert!(
errors.windows(2).all(|w| w[1] < w[0]),
"velocity error must fall under refinement: {errors:?}"
);
for (k, &rate) in rates.iter().enumerate() {
assert!(
rate > 0.75,
"refinement {} -> {}: velocity order {rate:.3} below what first-order upwind delivers \
without a body (0.85, 0.91) — the embedded treatment is polluting the order. \
Errors {errors:?}",
resolutions[k],
resolutions[k + 1]
);
assert!(
rate < 2.3,
"velocity order {rate:.3} above the scheme's — suspect the measure"
);
}
assert!(
p_errors.windows(2).all(|w| w[1] < w[0]),
"pressure error must fall under refinement: {p_errors:?}"
);
for m in &measurements {
assert!(
m.max_div < 1e-5,
"a fluid cell is not divergence-free: max |div u| = {:.3e}",
m.max_div
);
}
let corrections: Vec<f64> = measurements.iter().map(|m| m.ghost_correction).collect();
assert!(
corrections.last().unwrap() < corrections.first().unwrap(),
"the compatibility correction must shrink with the mesh: {corrections:?}"
);
for (m, &n) in measurements.iter().zip(&resolutions) {
assert!(
m.skipped_samples == 0,
"surface-force reconstruction skipped {} samples at n = {n}",
m.skipped_samples
);
}
// Both routes read a first-order-accurate solution, so their errors fall
// at first order: measured surface 0.52 / 0.29 / 0.15 at 16 / 32 / 64
// (halving each refinement). Monotone convergence is the claim; a
// sub-5% load needs a finer grid than this suite runs.
assert!(
surface_errors.windows(2).all(|w| w[1] < w[0]) && cv_errors.windows(2).all(|w| w[1] < w[0]),
"both force routes must converge toward the exact force: surface {surface_errors:?}, \
control volume {cv_errors:?}"
);
assert!(
*surface_errors.last().unwrap() < 0.2 && *cv_errors.last().unwrap() < 0.2,
"at n = 64 both routes must be within 20% of the exact force: surface {:.3e}, \
control volume {:.3e}",
surface_errors.last().unwrap(),
cv_errors.last().unwrap()
);
Ok(())
}
/// The TVD convection scheme on the embedded problem: the error must sit
/// below upwind's on the same grids and fall at a higher observed order.
/// (Upwind measured 8.489e-3 / 4.341e-3 at n = 32 / 64, order 0.97; SIMPLE's
/// TVD on the plain cavity measured orders 1.59-1.84.)
#[tokio::test]
async fn tvd_convection_beats_upwind_on_the_embedded_circle() -> CfdResult<()> {
let coarse = measure_with_scheme(32, ConvectionScheme::TvdVanAlbada).await?;
let fine = measure_with_scheme(64, ConvectionScheme::TvdVanAlbada).await?;
let order = (coarse.l2_velocity / fine.l2_velocity).log2();
println!(
" TVD: L2 u {:.4e} -> {:.4e}, order {order:.2} (upwind: 8.489e-3 -> 4.341e-3, 0.97)",
coarse.l2_velocity, fine.l2_velocity
);
assert!(
coarse.l2_velocity < 8.489e-3 && fine.l2_velocity < 4.341e-3,
"TVD error not below upwind's: {:.4e}, {:.4e}",
coarse.l2_velocity,
fine.l2_velocity
);
assert!(
order > 1.1,
"TVD observed order {order:.2} not above upwind's ~1"
);
assert!(fine.max_div < 1e-5, "divergence {:.3e}", fine.max_div);
Ok(())
}