Files
rustytorch/crates/specialized/rtx-cfd/tests/simple_tests.rs
T
Omar SobhandClaude Opus 5 b5814a304f
Performance Benchmarks / Run Benchmarks (push) Canceled after 0s
CI / Format Check (push) Canceled after 0s
CI / Clippy Check (push) Canceled after 0s
CI / Build (macos-latest) (push) Canceled after 0s
CI / Build (ubuntu-latest) (push) Canceled after 0s
CI / Test (macos-latest) (push) Canceled after 0s
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Build CPU-Only (Explicit) (push) Canceled after 0s
CI / Python Bindings (maturin) (macos-latest) (push) Canceled after 0s
CI / Python Bindings (maturin) (ubuntu-latest) (push) Canceled after 0s
CI / WASM Build + Size Check (push) Canceled after 0s
CI / Distributed Training Tests (push) Canceled after 0s
CI / CI Success (push) Canceled after 0s
Documentation / Build API Documentation (push) Canceled after 0s
Documentation / Build User Guide (push) Canceled after 0s
rtx-cfd: manufactured solution finds the diffusion conductances were 1/h too
large

Applies MMS to the SIMPLE solver. It found a major discretisation error on
the first run, which is the point of the method.

The diffusion conductances read `mu / dx` and `mu / dy`. Finite volume
requires `Gamma * A / delta` — the face area over the distance between the
nodes it separates — so they should be `mu * dy / dx` and `mu * dx / dy`.
The face area was missing entirely, making viscosity too large by a factor
of `1/h`: sixty-five times on a 65x65 mesh. Every other term in the
equation was already a force (`dp * dy` for pressure, `rho u dy` for the
convective flux), so the mismatch was confined to diffusion.

The consequence was that the solver ran at an effective Reynolds number
far below the one requested. Before the fix the manufactured-solution
error did not reduce under refinement at all — observed order about -0.05,
because the spurious viscosity grows with the mesh. After it, the error
falls monotonically.

This also explains an apparent regression that is really a correction.
The cavity vortex position moved from y = 0.484 to y = 0.391 against
Ghia's 0.4531, which reads as worse agreement. It is not: a strongly
over-diffusive cavity approaches Stokes flow, whose vortex sits near
mid-height, so the old number was closer to the reference than the scheme
deserved. Correcting the viscosity exposed the discretisation's own error.
The test now states that disagreement plainly rather than asserting a band
around the reference.

What MMS reports now, and it is not yet good enough:

    n = 16   L2 velocity error = 2.586104e-1   order    -
    n = 32   L2 velocity error = 1.797373e-1   order 0.52
    n = 64   L2 velocity error = 1.277188e-1   order 0.49

First-order upwind should give 1. It gives about 0.5, and the u component
is markedly further from exact than v on the same mesh. Both say there is
at least one more defect in the discretisation or its boundary treatment,
and the asymmetry between the two momentum equations is the clue. The test
asserts only monotone error reduction — what is established — and records
the shortfall, because asserting a rate the solver does not achieve would
either redden the suite or invite someone to weaken it later.

This changes the plan: raising the observed order to 1 is now a
precondition for the second-order convection work rather than a
consequence of it. There is no value in adding a higher-order scheme to a
discretisation that has not demonstrated first order.

Supporting changes:

  - `SimpleSolver::set_momentum_source` applies a volumetric body force,
    which is what lets a manufactured solution be imposed at all.
  - Divergence is now detected by growth, not only by NaN. The 8x8 case at
    Reynolds 10^6 reached 1e149 before anything caught it, because
    `is_finite` stays true right up until it does not.
  - `test_simple_solver_workflow` specified water properties on a unit
    domain, which is Reynolds 10^6 on ten cells: no steady laminar
    solution exists and the solver diverges on it, correctly. It passed
    only while the excess diffusion stabilised it. Now set to Reynolds 100.

561 tests across the three crates, 0 failing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 12:25:52 -07:00

