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]>
This commit is contained in:
Omar Sobh
2026-08-20 11:37:04 -07:00
co-authored by Claude Fable 5
parent 9969d8a661
commit 35b2b2cdf4
6 changed files with 499 additions and 4 deletions
@@ -0,0 +1,363 @@
//! TurekHron CFD2 (steady, Re = 100) and CFD3 (periodic vortex shedding,
//! Re = 200) past the rigid cylinder + flag, on the embedded-boundary PISO
//! solver with the multigrid projection.
//!
//! Geometry, parameters and reference values from the FEATFLOW benchmark
//! tables (sourced 2026-08-20, omni-cortex
//! `docs/turek_hron_geometry_decision.md`); the body model and conventions
//! are `tests/turek_hron_cfd.rs`'s (flag extended into the cylinder, loads
//! by the surface-stress and control-volume routes):
//!
//! - CFD2: `U = 1`, Re = 100, steady. Reference (level 6): **drag 136.700,
//! lift 10.5343**.
//! - CFD3: `U = 2`, Re = 200, periodic. Reference (level 4, dt 0.005):
//! **drag 439.45 ± 5.6183, lift 11.893 ± 437.81, frequency 4.3956 Hz**,
//! with the benchmark's inflow ramp `(1 cos(pi t / 2)) / 2` for
//! `t < 2 s`.
//!
//! Both cases run the inflow ramp (it is part of CFD3's definition and a
//! gentler start for CFD2's explicit march). CFD2 is settled the way CFD1
//! is: the control-volume drag stagnant to 1e-4 relative over 200 steps
//! after one flow-through time. CFD3 marches to `t = 9 s` and measures over
//! `t in [6, 9]` (~13 shedding periods): mean and amplitude as
//! `(max + min)/2 ± (max min)/2` of the control-volume series, the
//! frequency from linearly-interpolated upward zero crossings of the lift
//! about its mean, and a periodicity check that the two halves of the
//! window agree on the lift amplitude.
//!
//! Measured (TVD van Albada + multigrid, dev profile; surface route
//! primary, control volume printed as the diagnostic — its central-
//! difference evaluation truncation grows with the convective flux and the
//! two routes differ ~1525% here where they agreed to 0.6% at Re 20):
//!
//! | case | ny | surface drag | surface lift | f (Hz) | wall |
//! |------|----|--------------|--------------|--------|------|
//! | CFD2 | 41 | 119.9 ± 0.000 (12.3%) | 3.4 | steady | 57 s |
//! | CFD2 | 62 | 121.4 ± 0.000 (11.2%) | +30.2 | steady | 176 s |
//! | CFD3 | 41 | 409.0 ± 8.2 (6.9%) | 184 ± 438.0 (amp +0.05%) | 4.2746 (2.8%) | 184 s |
//! | CFD3 | 62 | 413.0 ± 11.9 (6.0%) | +160 ± 555.6 (amp +27%) | 4.3400 (1.3%) | 618 s |
//! | CFD2 | 82 | 122.6 ± 0.000 (10.3%) | +8.4 | steady | 496 s |
//! | CFD3 | 82 | 394.2 ± 9.5 (10.3%) | 2.6 ± 557.2 (amp +27%) | 4.3939 (**0.04%**) | 1506 s |
//!
//! References: CFD2 drag 136.700, lift 10.5343; CFD3 drag 439.45 ± 5.62,
//! lift 11.893 ± 437.81, f 4.3956. What holds and what does not: the
//! shedding frequency converges cleanly (2.8% → 1.3% → **0.04%** at
//! h = 5 mm) and the lift MEAN collapses onto the reference (184 → +160 →
//! 2.6 vs 11.9); the CFD2 control-volume drag converges (152.4 → 143.3 →
//! 139.4, +2.0% at 5 mm) while its surface drag sits ~10% (the Re 100200
//! boundary layer is ~510 mm — barely a cell); the CFD3 lift amplitude
//! reads +27% at both 6.6 and 5 mm, unconverged (the flag is 3 / 4 cells
//! thick, and the reference itself needed their level 4). Pre-asymptotic
//! numbers are recorded, not asserted. Suite defaults: CFD2 at ny = 62,
//! CFD3 at ny = 41 (its cost); `RTX_CFD2_NY` / `RTX_CFD3_NY` override.
use rtx_cfd::solvers::incompressible::{
AleBoundaries, ConvectionScheme, EmbeddedBody, EmbeddedParameters, EmbeddedPisoSolver,
FlowField, PoissonSolverKind, SideBoundary,
};
use rtx_cfd::{CfdConfig, CfdResult};
const L: f64 = 2.5;
const H: f64 = 0.41;
const RHO: f64 = 1000.0;
const NU: f64 = 1e-3;
const CFD2_U: f64 = 1.0;
const CFD2_REF_DRAG: f64 = 136.700;
const CFD2_REF_LIFT: f64 = 10.5343;
const CFD3_U: f64 = 2.0;
const CFD3_REF_DRAG_MEAN: f64 = 439.45;
const CFD3_REF_DRAG_AMP: f64 = 5.6183;
const CFD3_REF_LIFT_MEAN: f64 = -11.893;
const CFD3_REF_LIFT_AMP: f64 = 437.81;
const CFD3_REF_FREQUENCY: f64 = 4.3956;
fn body() -> EmbeddedBody {
EmbeddedBody::union(
EmbeddedBody::circle(0.2, 0.2, 0.05),
EmbeddedBody::rectangle(0.20, 0.19, 0.6, 0.21),
)
}
/// The ramped parabolic inflow of the benchmark definition.
fn inflow(u_mean: f64, y: f64, t: f64) -> f64 {
let ramp = if t < 2.0 {
0.5 * (1.0 - (std::f64::consts::PI * t / 2.0).cos())
} else {
1.0
};
ramp * 1.5 * u_mean * y * (H - y) / (0.5 * H).powi(2)
}
struct Runner {
solver: EmbeddedPisoSolver,
field: FlowField,
nx: usize,
ny: usize,
h: f64,
dt: f64,
mu: f64,
cv: (usize, usize, usize, usize),
}
impl Runner {
fn new(u_mean: f64, ny: usize) -> CfdResult<Self> {
let h = H / ny as f64;
let nx = (L / h).round() as usize;
let mu = RHO * NU;
// Combined explicit criterion with the blockage's local peak
// (see turek_hron_cfd.rs for the failure that taught it).
let u_peak = 1.5 * 1.5 * u_mean;
let dt = 0.25 / (2.0 * u_peak / h + 4.0 * NU / (h * h));
let config = CfdConfig::new()
.with_density(RHO)
.with_viscosity(mu)
.with_reference_velocity(u_mean)
.with_reference_length(0.1);
let params = EmbeddedParameters {
corrector_steps: 2,
tolerance: 1e-7,
boundaries: AleBoundaries {
left: SideBoundary::Velocity,
right: SideBoundary::PressureOutlet,
bottom: SideBoundary::Velocity,
top: SideBoundary::Velocity,
},
poisson_solver: PoissonSolverKind::Multigrid,
// Upwind's numerical viscosity (|u| h / 2 ~ 10x the physical nu
// on these grids) suppressed CFD3's vortex shedding entirely:
// the ny = 41 upwind run produced ONE lift zero-crossing in
// three seconds. The limited scheme restores the physics.
convection_scheme: ConvectionScheme::TvdVanAlbada,
};
let mut solver = EmbeddedPisoSolver::new(config, params)?;
solver.set_boundary_velocity(move |x, y, t| {
if x <= 0.0 {
(inflow(u_mean, y, t), 0.0)
} else {
(0.0, 0.0)
}
});
solver.set_body(body());
// Start at rest: the ramp brings the inflow up from zero.
let mut field = FlowField::new(nx, ny, h, h)?;
solver.initialize(&mut field)?;
let cv = (
(0.10 / h).round() as usize,
(0.75 / h).round() as usize,
(0.05 / h).round() as usize,
(0.36 / h).round() as usize,
);
Ok(Self {
solver,
field,
nx,
ny,
h,
dt,
mu,
cv,
})
}
fn cv_force(&self) -> (f64, f64) {
self.solver.mask().unwrap().control_volume_force(
&self.field.u,
&self.field.v,
&self.field.p,
&self.field.u_old,
&self.field.v_old,
self.dt,
RHO,
self.mu,
None,
self.cv,
)
}
fn surface_force(&self) -> rtx_cfd::solvers::incompressible::SurfaceForce {
self.solver.mask().unwrap().surface_force(
self.solver.body().unwrap(),
&self.field.u,
&self.field.v,
&self.field.p,
self.mu,
self.solver.time(),
0.5 * self.h,
)
}
async fn step(&mut self) -> CfdResult<()> {
self.solver.advance(&mut self.field, self.dt).await?;
let umax = self
.field
.u
.iter()
.fold(0.0f64, |acc, &value| acc.max(value.abs()));
assert!(
umax.is_finite(),
"velocity became non-finite at t = {:.3}",
self.solver.time()
);
Ok(())
}
}
/// One sampled series of both load routes.
struct Series {
times: Vec<f64>,
surface_drag: Vec<f64>,
surface_lift: Vec<f64>,
skipped_max: usize,
cv_drag: Vec<f64>,
cv_lift: Vec<f64>,
steps: usize,
seconds: f64,
}
/// March to `t_end`, sampling both load routes every 25 steps once
/// `t >= t_start`. With the TVD convection even the nominally steady CFD2
/// oscillates a little on coarse grids (the upwind run was steady only
/// because its numerical viscosity was ten times the physical one), so
/// every case is measured the same way: time statistics over a window,
/// never a single snapshot.
async fn run_sampled(u_mean: f64, ny: usize, t_start: f64, t_end: f64) -> CfdResult<Series> {
let mut runner = Runner::new(u_mean, ny)?;
let start = std::time::Instant::now();
let mut series = Series {
times: Vec::new(),
surface_drag: Vec::new(),
surface_lift: Vec::new(),
skipped_max: 0,
cv_drag: Vec::new(),
cv_lift: Vec::new(),
steps: 0,
seconds: 0.0,
};
while runner.solver.time() < t_end {
runner.step().await?;
series.steps += 1;
if series.steps % 25 == 0 && runner.solver.time() >= t_start {
let surface = runner.surface_force();
let (cx, cy) = runner.cv_force();
series.times.push(runner.solver.time());
series.surface_drag.push(surface.fx);
series.surface_lift.push(surface.fy);
series.skipped_max = series.skipped_max.max(surface.skipped);
series.cv_drag.push(cx);
series.cv_lift.push(cy);
}
}
series.seconds = start.elapsed().as_secs_f64();
assert!(series.times.len() > 50, "too few samples in the window");
Ok(series)
}
/// Mid-range mean and half-range amplitude of a series.
fn mid_amp(series: &[f64]) -> (f64, f64) {
let max = series.iter().copied().fold(f64::MIN, f64::max);
let min = series.iter().copied().fold(f64::MAX, f64::min);
(0.5 * (max + min), 0.5 * (max - min))
}
/// Frequency from linearly-interpolated upward zero crossings about the
/// mean; `None` with fewer than four crossings.
fn crossing_frequency(times: &[f64], series: &[f64]) -> Option<f64> {
let (mean, _) = mid_amp(series);
let mut crossings: Vec<f64> = Vec::new();
for k in 1..series.len() {
let (a, b) = (series[k - 1] - mean, series[k] - mean);
if a < 0.0 && b >= 0.0 {
let frac = a / (a - b);
crossings.push(times[k - 1] + frac * (times[k] - times[k - 1]));
}
}
(crossings.len() >= 4).then(|| {
(crossings.len() - 1) as f64 / (crossings.last().unwrap() - crossings.first().unwrap())
})
}
fn ny_list(var: &str, default: &[usize]) -> Vec<usize> {
std::env::var(var)
.ok()
.map(|s| {
s.split(',')
.map(|t| t.trim().parse().expect("integer ny"))
.collect()
})
.unwrap_or_else(|| default.to_vec())
}
#[tokio::test]
async fn cfd2_steady_drag_and_lift() -> CfdResult<()> {
let resolutions = ny_list("RTX_CFD2_NY", &[62]);
let rel = |a: f64, b: f64| ((a - b) / b).abs();
for &ny in &resolutions {
let r = run_sampled(CFD2_U, ny, 8.0, 10.0).await?;
let (drag_s, drag_s_amp) = mid_amp(&r.surface_drag);
let (lift_s, lift_s_amp) = mid_amp(&r.surface_lift);
let (drag_c, _) = mid_amp(&r.cv_drag);
let (lift_c, _) = mid_amp(&r.cv_lift);
println!(
" CFD2 ny = {ny:3} (h = {:.4}) surface: drag {drag_s:.3} ± {drag_s_amp:.3} lift {lift_s:.3} ± {lift_s_amp:.3} (skipped ≤ {}) \
control volume means: drag {drag_c:.3} lift {lift_c:.3} [{} steps, {:.0} s] reference drag {CFD2_REF_DRAG} lift {CFD2_REF_LIFT}",
H / ny as f64,
r.skipped_max,
r.steps,
r.seconds
);
assert!(
rel(drag_s, CFD2_REF_DRAG) < 0.15,
"ny = {ny}: surface drag mean {drag_s:.3} vs reference {CFD2_REF_DRAG}"
);
}
Ok(())
}
#[tokio::test]
async fn cfd3_shedding_frequency_and_loads() -> CfdResult<()> {
let resolutions = ny_list("RTX_CFD3_NY", &[41]);
let rel = |a: f64, b: f64| ((a - b) / b).abs();
for &ny in &resolutions {
let r = run_sampled(CFD3_U, ny, 6.0, 9.0).await?;
let (drag_mean, drag_amp) = mid_amp(&r.surface_drag);
let (lift_mean, lift_amp) = mid_amp(&r.surface_lift);
let frequency = crossing_frequency(&r.times, &r.surface_lift);
let half = r.surface_lift.len() / 2;
let (_, amp_first) = mid_amp(&r.surface_lift[..half]);
let (_, amp_second) = mid_amp(&r.surface_lift[half..]);
let (cv_drag_mean, cv_drag_amp) = mid_amp(&r.cv_drag);
let (cv_lift_mean, cv_lift_amp) = mid_amp(&r.cv_lift);
println!(
" CFD3 ny = {ny:3} (h = {:.4}) surface: drag {drag_mean:.2} ± {drag_amp:.2}, lift {lift_mean:.2} ± {lift_amp:.2}, f = {frequency:?} Hz (skipped ≤ {}) \
CV: drag {cv_drag_mean:.2} ± {cv_drag_amp:.2}, lift {cv_lift_mean:.2} ± {cv_lift_amp:.2} half-window lift amps {amp_first:.2}/{amp_second:.2} \
[{} steps, {:.0} s] reference drag {CFD3_REF_DRAG_MEAN} ± {CFD3_REF_DRAG_AMP}, lift {CFD3_REF_LIFT_MEAN} ± {CFD3_REF_LIFT_AMP}, f {CFD3_REF_FREQUENCY}",
H / ny as f64,
r.skipped_max,
r.steps,
r.seconds
);
let frequency = frequency.expect("the wake must shed: fewer than four lift zero-crossings");
assert!(
(amp_first - amp_second).abs() < 0.15 * amp_second.max(1e-9),
"ny = {ny}: lift amplitude still drifting: halves {amp_first:.2} / {amp_second:.2}"
);
assert!(
rel(frequency, CFD3_REF_FREQUENCY) < 0.10,
"ny = {ny}: shedding frequency {frequency:.4} vs reference {CFD3_REF_FREQUENCY}"
);
assert!(
rel(drag_mean, CFD3_REF_DRAG_MEAN) < 0.15,
"ny = {ny}: mean surface drag {drag_mean:.2} vs reference {CFD3_REF_DRAG_MEAN}"
);
assert!(
rel(lift_amp, CFD3_REF_LIFT_AMP) < 0.35,
"ny = {ny}: surface lift amplitude {lift_amp:.2} vs reference {CFD3_REF_LIFT_AMP}"
);
}
Ok(())
}