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]>
505 lines
18 KiB
Rust
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(())
|
|
}
|