Files
rustytorch/crates/specialized/rtx-cfd/tests/cfd_workflow_tests.rs
T
Omar SobhandClaude Fable 5 327da7ff47
Performance Benchmarks / Run Benchmarks (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
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
rtx-cfd: multigrid-PCG projection — 30x faster, same answers — and the CFD1 refinement study
Falsifier 4 of the Turek–Hron geometry decision fired (the SOR projection
cost 0.09 s/step at 250x41 and an hour per run at 5 mm); this answers it.

solvers::incompressible::poisson: PoissonProblem (cell-centred five-point
SPD operator as per-cell face coefficients + Dirichlet diagonal extra +
active mask) and solve_multigrid_pcg — conjugate gradient preconditioned
by one V-cycle of geometric multigrid: aggregation by 2 per direction (odd
sizes absorbed, coarse cell active iff any child is), the Galerkin coarse
operator for piecewise-constant prolongation / summation restriction,
symmetric Gauss–Seidel smoothing, coarse correction scaled by 2 (Braess's
under-correction of unsmoothed aggregation; scalar, so the preconditioner
stays symmetric and positive on range(A)), L1 TRUE-residual stop with a
stagnation guard. Singular systems are handled per connected component of
the active cells (mean projection and level per pure-Neumann component;
the anchor's component to p[anchor] = 0). PoissonSolverKind::{Sor,
Multigrid} on PisoParameters / EmbeddedParameters; Sor is the default and
its code is byte-for-byte untouched; an unconverged multigrid solve falls
back to the SOR sweeps for that projection.

Verified (poisson/tests.rs, tests/poisson_equivalence.rs):
- PCG iterations to cut the residual 1e-8 on the closed Neumann box at
  32^2..256^2: 4, 4, 4, 4; ragged masked domains 8/8/8;
- manufactured recoveries to ~1e-14; Galerkin identity A_c v = R A P v to
  7e-15 on every level (masked, outlet column, non-uniform conductances);
  V-cycle symmetric to 1e-14; NaN-poisoned inactive cells untouched;
- two Neumann components with opposite imbalances, and a Dirichlet
  component beside an imbalanced Neumann one (review scenarios): converge,
  each component right up to its own constant;
- speed vs plain SOR at the same stop: 22.7x (128^2), 41x (256^2);
- same answers as SOR: PISO MMS 4.6e-8 relative, Taylor–Green divergence
  1.4e-9 every step, embedded-circle MMS 7e-8, no-body bit-identity with MG
  on both solvers, channel+outlet+circle 1.4e-10; CFD1 loads identical to
  four digits at 0.003 s/step vs 0.094 (30x).

CFD1 refinement study (tests/turek_hron_cfd.rs, three grids, 257 s):
h = 10 / 6.6 / 5 mm -> control-volume drag 15.6156 / 15.2829 / 15.0988 vs
14.2929 (+9.25 / +6.93 / +5.64%), apparent order 0.71, Richardson
extrapolate 14.04; surface route and lift not monotone (flag 2/3/4 cells
thick) — the test asserts the measured band at the finest grid.

Built with a 4-agent workflow (core, integration, refinement study,
adversarial review); the review found no defects and four risks, three
fixed here (per-component projection, one symmetric smoother-sweep
parameter, acting on `converged` with an SOR fallback) and one recorded
(isotropic aggregation loses grid-independence on anisotropic cells).

rtx-cfd 301 -> 318 green.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-20 10:20:25 -07:00

401 lines
13 KiB
Rust

//! Production CFD workflow integration tests
//!
//! Tests the complete CFD workflow with actual SIMPLE and PISO solvers,
//! validating convergence, mass conservation, and turbulence integration.
use rtx_cfd::{
CfdConfig, CfdResult,
solvers::incompressible::{
BoundaryConditions, FlowField, IncompressibleSolver, PisoParameters, PisoSolver,
SimpleParameters, SimpleSolver,
},
};
use std::time::Duration;
/// Test complete SIMPLE solver workflow with turbulence
#[tokio::test]
async fn test_simple_solver_workflow() -> CfdResult<()> {
// Create CFD configuration
// Reynolds number 100, not 10^6.
//
// The nominal water properties (rho = 1000, mu = 1e-3) give
// Re = rho U L / mu = 10^6 on a unit domain, which has no steady laminar
// solution and could not be resolved by ten cells if it did. The solver
// diverges on it, correctly. It only appeared to work while the diffusion
// conductances were a factor 1/h too large, which quietly stabilised it.
let config = CfdConfig::new()
.with_density(1000.0)
.with_viscosity(10.0)
.with_reference_velocity(1.0)
.with_reference_length(1.0);
// Create solver parameters with turbulence enabled
let params = SimpleParameters {
pressure_relaxation: 0.3,
velocity_relaxation: 0.7,
max_iterations: 50,
tolerance: 1e-6,
time_step: 0.001,
max_courant: 1.0,
use_turbulence: true,
steady: true,
..SimpleParameters::default()
};
// Create solver
let mut solver = SimpleSolver::new(config.clone(), params)?;
// Create flow field (10x10 grid)
let mut flow_field = FlowField::new(10, 10, 0.1, 0.1)?;
// Initialize with simple flow
for j in 0..10 {
for i in 0..10 {
if i < 10 && j < 10 {
flow_field.u[(j, i)] = 1.0; // Initial u-velocity
flow_field.v[(j, i)] = 0.0; // Initial v-velocity
flow_field.p[(j, i)] = 0.0; // Initial pressure
}
}
}
// Set up boundary conditions (lid-driven cavity)
let boundary_conditions = BoundaryConditions::lid_driven_cavity(10, 10, 1.0);
// Run solver for a few iterations
let mut total_iterations = 0;
for _outer in 0..5 {
let result = solver.solve(&mut flow_field, &boundary_conditions).await?;
total_iterations += result.solver_result.iterations;
// Check convergence properties
assert!(result.solver_result.final_residual >= 0.0);
assert!(result.solver_result.solve_time > Duration::from_nanos(0));
// Basic sanity checks on flow field
assert!(!flow_field.u.iter().any(|&x| x.is_nan()));
assert!(!flow_field.v.iter().any(|&x| x.is_nan()));
assert!(!flow_field.p.iter().any(|&x| x.is_nan()));
// Check that velocities are within reasonable bounds
let max_u = flow_field.u.iter().fold(0.0f64, |a, &b| a.max(b.abs()));
let max_v = flow_field.v.iter().fold(0.0f64, |a, &b| a.max(b.abs()));
assert!(max_u < 10.0, "u-velocity too large: {}", max_u);
assert!(max_v < 10.0, "v-velocity too large: {}", max_v);
if result.solver_result.converged {
break;
}
}
println!(
"SIMPLE solver completed {} total iterations",
total_iterations
);
Ok(())
}
/// Test complete PISO solver workflow
#[tokio::test]
async fn test_piso_solver_workflow() -> CfdResult<()> {
// Create CFD configuration
let config = CfdConfig::new()
.with_density(1000.0)
.with_viscosity(1e-3)
.with_reference_velocity(1.0)
.with_reference_length(1.0);
// Create PISO parameters
let params = PisoParameters {
corrector_steps: 2,
time_step: 0.001,
tolerance: 1e-6,
..PisoParameters::default()
};
// Create solver
let mut solver = PisoSolver::new(config.clone(), params.clone())?;
// Create flow field (10x10 grid)
let mut flow_field = FlowField::new(10, 10, 0.1, 0.1)?;
// Initialize with simple flow
for j in 0..10 {
for i in 0..10 {
if i < 10 && j < 10 {
flow_field.u[(j, i)] = 1.0;
flow_field.v[(j, i)] = 0.0;
flow_field.p[(j, i)] = 0.0;
}
}
}
// Set up boundary conditions
let boundary_conditions = BoundaryConditions::lid_driven_cavity(10, 10, 1.0);
// Run time stepping
let dt = 0.001;
for _time_step in 0..10 {
let result = solver
.solve_time_step(&mut flow_field, &boundary_conditions, dt)
.await?;
// Check that pressure correction steps were performed
assert!(result.corrector_steps_performed >= 1);
assert!(result.corrector_steps_performed <= params.corrector_steps);
// Basic sanity checks
assert!(result.solver_result.final_residual >= 0.0);
assert!(!flow_field.u.iter().any(|&x| x.is_nan()));
assert!(!flow_field.v.iter().any(|&x| x.is_nan()));
assert!(!flow_field.p.iter().any(|&x| x.is_nan()));
}
println!("PISO solver completed 10 time steps successfully");
Ok(())
}
/// Test mass conservation in CFD solvers
#[tokio::test]
async fn test_mass_conservation() -> CfdResult<()> {
let config = CfdConfig::new().with_density(1000.0).with_viscosity(1e-3);
let params = SimpleParameters::default();
let mut solver = SimpleSolver::new(config, params)?;
let mut flow_field = FlowField::new(5, 5, 0.2, 0.2)?;
let boundary_conditions = BoundaryConditions::lid_driven_cavity(5, 5, 1.0);
// Run a few iterations
for _i in 0..3 {
let _result = solver.solve(&mut flow_field, &boundary_conditions).await?;
// Calculate mass conservation (∇·u should be small for incompressible flow)
let mut max_divergence: f64 = 0.0;
let (nx, ny, dx, dy) = flow_field.grid_info();
for j in 1..ny - 1 {
for i in 1..nx - 1 {
let du_dx = (flow_field.u[(j, i + 1)] - flow_field.u[(j, i - 1)]) / (2.0 * dx);
let dv_dy = (flow_field.v[(j + 1, i)] - flow_field.v[(j - 1, i)]) / (2.0 * dy);
let divergence = (du_dx + dv_dy).abs();
max_divergence = max_divergence.max(divergence);
}
}
// For a converged incompressible solution, divergence should be small
println!("Max divergence: {}", max_divergence);
// Note: For early iterations, divergence may be larger but should decrease
}
Ok(())
}
/// Test turbulence model integration
#[tokio::test]
async fn test_turbulence_integration() -> CfdResult<()> {
let config = CfdConfig::new().with_density(1000.0).with_viscosity(1e-3);
// Create parameters with turbulence enabled
let params_turbulent = SimpleParameters {
use_turbulence: true,
..SimpleParameters::default()
};
// Create parameters without turbulence
let params_laminar = SimpleParameters {
use_turbulence: false,
..SimpleParameters::default()
};
let mut solver_turbulent = SimpleSolver::new(config.clone(), params_turbulent)?;
let mut solver_laminar = SimpleSolver::new(config, params_laminar)?;
let mut flow_field_turbulent = FlowField::new(8, 8, 0.125, 0.125)?;
let mut flow_field_laminar = FlowField::new(8, 8, 0.125, 0.125)?;
// Initialize both with same initial conditions
for j in 0..8 {
for i in 0..8 {
let u_init = if i == 0 { 1.0 } else { 0.0 };
flow_field_turbulent.u[(j, i)] = u_init;
flow_field_turbulent.v[(j, i)] = 0.0;
flow_field_turbulent.p[(j, i)] = 0.0;
flow_field_laminar.u[(j, i)] = u_init;
flow_field_laminar.v[(j, i)] = 0.0;
flow_field_laminar.p[(j, i)] = 0.0;
}
}
let boundary_conditions = BoundaryConditions::lid_driven_cavity(8, 8, 1.0);
// Run both solvers
let result_turbulent = solver_turbulent
.solve(&mut flow_field_turbulent, &boundary_conditions)
.await?;
let result_laminar = solver_laminar
.solve(&mut flow_field_laminar, &boundary_conditions)
.await?;
// Both should converge but may have different residuals
assert!(result_turbulent.solver_result.final_residual >= 0.0);
assert!(result_laminar.solver_result.final_residual >= 0.0);
// Flow fields should be different due to turbulence effects
let mut velocity_difference: f64 = 0.0;
for j in 0..8 {
for i in 0..8 {
let diff_u = (flow_field_turbulent.u[(j, i)] - flow_field_laminar.u[(j, i)]).abs();
let diff_v = (flow_field_turbulent.v[(j, i)] - flow_field_laminar.v[(j, i)]).abs();
velocity_difference = velocity_difference.max(diff_u).max(diff_v);
}
}
println!(
"Turbulent vs laminar max velocity difference: {}",
velocity_difference
);
Ok(())
}
/// Test solver robustness with extreme conditions
#[tokio::test]
async fn test_solver_robustness() -> CfdResult<()> {
let config = CfdConfig::new()
.with_density(1.0) // Low density
.with_viscosity(1e-6); // Low viscosity (high Reynolds number)
let params = SimpleParameters {
max_iterations: 20, // Limit iterations to avoid long test times
tolerance: 1e-4, // Relaxed tolerance
..SimpleParameters::default()
};
let mut solver = SimpleSolver::new(config, params)?;
let mut flow_field = FlowField::new(6, 6, 0.1, 0.1)?;
// Initialize with high velocities
for j in 0..6 {
for i in 0..6 {
flow_field.u[(j, i)] = 5.0; // High initial velocity
flow_field.v[(j, i)] = 0.0;
flow_field.p[(j, i)] = 0.0;
}
}
let boundary_conditions = BoundaryConditions::lid_driven_cavity(6, 6, 5.0);
// Solver should handle this without crashing
let result = solver.solve(&mut flow_field, &boundary_conditions).await?;
// Check that solution remains bounded
assert!(!flow_field.u.iter().any(|&x| x.is_nan() || x.is_infinite()));
assert!(!flow_field.v.iter().any(|&x| x.is_nan() || x.is_infinite()));
assert!(!flow_field.p.iter().any(|&x| x.is_nan() || x.is_infinite()));
println!(
"Robustness test completed with residual: {}",
result.solver_result.final_residual
);
Ok(())
}
/// Test SIMPLE vs PISO convergence comparison
#[tokio::test]
async fn test_simple_vs_piso_comparison() -> CfdResult<()> {
let config = CfdConfig::new().with_density(1000.0).with_viscosity(1e-3);
// SIMPLE solver
let simple_params = SimpleParameters {
max_iterations: 30,
tolerance: 1e-5,
..SimpleParameters::default()
};
let mut simple_solver = SimpleSolver::new(config.clone(), simple_params)?;
// PISO solver
let piso_params = PisoParameters {
corrector_steps: 2,
time_step: 0.001,
tolerance: 1e-5,
..PisoParameters::default()
};
let mut piso_solver = PisoSolver::new(config, piso_params)?;
// Create identical initial conditions
let mut flow_field_simple = FlowField::new(6, 6, 1.0 / 6.0, 1.0 / 6.0)?;
let mut flow_field_piso = flow_field_simple.clone();
let boundary_conditions = BoundaryConditions::lid_driven_cavity(6, 6, 1.0);
// Solve with SIMPLE
let start = std::time::Instant::now();
let simple_result = simple_solver
.solve(&mut flow_field_simple, &boundary_conditions)
.await?;
let simple_time = start.elapsed();
// Solve with PISO (single time step)
let start = std::time::Instant::now();
let piso_result = piso_solver
.solve_time_step(&mut flow_field_piso, &boundary_conditions, 0.001)
.await?;
let piso_time = start.elapsed();
println!(
"SIMPLE: {} iterations, residual: {:.2e}, time: {:?}",
simple_result.solver_result.iterations,
simple_result.solver_result.final_residual,
simple_time
);
println!(
"PISO: {} corrector steps, residual: {:.2e}, time: {:?}",
piso_result.corrector_steps_performed, piso_result.solver_result.final_residual, piso_time
);
// Both should produce valid solutions
assert!(simple_result.solver_result.final_residual >= 0.0);
assert!(piso_result.solver_result.final_residual >= 0.0);
Ok(())
}
/// Test Reynolds number effects on flow
#[tokio::test]
async fn test_reynolds_number_effects() -> CfdResult<()> {
// Low Reynolds number (laminar)
let config_low_re = CfdConfig::new()
.with_density(1.0)
.with_viscosity(1.0) // High viscosity
.with_reference_velocity(1.0)
.with_reference_length(1.0);
// High Reynolds number (turbulent)
let config_high_re = CfdConfig::new()
.with_density(1.0)
.with_viscosity(1e-5) // Low viscosity
.with_reference_velocity(1.0)
.with_reference_length(1.0);
assert!(config_low_re.is_laminar());
assert!(config_high_re.is_turbulent());
let re_low = config_low_re.reynolds_number();
let re_high = config_high_re.reynolds_number();
println!("Low Re: {}, High Re: {}", re_low, re_high);
assert!(re_low < 100.0);
assert!(re_high > 10000.0);
// Test that both configurations are valid
assert!(config_low_re.validate().is_ok());
assert!(config_high_re.validate().is_ok());
Ok(())
}