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
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]>
401 lines
13 KiB
Rust
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(())
|
|
}
|