376 lines
14 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.
//! Tests for SIMPLE algorithm implementation
//!
//! The SIMPLE (Semi-Implicit Method for Pressure Linked Equations) algorithm
//! is a widely used method for solving incompressible Navier-Stokes equations.
//! These tests verify the real mathematical implementation.
use approx::assert_relative_eq;
use rtx_cfd::{CfdConfig, CfdResult};
#[cfg(test)]
mod simple_tests {
use super::*;
use rtx_cfd::solvers::incompressible::{
BoundaryConditions, FlowField, IncompressibleSolver, SimpleParameters, SimpleSolver,
};
#[tokio::test]
async fn test_simple_solver_creation() -> CfdResult<()> {
let config = CfdConfig::new().with_density(1.0).with_viscosity(1e-3);
let params = SimpleParameters::default()
.with_pressure_relaxation(0.3)
.with_velocity_relaxation(0.7)
.with_max_iterations(1000)
.with_tolerance(1e-6);
let solver = SimpleSolver::new(config, params)?;
assert_eq!(solver.parameters().pressure_relaxation, 0.3);
assert_eq!(solver.parameters().velocity_relaxation, 0.7);
Ok(())
}
#[tokio::test]
async fn test_lid_driven_cavity_re100() -> CfdResult<()> {
// Classic benchmark: lid-driven cavity at Re=100
let config = CfdConfig::new()
.with_density(1.0)
.with_viscosity(1e-2) // Re = UL/ν = 1*1/0.01 = 100
.with_reference_velocity(1.0)
.with_reference_length(1.0);
// Steady solve: no pseudo-time step, under-relaxation folded into the
// momentum coefficients. The converged field is independent of both
// `time_step` (unused here) and the relaxation factor.
let params = SimpleParameters::default()
.with_pressure_relaxation(0.3)
.with_velocity_relaxation(0.7)
.with_max_iterations(8000)
// 2e-4, not 1e-6. The two lid corners, where the moving lid meets
// a stationary wall, carry a velocity discontinuity: the mass
// imbalance there does not reduce with iteration, so the
// normalised residual floors near 1.6e-4 on this grid. It falls
// roughly linearly with mesh size — 1.6e-3 at 17^2, 5.2e-4 at
// 33^2, 1.6e-4 at 65^2, 7.8e-5 at 97^2 — which is the signature of
// a singularity rather than of an unconverged solve. It is the
// same one Botella & Peyret (1998) subtract analytically to reach
// spectral accuracy. The *momentum* residual reaches machine zero.
// The physical assertions below establish correctness; this is
// only the stopping rule.
.with_tolerance(2e-4);
let mut solver = SimpleSolver::new(config, params)?;
// 65x65. First-order upwind carries a numerical viscosity of about
// |u| dx / 2, so the effective Reynolds number is well below the
// nominal 100 on a coarse grid; refining moves the solution steadily
// toward the reference (see the assertions on `u_min` below).
let nx = 65;
let ny = 65;
let dx = 1.0 / (nx as f64 - 1.0);
let dy = 1.0 / (ny as f64 - 1.0);
// Initialize flow field
let mut flow_field = FlowField::new(nx, ny, dx, dy)?;
// Setup boundary conditions for lid-driven cavity
let mut bcs = BoundaryConditions::new();
// Top wall (lid): u=1, v=0
for i in 0..nx {
bcs.set_velocity_bc(i, ny - 1, 1.0, 0.0)?;
}
// Other walls: u=0, v=0 (no-slip)
for i in 0..nx {
bcs.set_velocity_bc(i, 0, 0.0, 0.0)?; // Bottom
}
for j in 0..ny {
bcs.set_velocity_bc(0, j, 0.0, 0.0)?; // Left
bcs.set_velocity_bc(nx - 1, j, 0.0, 0.0)?; // Right
}
// Apply boundary conditions
flow_field.apply_boundary_conditions(&bcs)?;
// Run SIMPLE iterations
let result = solver.solve(&mut flow_field, &bcs).await?;
// Verify convergence
assert!(
result.solver_result.converged,
"SIMPLE solver should converge for lid-driven cavity"
);
assert!(
result.solver_result.final_residual < 2e-4,
"Final residual should be below tolerance"
);
assert!(
result.solver_result.iterations < 8000,
"Should converge in reasonable iterations"
);
// The check that distinguishes a cavity from a sheared box.
//
// A lid-driven cavity recirculates: on the vertical centreline the
// horizontal velocity is *negative* through the lower half, as the
// return flow comes back beneath the primary vortex. Ghia, Ghia & Shin
// (1982) put the minimum at u = -0.2109, y = 0.4531 for Re = 100.
//
// Before the pressure-velocity coupling was repaired this solver
// produced a monotonic profile rising from 0 at the floor to 1 at the
// lid — Couette flow, with no recirculation anywhere — because the
// pressure correction was some four orders of magnitude too weak to
// enforce continuity, and the return flow in a cavity is driven
// entirely by the pressure gradient. Every assertion above passes for
// that wrong field; this one does not.
let mut u_min = 0.0_f64;
let mut y_at_min = 0.0_f64;
for j in 0..ny {
let (u, _) = flow_field.get_velocity_at(nx / 2, j)?;
if u < u_min {
u_min = u;
y_at_min = j as f64 / (ny - 1) as f64;
}
}
assert!(
u_min < -0.05,
"no recirculation on the centreline (minimum u = {u_min:.4}); \
the solution is a shear layer, not a cavity"
);
// Ghia puts the centreline minimum at y = 0.4531. This solver reads
// about 0.39 on a 65^2 grid, and the band below is set accordingly
// rather than around the reference — an honest record of a real
// disagreement, not a claim of agreement.
//
// It used to read 0.484, which looked better. That was partly luck:
// the diffusion conductances omitted the face area and were a factor
// 1/h too large, so the solver was running at a Reynolds number far
// below 100. A strongly over-diffusive cavity approaches Stokes flow,
// whose vortex sits near mid-height, which happened to land closer to
// Ghia than the scheme deserved. Correcting the viscosity exposed the
// discretisation's own error.
//
// `tests/mms_navier_stokes.rs` measures that error directly and finds
// the observed order of accuracy is about 0.5 where first-order upwind
// should give 1. Quantitative agreement with Ghia is not expected
// until that is resolved.
assert!(
(0.34..0.55).contains(&y_at_min),
"the primary vortex is at y = {y_at_min:.4}, outside even the wide \
band this solver currently warrants (Ghia: 0.4531)"
);
// The *strength* is limited by first-order upwind's numerical
// viscosity, which at this resolution is a substantial fraction of the
// physical viscosity, so Ghia's -0.2109 is not reachable here. The
// grid study in docs/solver_status.md shows the value climbing
// monotonically toward it: -0.123 at 17^2, -0.154 at 33^2, -0.174 at
// 65^2, -0.182 at 97^2, Richardson-extrapolating to about -0.199.
// Bounding it both ways catches a solver that has stopped
// recirculating *and* one that has become unphysically energetic.
assert!(
(-0.19..-0.15).contains(&u_min),
"centreline minimum {u_min:.4} is outside the band expected for \
first-order upwind at 65^2 approaching Ghia's -0.2109"
);
// Pressure must be O(rho U^2), not O(0). A near-zero pressure field is
// the signature of a correction that is not coupling to the momentum
// equation at all.
let mut p_max = 0.0_f64;
for j in 0..ny {
for i in 0..nx {
p_max = p_max.max(flow_field.p[(j, i)].abs());
}
}
assert!(
p_max > 0.1,
"peak pressure {p_max:.3e} is far below the rho U^2 scale of 1.0; \
the pressure field is not being driven"
);
// Verify physical correctness
// 1. Check mass conservation (div(u) ≈ 0)
// Note: compute_max_divergence would need to be implemented
// For now, we'll just check that velocity field is reasonable
// 2. Check that maximum velocity is at the lid
// Find max u-velocity manually
let mut u_max = 0.0_f64;
let mut u_max_loc = (0, 0);
for i in 0..nx {
for j in 0..ny {
if let Ok((u, _)) = flow_field.get_velocity_at(i, j) {
if u > u_max {
u_max = u;
u_max_loc = (i, j);
}
}
}
}
assert!(
u_max_loc.1 >= ny - 5,
"Maximum u-velocity should be near the lid"
);
assert!(u_max <= 1.1, "Max velocity should be reasonable");
// 3. Check center vortex characteristics for Re=100
let (u_center, v_center) = flow_field.get_velocity_at(nx / 2, ny / 2)?;
assert!(
u_center.abs() < 0.5,
"Center u-velocity should be reasonable"
);
assert!(
v_center.abs() < 0.5,
"Center v-velocity should be reasonable"
);
Ok(())
}
#[tokio::test]
async fn test_simple_pressure_correction() -> CfdResult<()> {
// Test that pressure correction step actually corrects mass balance
let config = CfdConfig::new().with_density(1.0).with_viscosity(1e-3);
let params = SimpleParameters::new()
.with_pressure_relaxation(0.3)
.with_velocity_relaxation(0.7);
let mut solver = SimpleSolver::new(config, params)?;
let nx = 16;
let ny = 16;
let dx = 0.1;
let dy = 0.1;
let mut flow_field = FlowField::new(nx, ny, dx, dy)?;
// Create artificial mass imbalance
for i in 1..nx - 1 {
for j in 1..ny - 1 {
flow_field.set_velocity(i, j, 0.1 * (i as f64), 0.1 * (j as f64))?;
}
}
// Note: Direct pressure correction step testing would require internal solver API
// For now, we test that the solver can perform a time step
let result = solver
.solve_time_step(&mut flow_field, &BoundaryConditions::new(), 0.01)
.await?;
assert!(
result.solver_result.final_residual >= 0.0,
"Residual should be non-negative"
);
Ok(())
}
#[tokio::test]
async fn test_simple_momentum_prediction() -> CfdResult<()> {
// Test momentum equation solution (prediction step)
let config = CfdConfig::new().with_density(1.0).with_viscosity(1e-2);
let params = SimpleParameters::default();
let mut solver = SimpleSolver::new(config, params)?;
let nx = 16;
let ny = 16;
let dx = 0.1;
let dy = 0.1;
let mut flow_field = FlowField::new(nx, ny, dx, dy)?;
// Setup simple shear flow
for i in 0..nx {
for j in 0..ny {
let y = j as f64 * dy;
flow_field.set_velocity(i, j, y, 0.0)?; // Linear shear
}
}
let dt = 0.001;
// Store initial kinetic energy - calculate manually
let mut initial_ke = 0.0;
for i in 0..nx {
for j in 0..ny {
if let Ok((u, v)) = flow_field.get_velocity_at(i, j) {
initial_ke += 0.5 * (u * u + v * v);
}
}
}
// Apply a time step
let _result = solver
.solve_time_step(&mut flow_field, &BoundaryConditions::new(), dt)
.await?;
// Verify that momentum equations are being solved
// (viscous diffusion should change the velocity field)
let mut final_ke = 0.0;
for i in 0..nx {
for j in 0..ny {
if let Ok((u, v)) = flow_field.get_velocity_at(i, j) {
final_ke += 0.5 * (u * u + v * v);
}
}
}
// With viscosity, kinetic energy should not increase excessively
assert!(
final_ke <= initial_ke * 1.5,
"Kinetic energy should not increase excessively"
);
Ok(())
}
#[tokio::test]
async fn test_simple_under_relaxation() -> CfdResult<()> {
// Test that under-relaxation factors work correctly
let config = CfdConfig::default();
// Test with strong under-relaxation
let params_conservative = SimpleParameters::default()
.with_pressure_relaxation(0.1)
.with_velocity_relaxation(0.1);
// Test with weak under-relaxation
let params_aggressive = SimpleParameters::default()
.with_pressure_relaxation(0.8)
.with_velocity_relaxation(0.8);
let solver_conservative = SimpleSolver::new(config.clone(), params_conservative)?;
let solver_aggressive = SimpleSolver::new(config, params_aggressive)?;
// Both should have different relaxation parameters
assert_ne!(
solver_conservative.parameters().pressure_relaxation,
solver_aggressive.parameters().pressure_relaxation
);
Ok(())
}
#[tokio::test]
async fn test_simple_parameters_validation() -> CfdResult<()> {
// Test parameter validation - with_pressure_relaxation clamps to non-negative
let params = SimpleParameters::default().with_pressure_relaxation(-0.1); // Invalid: negative relaxation
// Should clamp to 0.0 (see with_pressure_relaxation implementation)
assert!(params.pressure_relaxation >= 0.0);
let params2 = SimpleParameters::default().with_pressure_relaxation(1.5); // Potentially unstable but valid
assert_eq!(params2.pressure_relaxation, 1.5);
Ok(())
}
}