Files
rustytorch/crates/specialized/rtx-cfd/tests/poisson_equivalence.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

691 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 TaylorGreen 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
//! TurekHron 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. TaylorGreen.
// ---------------------------------------------------------------------------
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(())
}