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]>
691 lines
24 KiB
Rust
691 lines
24 KiB
Rust
//! The multigrid projection solves the SOR projection's system.
|
||
//!
|
||
//! `PisoSolver` and `EmbeddedPisoSolver` can run their pressure-correction
|
||
//! projection either by point SOR (the historical path) or by the
|
||
//! multigrid-preconditioned CG solver of `solvers/incompressible/poisson.rs`
|
||
//! (`PoissonSolverKind::Multigrid`). The two are handed the same five-point
|
||
//! coefficients, the same mass-imbalance source, the same anchor and the
|
||
//! same true-residual stop, so every benchmark of this suite must land on
|
||
//! the same discrete answer whichever is selected — to the inner tolerance,
|
||
//! which for the steady states below is far below the discretisation error
|
||
//! being measured. A multigrid branch that assembled a different system
|
||
//! (a wrong outlet coefficient, a dropped anchor, a coefficient across a
|
||
//! prescribed face) would move the steady solution by far more than the
|
||
//! tolerances here, and would not be caught by the solver's own unit tests,
|
||
//! which only see the `PoissonProblem` it is given.
|
||
//!
|
||
//! Four settings:
|
||
//! 1. the fixed-grid PISO manufactured steady solution (`tests/mms_piso.rs`
|
||
//! at n = 32): same L2 velocity error to `1e-6` relative, divergence-free;
|
||
//! 2. the decaying Taylor–Green vortex (`tests/taylor_green.rs` at n = 32):
|
||
//! divergence-free on every step, the energy-decay error no worse;
|
||
//! 3. the embedded-circle manufactured solution (`tests/embedded_mms.rs` at
|
||
//! n = 32): same L2 velocity error to `1e-5` relative, and the no-body
|
||
//! degeneracy (embedded solver == PISO to the bit) holds with multigrid
|
||
//! on both;
|
||
//! 4. a channel with a pressure outlet and an embedded circle — the
|
||
//! Turek–Hron configuration in miniature, which exercises the outlet's
|
||
//! Dirichlet column and the level-free (un-anchored) system: the steady
|
||
//! fields agree to `1e-6` relative.
|
||
|
||
use rtx_cfd::solvers::incompressible::{
|
||
AleBoundaries, BoundaryConditions, EmbeddedBody, EmbeddedParameters, EmbeddedPisoSolver,
|
||
FaceKind, FlowField, IncompressibleSolver, PisoParameters, PisoSolver, PoissonSolverKind,
|
||
SideBoundary,
|
||
};
|
||
use rtx_cfd::{CfdConfig, CfdResult};
|
||
use std::f64::consts::PI;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Shared manufactured field (tests/mms_piso.rs, tests/embedded_mms.rs).
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const RHO: f64 = 1.0;
|
||
const MU: f64 = 0.05;
|
||
|
||
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 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)
|
||
}
|
||
|
||
fn mms_config() -> CfdConfig {
|
||
CfdConfig::new()
|
||
.with_density(RHO)
|
||
.with_viscosity(MU)
|
||
.with_reference_velocity(1.0)
|
||
.with_reference_length(1.0)
|
||
}
|
||
|
||
fn mms_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)
|
||
}
|
||
|
||
fn max_divergence(field: &FlowField, fluid: impl Fn(usize, usize) -> bool) -> f64 {
|
||
let (nx, ny, dx, dy) = field.grid_info();
|
||
let mut max_div: f64 = 0.0;
|
||
for j in 0..ny {
|
||
for i in 0..nx {
|
||
if !fluid(j, i) {
|
||
continue;
|
||
}
|
||
let div = (field.u[(j, i + 1)] - field.u[(j, i)]) / dx
|
||
+ (field.v[(j + 1, i)] - field.v[(j, i)]) / dy;
|
||
max_div = max_div.max(div.abs());
|
||
}
|
||
}
|
||
max_div
|
||
}
|
||
|
||
fn max_change(a: &FlowField, b: &FlowField) -> f64 {
|
||
let mut m: f64 = 0.0;
|
||
for (x, y) in a.u.iter().zip(b.u.iter()) {
|
||
m = m.max((x - y).abs());
|
||
}
|
||
for (x, y) in a.v.iter().zip(b.v.iter()) {
|
||
m = m.max((x - y).abs());
|
||
}
|
||
m
|
||
}
|
||
|
||
fn rel(a: f64, b: f64) -> f64 {
|
||
((a - b) / b).abs()
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 1. Fixed-grid PISO manufactured steady solution.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
struct SteadyMeasurement {
|
||
l2_velocity: f64,
|
||
max_div: f64,
|
||
steps: usize,
|
||
seconds: f64,
|
||
}
|
||
|
||
/// `tests/mms_piso.rs::measure`, with the inner solver selectable.
|
||
async fn piso_mms(n: usize, kind: PoissonSolverKind) -> CfdResult<SteadyMeasurement> {
|
||
let dx = 1.0 / n as f64;
|
||
let dt = mms_time_step(n);
|
||
let mut solver = PisoSolver::new(
|
||
mms_config(),
|
||
PisoParameters {
|
||
corrector_steps: 2,
|
||
time_step: dt,
|
||
tolerance: 1e-8,
|
||
poisson_solver: kind,
|
||
},
|
||
)?;
|
||
solver.set_momentum_source(source);
|
||
solver.set_wall_velocity(|x, y| (u_exact(x, y), v_exact(x, y)));
|
||
|
||
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)] = u_exact(0.0, y);
|
||
field.u[(j, n)] = u_exact(1.0, y);
|
||
}
|
||
for i in 0..n {
|
||
let x = (i as f64 + 0.5) * dx;
|
||
field.v[(0, i)] = v_exact(x, 0.0);
|
||
field.v[(n, i)] = v_exact(x, 1.0);
|
||
}
|
||
|
||
let empty = BoundaryConditions::new();
|
||
let start = std::time::Instant::now();
|
||
let mut steady_residual = f64::INFINITY;
|
||
let mut steps = 0;
|
||
for _ in 0..200_000 {
|
||
let before = field.clone();
|
||
solver.solve_time_step(&mut field, &empty, dt).await?;
|
||
steps += 1;
|
||
steady_residual = max_change(&field, &before) / dt;
|
||
if steady_residual < 1e-6 {
|
||
break;
|
||
}
|
||
}
|
||
assert!(
|
||
steady_residual < 1e-6,
|
||
"PISO ({kind:?}) did not reach a steady state: |du/dt| = {steady_residual:.3e}"
|
||
);
|
||
|
||
let mut squared = 0.0;
|
||
let mut volume = 0.0;
|
||
for j in 0..n {
|
||
for i in 1..n {
|
||
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 {
|
||
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;
|
||
}
|
||
}
|
||
Ok(SteadyMeasurement {
|
||
l2_velocity: (squared / volume).sqrt(),
|
||
max_div: max_divergence(&field, |_, _| true),
|
||
steps,
|
||
seconds: start.elapsed().as_secs_f64(),
|
||
})
|
||
}
|
||
|
||
/// Both inner solvers march the manufactured problem to the same discrete
|
||
/// steady state: L2 velocity errors equal to `1e-6` relative (the errors
|
||
/// themselves are `~2e-2`, so this is agreement to four orders below the
|
||
/// discretisation error), and every cell divergence-free under either.
|
||
#[tokio::test]
|
||
async fn piso_manufactured_steady_state_is_solver_independent() -> CfdResult<()> {
|
||
let n = 32;
|
||
let sor = piso_mms(n, PoissonSolverKind::Sor).await?;
|
||
let mg = piso_mms(n, PoissonSolverKind::Multigrid).await?;
|
||
println!(
|
||
" PISO MMS n = {n}: SOR L2 {:.6e} (div {:.2e}, {} steps, {:.1} s) MG L2 {:.6e} \
|
||
(div {:.2e}, {} steps, {:.1} s) relative difference {:.2e}",
|
||
sor.l2_velocity,
|
||
sor.max_div,
|
||
sor.steps,
|
||
sor.seconds,
|
||
mg.l2_velocity,
|
||
mg.max_div,
|
||
mg.steps,
|
||
mg.seconds,
|
||
rel(mg.l2_velocity, sor.l2_velocity)
|
||
);
|
||
assert!(
|
||
rel(mg.l2_velocity, sor.l2_velocity) < 1e-6,
|
||
"L2 velocity error differs between inner solvers: SOR {:.8e}, multigrid {:.8e}",
|
||
sor.l2_velocity,
|
||
mg.l2_velocity
|
||
);
|
||
for (name, m) in [("SOR", &sor), ("multigrid", &mg)] {
|
||
assert!(
|
||
m.max_div < 1e-5,
|
||
"{name}: max |div u| = {:.3e}, the projection is not removing the divergence",
|
||
m.max_div
|
||
);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 2. Taylor–Green.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const TG_NU: f64 = 0.02;
|
||
const TG_T_END: f64 = 0.25;
|
||
|
||
fn tg_amplitude(t: f64) -> f64 {
|
||
(-2.0 * TG_NU * PI * PI * t).exp()
|
||
}
|
||
|
||
fn tg_u(x: f64, y: f64, t: f64) -> f64 {
|
||
tg_amplitude(t) * (PI * x).sin() * (PI * y).cos()
|
||
}
|
||
|
||
fn tg_v(x: f64, y: f64, t: f64) -> f64 {
|
||
-tg_amplitude(t) * (PI * x).cos() * (PI * y).sin()
|
||
}
|
||
|
||
fn tg_p(x: f64, y: f64, t: f64) -> f64 {
|
||
let a = tg_amplitude(t);
|
||
-RHO * a * a / 4.0 * ((2.0 * PI * x).cos() + (2.0 * PI * y).cos())
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
struct TaylorGreen {
|
||
energy_ratio: f64,
|
||
/// Largest |div u| seen after any step.
|
||
max_div_any_step: f64,
|
||
steps: usize,
|
||
}
|
||
|
||
/// `tests/taylor_green.rs::measure`, with the inner solver selectable and
|
||
/// the divergence checked after every step rather than at the end only.
|
||
async fn taylor_green(n: usize, kind: PoissonSolverKind) -> CfdResult<TaylorGreen> {
|
||
let dx = 1.0 / n as f64;
|
||
let dt = 0.4 * dx * dx / (4.0 * TG_NU);
|
||
let steps = (TG_T_END / dt).ceil() as usize;
|
||
let dt = TG_T_END / steps as f64;
|
||
|
||
let config = CfdConfig::new()
|
||
.with_density(RHO)
|
||
.with_viscosity(RHO * TG_NU)
|
||
.with_reference_velocity(1.0)
|
||
.with_reference_length(1.0);
|
||
let mut solver = PisoSolver::new(
|
||
config,
|
||
PisoParameters {
|
||
corrector_steps: 60,
|
||
time_step: dt,
|
||
tolerance: 1e-9,
|
||
poisson_solver: kind,
|
||
},
|
||
)?;
|
||
|
||
let mut field = FlowField::new(n, n, dx, dx)?;
|
||
for j in 0..n {
|
||
let y = (j as f64 + 0.5) * dx;
|
||
for i in 0..=n {
|
||
field.u[(j, i)] = tg_u(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)] = tg_v((i as f64 + 0.5) * dx, y, 0.0);
|
||
}
|
||
}
|
||
for j in 0..n {
|
||
for i in 0..n {
|
||
field.p[(j, i)] = tg_p((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();
|
||
let mut max_div_any_step: f64 = 0.0;
|
||
for step in 0..steps {
|
||
let t = step as f64 * dt;
|
||
solver.set_wall_velocity(move |x, y| (tg_u(x, y, t), tg_v(x, y, t)));
|
||
let result = solver.solve_time_step(&mut field, &empty, dt).await?;
|
||
assert!(
|
||
result.solver_result.converged,
|
||
"{kind:?} step {step}: projection left mass residual {:.3e}",
|
||
result.solver_result.final_residual
|
||
);
|
||
max_div_any_step = max_div_any_step.max(max_divergence(&field, |_, _| true));
|
||
}
|
||
Ok(TaylorGreen {
|
||
energy_ratio: kinetic_energy(&field, n, dx) / initial_energy,
|
||
max_div_any_step,
|
||
steps,
|
||
})
|
||
}
|
||
|
||
/// With the multigrid projection every step of the decaying vortex is
|
||
/// divergence-free and the kinetic-energy decay is no further from the
|
||
/// closed form `e^(-4 nu pi^2 T)` than with SOR — and equal to it to `1e-6`
|
||
/// relative, since both solve the same projection to the same stop.
|
||
#[tokio::test]
|
||
async fn taylor_green_is_divergence_free_every_step_with_multigrid() -> CfdResult<()> {
|
||
let n = 32;
|
||
let exact_ratio = (-4.0 * TG_NU * PI * PI * TG_T_END).exp();
|
||
let sor = taylor_green(n, PoissonSolverKind::Sor).await?;
|
||
let mg = taylor_green(n, PoissonSolverKind::Multigrid).await?;
|
||
let deficit_sor = exact_ratio - sor.energy_ratio;
|
||
let deficit_mg = exact_ratio - mg.energy_ratio;
|
||
println!(
|
||
" Taylor-Green n = {n} ({} steps): E(T)/E(0) SOR {:.8} MG {:.8} (exact {exact_ratio:.8}); \
|
||
deficits SOR {:.3e} MG {:.3e}; max |div u| over all steps SOR {:.2e} MG {:.2e}",
|
||
mg.steps,
|
||
sor.energy_ratio,
|
||
mg.energy_ratio,
|
||
deficit_sor,
|
||
deficit_mg,
|
||
sor.max_div_any_step,
|
||
mg.max_div_any_step
|
||
);
|
||
assert!(
|
||
mg.max_div_any_step < 1e-5,
|
||
"multigrid: a step left max |div u| = {:.3e}",
|
||
mg.max_div_any_step
|
||
);
|
||
assert!(
|
||
deficit_mg.abs() <= deficit_sor.abs() * (1.0 + 1e-6) + 1e-12,
|
||
"multigrid energy-decay error {deficit_mg:.6e} is worse than SOR's {deficit_sor:.6e}"
|
||
);
|
||
assert!(
|
||
rel(mg.energy_ratio, sor.energy_ratio) < 1e-6,
|
||
"energy ratios differ between inner solvers: SOR {:.10}, multigrid {:.10}",
|
||
sor.energy_ratio,
|
||
mg.energy_ratio
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 3. Embedded circle.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const CX: f64 = 0.6;
|
||
const CY: f64 = 0.45;
|
||
const R: f64 = 0.2;
|
||
|
||
/// The manufactured field on the box boundary with the normal components
|
||
/// snapped to their exact analytic zero (see `tests/embedded_mms.rs`).
|
||
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 mms_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)
|
||
}
|
||
|
||
/// `tests/embedded_mms.rs::measure`, velocity error and divergence only,
|
||
/// with the inner solver selectable.
|
||
async fn embedded_mms(n: usize, kind: PoissonSolverKind) -> CfdResult<SteadyMeasurement> {
|
||
let dx = 1.0 / n as f64;
|
||
let dt = mms_time_step(n);
|
||
let mut solver = EmbeddedPisoSolver::new(
|
||
mms_config(),
|
||
EmbeddedParameters {
|
||
corrector_steps: 2,
|
||
tolerance: 1e-8,
|
||
poisson_solver: kind,
|
||
..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 = mms_initial_field(n)?;
|
||
solver.initialize(&mut field)?;
|
||
|
||
let start = std::time::Instant::now();
|
||
let mut steady_residual = f64::INFINITY;
|
||
let mut steps = 0;
|
||
for _ in 0..200_000 {
|
||
let before = field.clone();
|
||
solver.advance(&mut field, dt).await?;
|
||
steps += 1;
|
||
steady_residual = max_change(&field, &before) / dt;
|
||
if steady_residual < 1e-6 {
|
||
break;
|
||
}
|
||
}
|
||
assert!(
|
||
steady_residual < 1e-6,
|
||
"embedded PISO ({kind:?}) did not reach a steady state at n = {n}: |du/dt| = \
|
||
{steady_residual:.3e}"
|
||
);
|
||
let mask = solver.mask().expect("mask built");
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
Ok(SteadyMeasurement {
|
||
l2_velocity: (squared / volume).sqrt(),
|
||
max_div: max_divergence(&field, |j, i| mask.is_fluid_cell(j, i)),
|
||
steps,
|
||
seconds: start.elapsed().as_secs_f64(),
|
||
})
|
||
}
|
||
|
||
/// The embedded-circle manufactured steady state (`tests/embedded_mms.rs`
|
||
/// records L2 velocity 8.489e-3 at n = 32 with SOR) is the same under
|
||
/// multigrid to `1e-5` relative, and divergence-free on every fluid cell.
|
||
#[tokio::test]
|
||
async fn embedded_circle_steady_state_is_solver_independent() -> CfdResult<()> {
|
||
let n = 32;
|
||
let sor = embedded_mms(n, PoissonSolverKind::Sor).await?;
|
||
let mg = embedded_mms(n, PoissonSolverKind::Multigrid).await?;
|
||
println!(
|
||
" embedded MMS n = {n}: SOR L2 {:.6e} (div {:.2e}, {} steps, {:.1} s) MG L2 {:.6e} \
|
||
(div {:.2e}, {} steps, {:.1} s) relative difference {:.2e} (recorded SOR value 8.4892e-3)",
|
||
sor.l2_velocity,
|
||
sor.max_div,
|
||
sor.steps,
|
||
sor.seconds,
|
||
mg.l2_velocity,
|
||
mg.max_div,
|
||
mg.steps,
|
||
mg.seconds,
|
||
rel(mg.l2_velocity, sor.l2_velocity)
|
||
);
|
||
assert!(
|
||
rel(mg.l2_velocity, sor.l2_velocity) < 1e-5,
|
||
"L2 velocity error differs between inner solvers: SOR {:.8e}, multigrid {:.8e}",
|
||
sor.l2_velocity,
|
||
mg.l2_velocity
|
||
);
|
||
for (name, m) in [("SOR", &sor), ("multigrid", &mg)] {
|
||
assert!(
|
||
m.max_div < 1e-5,
|
||
"{name}: a fluid cell is not divergence-free, max |div u| = {:.3e}",
|
||
m.max_div
|
||
);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// `tests/embedded_mms.rs::without_a_body_the_embedded_solver_is_piso_to_the_bit`
|
||
/// with the multigrid projection on both solvers: the no-body embedded
|
||
/// solver assembles exactly the fixed-grid PISO's `PoissonProblem` (all
|
||
/// cells active, anchor `(1, 1)`, no outlet), so the fields must still agree
|
||
/// to the bit.
|
||
#[tokio::test]
|
||
async fn without_a_body_the_embedded_solver_is_piso_to_the_bit_with_multigrid() -> CfdResult<()> {
|
||
let n = 16;
|
||
let dt = mms_time_step(n);
|
||
let mut piso = PisoSolver::new(
|
||
mms_config(),
|
||
PisoParameters {
|
||
corrector_steps: 2,
|
||
time_step: dt,
|
||
tolerance: 1e-8,
|
||
poisson_solver: PoissonSolverKind::Multigrid,
|
||
},
|
||
)?;
|
||
piso.set_momentum_source(source);
|
||
piso.set_wall_velocity(boundary_exact);
|
||
let mut embedded = EmbeddedPisoSolver::new(
|
||
mms_config(),
|
||
EmbeddedParameters {
|
||
corrector_steps: 2,
|
||
tolerance: 1e-8,
|
||
poisson_solver: PoissonSolverKind::Multigrid,
|
||
..EmbeddedParameters::default()
|
||
},
|
||
)?;
|
||
embedded.set_momentum_source(|x, y, _| source(x, y));
|
||
embedded.set_boundary_velocity(|x, y, _| boundary_exact(x, y));
|
||
|
||
let mut a = mms_initial_field(n)?;
|
||
let mut b = mms_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 = max_change(&a, &b);
|
||
for (x, y) in a.p.iter().zip(b.p.iter()) {
|
||
max_diff = max_diff.max((x - y).abs());
|
||
}
|
||
// Both must also have actually moved the field — a pair of solvers that
|
||
// both do nothing agree to the bit too.
|
||
let moved = max_change(&a, &mms_initial_field(n)?);
|
||
assert!(
|
||
moved > 1e-3,
|
||
"the solvers did not advance the field ({moved:.3e})"
|
||
);
|
||
assert!(
|
||
max_diff == 0.0,
|
||
"embedded solver without a body differs from PISO by {max_diff:.3e} under multigrid"
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 4. Channel with a pressure outlet and an embedded circle.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Steady channel flow past a circle with a pressure outlet on the right —
|
||
/// the outlet's Dirichlet column makes the system non-singular (no anchor),
|
||
/// the circle masks cells: every arm of the embedded assembly is exercised.
|
||
/// Returns the steady `u`, `v`, `p` fields.
|
||
async fn channel_with_circle(kind: PoissonSolverKind) -> CfdResult<(FlowField, usize, f64)> {
|
||
let (length, height) = (2.0, 0.5);
|
||
let ny = 20;
|
||
let h = height / ny as f64;
|
||
let nx = (length / h).round() as usize;
|
||
let (rho, nu, u_mean) = (1.0, 0.01, 1.0);
|
||
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(rho * nu)
|
||
.with_reference_velocity(u_mean)
|
||
.with_reference_length(height);
|
||
let mut solver = EmbeddedPisoSolver::new(
|
||
config,
|
||
EmbeddedParameters {
|
||
corrector_steps: 2,
|
||
tolerance: 1e-8,
|
||
boundaries: AleBoundaries {
|
||
left: SideBoundary::Velocity,
|
||
right: SideBoundary::PressureOutlet,
|
||
bottom: SideBoundary::Velocity,
|
||
top: SideBoundary::Velocity,
|
||
},
|
||
poisson_solver: kind,
|
||
..EmbeddedParameters::default()
|
||
},
|
||
)?;
|
||
let inflow = move |y: f64| 1.5 * u_mean * y * (height - y) / (0.5 * height).powi(2);
|
||
solver.set_boundary_velocity(move |x, y, _| {
|
||
if x <= 0.0 {
|
||
(inflow(y), 0.0)
|
||
} else {
|
||
(0.0, 0.0)
|
||
}
|
||
});
|
||
solver.set_body(EmbeddedBody::circle(0.5, 0.27, 0.1));
|
||
let mut field = FlowField::new(nx, ny, h, h)?;
|
||
for j in 0..ny {
|
||
let u0 = inflow((j as f64 + 0.5) * h);
|
||
for i in 0..=nx {
|
||
field.u[(j, i)] = u0;
|
||
}
|
||
}
|
||
solver.initialize(&mut field)?;
|
||
|
||
let start = std::time::Instant::now();
|
||
let mut steps = 0;
|
||
let mut steady_residual = f64::INFINITY;
|
||
for _ in 0..200_000 {
|
||
let before = field.clone();
|
||
solver.advance(&mut field, dt).await?;
|
||
steps += 1;
|
||
steady_residual = max_change(&field, &before) / dt;
|
||
if steady_residual < 1e-6 * u_mean {
|
||
break;
|
||
}
|
||
}
|
||
assert!(
|
||
steady_residual < 1e-6 * u_mean,
|
||
"channel ({kind:?}) did not reach a steady state: |du/dt| = {steady_residual:.3e}"
|
||
);
|
||
let mask = solver.mask().expect("mask built");
|
||
let max_div = max_divergence(&field, |j, i| mask.is_fluid_cell(j, i));
|
||
assert!(
|
||
max_div < 1e-5 * u_mean / h,
|
||
"channel ({kind:?}): a fluid cell is not divergence-free, max |div u| = {max_div:.3e}"
|
||
);
|
||
Ok((field, steps, start.elapsed().as_secs_f64()))
|
||
}
|
||
|
||
/// The steady channel-with-circle fields agree between the two inner
|
||
/// solvers to `1e-6` relative (RMS of the difference over the RMS of the
|
||
/// field), for velocity and for pressure — the outlet column and the
|
||
/// un-anchored system are assembled as the SOR loop forms them.
|
||
#[tokio::test]
|
||
async fn outlet_channel_with_circle_steady_state_is_solver_independent() -> CfdResult<()> {
|
||
let (sor, sor_steps, sor_seconds) = channel_with_circle(PoissonSolverKind::Sor).await?;
|
||
let (mg, mg_steps, mg_seconds) = channel_with_circle(PoissonSolverKind::Multigrid).await?;
|
||
let sums = |a: &nalgebra::DMatrix<f64>, b: &nalgebra::DMatrix<f64>| {
|
||
let diff: f64 = a.iter().zip(b.iter()).map(|(x, y)| (x - y) * (x - y)).sum();
|
||
let scale: f64 = b.iter().map(|y| y * y).sum();
|
||
(diff, scale)
|
||
};
|
||
// Velocity: both components against the velocity scale (v alone is
|
||
// small in a channel); pressure against its own RMS (the outlet fixes
|
||
// the level, so the RMS is a scale and not an arbitrary offset).
|
||
let (du, su) = sums(&mg.u, &sor.u);
|
||
let (dv, sv) = sums(&mg.v, &sor.v);
|
||
let (dp, sp) = sums(&mg.p, &sor.p);
|
||
let velocity = ((du + dv) / (su + sv)).sqrt();
|
||
let pressure = (dp / sp).sqrt();
|
||
println!(
|
||
" outlet channel with circle: SOR {sor_steps} steps {sor_seconds:.1} s, MG {mg_steps} steps \
|
||
{mg_seconds:.1} s; relative RMS differences velocity {velocity:.2e} pressure {pressure:.2e}"
|
||
);
|
||
assert!(
|
||
velocity < 1e-6 && pressure < 1e-6,
|
||
"steady fields differ between inner solvers: velocity {velocity:.3e}, pressure {pressure:.3e}"
|
||
);
|
||
Ok(())
|
||
}
|