//! Turek–Hron CFD1: steady laminar flow (Re = 20) past the rigid cylinder //! with the rigid flag attached, on the embedded-boundary PISO solver. //! //! Geometry and parameters from the FEATFLOW benchmark definition (sourced //! 2026-08-20, see omni-cortex `docs/turek_hron_geometry_decision.md`): //! channel `[0, 2.5] x [0, 0.41]`, cylinder centre (0.2, 0.2) radius 0.05, //! flag `[0.25, 0.6] x [0.19, 0.21]`, `rho = 1000`, `nu = 1e-3`, parabolic //! inflow with mean `U = 0.2` (max 0.3), no-slip walls, outlet at the right. //! Reference (level 6): **drag 14.2929, lift 1.11905** on cylinder + flag. //! //! The flag is modelled as `[0.20, 0.6] x [0.19, 0.21]`: its left 5 cm lie //! inside the cylinder, which removes the two 1 mm fluid wedges the literal //! corners (0.25, 0.19 ± 0.01) would leave between bar and circle — below //! the grid scale here, and filled in the benchmark's own meshes. //! //! This is the first quantitative claim of the embedded solver against an //! external reference. The in-suite resolution is bounded by the dev //! profile's speed (the SOR projection: ~0.1 s/step at h = 10 mm, an hour //! per run at 5 mm); the assertion is correspondingly the measured band, //! not an accuracy claim, with the two load routes required to agree with //! each other as well. Measured at h = 10 mm (flag two cells thick), fully //! settled (drag stagnant to four digits): surface route drag 15.71, lift //! 0.936 (3 junction samples skipped); control-volume route drag 15.62, //! lift 1.079 — drag routes agree to 0.6%, both +9.5% on the reference. //! The refinement study that turns this into a claim waits on a multigrid //! Poisson solver (falsifier 4 of the geometry decision). use rtx_cfd::solvers::incompressible::{ AleBoundaries, EmbeddedBody, EmbeddedParameters, EmbeddedPisoSolver, FlowField, SideBoundary, }; use rtx_cfd::{CfdConfig, CfdResult}; const L: f64 = 2.5; const H: f64 = 0.41; const RHO: f64 = 1000.0; const NU: f64 = 1e-3; const U_MEAN: f64 = 0.2; const REF_DRAG: f64 = 14.2929; const REF_LIFT: f64 = 1.11905; fn inflow(y: f64) -> f64 { 1.5 * U_MEAN * y * (H - y) / (0.5 * H).powi(2) } fn body() -> EmbeddedBody { EmbeddedBody::union( EmbeddedBody::circle(0.2, 0.2, 0.05), EmbeddedBody::rectangle(0.20, 0.19, 0.6, 0.21), ) } struct Cfd1 { drag_surface: f64, lift_surface: f64, skipped: usize, drag_cv: f64, lift_cv: f64, steps: usize, seconds: f64, } /// March CFD1 to a steady state on a grid of `ny` cells across the channel. /// Steady means the control-volume drag has stopped moving: its relative /// change over the last 200 steps below `1e-4`, after at least one flow- /// through time — "the answer stopped moving", not a residual. async fn run_cfd1(ny: usize) -> CfdResult { let h = H / ny as f64; let nx = (L / h).round() as usize; let mu = RHO * NU; // Explicit predictor: the COMBINED criterion — convective Courant numbers // in both directions plus the diffusion number must stay below one — // with the local peak velocity taken as 1.5x the inflow peak for the // 24% blockage. (Taking 0.4 of the smaller single limit, as the MMS // tests do, went NaN here at t ~ 7 s: both limits are active at once.) let u_peak = 1.5 * 1.5 * U_MEAN; let dt = 0.25 / (2.0 * u_peak / h + 4.0 * NU / (h * h)); let config = CfdConfig::new() .with_density(RHO) .with_viscosity(mu) .with_reference_velocity(U_MEAN) .with_reference_length(0.1); let params = EmbeddedParameters { corrector_steps: 2, tolerance: 1e-7, boundaries: AleBoundaries { left: SideBoundary::Velocity, right: SideBoundary::PressureOutlet, bottom: SideBoundary::Velocity, top: SideBoundary::Velocity, }, }; let mut solver = EmbeddedPisoSolver::new(config, params)?; solver.set_boundary_velocity(|x, y, _| { if x <= 0.0 { (inflow(y), 0.0) } else { (0.0, 0.0) } }); solver.set_body(body()); let mut field = FlowField::new(nx, ny, h, h)?; // Start from the inflow profile everywhere (the body's faces are // overwritten by the mask at initialisation). for j in 0..ny { let u0 = inflow((j as f64 + 0.5) * h); for i in 0..=nx { field.u[(j, i)] = u0; } } solver.initialize(&mut field)?; // Control volume for the momentum balance: whole cells, in the fluid // on its boundary, enclosing cylinder and flag. let cv = ( (0.10 / h).round() as usize, (0.75 / h).round() as usize, (0.05 / h).round() as usize, (0.36 / h).round() as usize, ); let cv_force = |field: &FlowField, mask: &rtx_cfd::solvers::incompressible::EmbeddedMask, dt: f64| { mask.control_volume_force( &field.u, &field.v, &field.p, &field.u_old, &field.v_old, dt, RHO, mu, None, cv, ) }; let start = std::time::Instant::now(); let flow_through = L / U_MEAN; let min_steps = (flow_through / dt).ceil() as usize; let mut history: Vec = Vec::new(); let mut steps = 0; loop { let result = solver.advance(&mut field, dt).await?; steps += 1; if steps % 50 == 0 { let (fx, _) = cv_force(&field, solver.mask().unwrap(), dt); history.push(fx); // Diagnostics: where is the velocity largest, did the projection // converge, how big was the ghost correction. let (mut umax, mut at) = (0.0f64, (0usize, 0usize)); for j in 0..ny { for i in 0..=nx { let a = field.u[(j, i)].abs(); if a > umax { umax = a; at = (j, i); } } } if steps % 250 == 0 || umax > 3.0 * 1.5 * U_MEAN || !umax.is_finite() { println!( " ny = {ny}: step {steps} t = {:.2} s drag_cv = {fx:.4} max|u| = {umax:.4} at (x={:.3}, y={:.3}) \ projection: converged {} residual {:.2e} passes {} ghost corr {:.2e} [{:.0} s wall]", solver.time(), at.1 as f64 * h, (at.0 as f64 + 0.5) * h, result.solver_result.converged, result.solver_result.final_residual, result.corrector_steps_performed, result.ghost_correction, start.elapsed().as_secs_f64() ); } assert!( umax.is_finite(), "velocity became non-finite at step {steps}" ); if steps >= min_steps && history.len() > 4 { let now = history[history.len() - 1]; let then = history[history.len() - 5]; if ((now - then) / now).abs() < 1e-4 { break; } } } assert!(steps < 400_000, "CFD1 at ny = {ny} did not settle"); } let seconds = start.elapsed().as_secs_f64(); let mask = solver.mask().unwrap(); let surface = mask.surface_force( solver.body().unwrap(), &field.u, &field.v, &field.p, mu, solver.time(), 0.5 * h, ); let (drag_cv, lift_cv) = cv_force(&field, mask, dt); Ok(Cfd1 { drag_surface: surface.fx, lift_surface: surface.fy, skipped: surface.skipped, drag_cv, lift_cv, steps, seconds, }) } #[tokio::test] async fn cfd1_drag_and_lift_against_the_featflow_reference() -> CfdResult<()> { // One resolution until the projection has a multigrid solver: at the // SOR cost a 62-cell run takes over an hour in the test profile. let resolutions = [41usize]; let mut results = Vec::new(); for &ny in &resolutions { let r = run_cfd1(ny).await?; println!( " ny = {ny:3} (h = {:.4}) surface: drag {:.4} lift {:.4} (skipped {}) \ control volume: drag {:.4} lift {:.4} [{} steps, {:.0} s] reference drag {REF_DRAG} lift {REF_LIFT}", H / ny as f64, r.drag_surface, r.lift_surface, r.skipped, r.drag_cv, r.lift_cv, r.steps, r.seconds ); results.push(r); } let fine = results.last().unwrap(); let rel = |a: f64, b: f64| ((a - b) / b).abs(); // Both routes within the measured band of the reference (9.5% at this // grid) and of each other. assert!( rel(fine.drag_surface, REF_DRAG) < 0.12 && rel(fine.drag_cv, REF_DRAG) < 0.12, "drag: surface {:.4}, control volume {:.4}, reference {REF_DRAG}", fine.drag_surface, fine.drag_cv ); assert!( rel(fine.drag_surface, fine.drag_cv) < 0.05, "the two drag routes disagree: surface {:.4} vs control volume {:.4}", fine.drag_surface, fine.drag_cv ); assert!( rel(fine.lift_surface, REF_LIFT) < 0.25 && rel(fine.lift_cv, REF_LIFT) < 0.25, "lift: surface {:.4}, control volume {:.4}, reference {REF_LIFT}", fine.lift_surface, fine.lift_cv ); Ok(()) }