//! 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(()) } }