//! 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, BoundaryLocation, BoundaryType, 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. // // On a staggered MAC grid the only velocity components that live *on* a // boundary are the normal ones: u faces `i = 0` and `i = nx` on the // sides, v faces `j = 0` and `j = ny` on the floor and lid. Every u row // sits at `y = (j + 0.5) dy` and every v column at `x = (i + 0.5) dx` — // strictly interior, every one of them. The tangential no-slip and lid // conditions are therefore not stored values at all; they enter the // discretisation through the near-wall control volume's half-cell // diffusion term, which is what `set_wall_velocity` supplies. // // `FreeSlipWall` is the condition that prescribes the normal component // and leaves the tangential one free, so it is the right one on all four // sides here — the no-slip part arrives via the wall velocity below. // // This previously prescribed whole u rows and v columns with // `set_velocity_bc`, pinning lines that lie half a cell inside the // domain. That was harmless only while the momentum sweeps froze those // same lines. Now that every cell has a continuity equation, every cell // needs at least one face the pressure correction may move, and pinning // an interior face over-determines the cells beside it: the solve // diverged, residual climbing steadily from 0.10 at iteration 20 to 3.8 // at iteration 8000, with max |div u| of 64. let mut bcs = BoundaryConditions::new(); for location in [ BoundaryLocation::Left, BoundaryLocation::Right, BoundaryLocation::Bottom, BoundaryLocation::Top, ] { bcs.add_boundary_condition(location, BoundaryType::FreeSlipWall); } // The lid moves; the other three walls do not. Sampled at the wall face // position, so the test compares `y` against the top of the domain, // which is `ny * dy` — this grid spans slightly more than the unit // square, since `FlowField` counts cells and `dx` is 1/(nx - 1). let domain_top = ny as f64 * dy; solver.set_wall_velocity(move |_x, y| { if y > 0.5 * domain_top { (1.0, 0.0) } else { (0.0, 0.0) } }); // 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 // 0.5000 on a 65^2 grid, and the band below is set around what it // actually delivers rather than around the reference — an honest record // of a real disagreement, not a claim of agreement. // // The history is worth keeping straight, because two of the three // numbers this has read were right for the wrong reasons. It used to // read 0.484, which looked good: the diffusion conductances omitted the // face area and were a factor 1/h too large, so the solver ran at a // Reynolds number far below 100, and a strongly over-diffusive cavity // approaches Stokes flow, whose vortex sits near mid-height. Correcting // the viscosity exposed the discretisation's own error and it fell to // 0.3906. Making the near-wall lines unknowns — rather than freezing // them and imposing the wall half a cell inside the domain — moved it to // 0.5000. The error against Ghia went 0.062 -> 0.047, so this is a real // improvement, but it overshoots now where it undershot before, and a // first-order scheme at 65^2 has no business claiming better. // // `tests/mms_navier_stokes.rs` measures the underlying error directly: // the observed order of accuracy is now 0.85 to 0.91, up from 0.48, // where first-order upwind should give 1. assert!( (0.44..0.56).contains(&y_at_min), "the primary vortex is at y = {y_at_min:.4}, outside the 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 showed the value climbing monotonically // toward it: -0.123 at 17^2, -0.154 at 33^2, -0.1792 at 65^2, -0.182 at // 97^2, Richardson-extrapolating to about -0.199. // // Solving the near-wall rows instead of freezing them moved the 65^2 // figure from -0.1792 to -0.1932 — 15% of the remaining gap to Ghia, // closed on the same mesh, and in 733 outer iterations instead of 971. // The band is shifted to match, not widened: it is the same 0.04 wide as // before. Bounding it both ways catches a solver that has stopped // recirculating *and* one that has become unphysically energetic. assert!( (-0.21..-0.17).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(()) } /// The same Re = 100 cavity with the deferred-correction TVD scheme. /// /// First-order upwind's numerical viscosity is what holds the 65^2 /// centreline minimum near -0.19 against Ghia's -0.2109; a second-order /// convective flux removes most of that viscosity, so this measures how /// far the cavity closes on the reference once the scheme, rather than /// the resolution, stops being the limit. #[tokio::test] async fn test_lid_driven_cavity_re100_tvd() -> CfdResult<()> { use rtx_cfd::solvers::incompressible::ConvectionScheme; let config = CfdConfig::new() .with_density(1.0) .with_viscosity(1e-2) .with_reference_velocity(1.0) .with_reference_length(1.0); let params = SimpleParameters::default() .with_pressure_relaxation(0.3) .with_velocity_relaxation(0.7) .with_max_iterations(20000) .with_convection_scheme(ConvectionScheme::TvdVanAlbada) // Same corner-singularity floor as the upwind test above. .with_tolerance(2e-4); let mut solver = SimpleSolver::new(config, params)?; let nx = 65; let ny = 65; let dx = 1.0 / (nx as f64 - 1.0); let dy = 1.0 / (ny as f64 - 1.0); let mut flow_field = FlowField::new(nx, ny, dx, dy)?; let mut bcs = BoundaryConditions::new(); for location in [ BoundaryLocation::Left, BoundaryLocation::Right, BoundaryLocation::Bottom, BoundaryLocation::Top, ] { bcs.add_boundary_condition(location, BoundaryType::FreeSlipWall); } let domain_top = ny as f64 * dy; solver.set_wall_velocity(move |_x, y| { if y > 0.5 * domain_top { (1.0, 0.0) } else { (0.0, 0.0) } }); flow_field.apply_boundary_conditions(&bcs)?; let result = solver.solve(&mut flow_field, &bcs).await?; assert!( result.solver_result.converged, "TVD cavity did not converge: residual {:.3e} after {} iterations", result.solver_result.final_residual, result.solver_result.iterations ); 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; } } println!( " TVD 65^2 cavity: u_min = {u_min:.4} at y = {y_at_min:.4} \ (Ghia: -0.2109 at 0.4531) iterations = {}", result.solver_result.iterations ); // Measured: u_min = -0.2036 at y = 0.4844, in 790 iterations. Upwind // on the same mesh reads -0.1932 at 0.5000, so the TVD scheme closes // 59% of the remaining gap to Ghia's -0.2109 at equal resolution. The // band is set around what the scheme delivers and excludes the upwind // value: falling back to first order is the regression this test is // here to catch. assert!( (-0.215..-0.195).contains(&u_min), "centreline minimum {u_min:.4} outside the band the TVD scheme \ warrants at 65^2 (measured -0.2036; upwind gives -0.1932; \ Ghia -0.2109)" ); // Position: 0.4844 against Ghia's 0.4531, down from upwind's 0.5000. // The gridline spacing is 1/64, so the reading is quantised; the upper // bound excludes 0.5000 exactly because that is the upwind value. assert!( (0.44..0.50).contains(&y_at_min), "primary vortex at y = {y_at_min:.4}, outside the TVD band \ (measured 0.4844; Ghia 0.4531; upwind reads 0.5000)" ); 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(()) } }