//! The discrete geometric conservation law (DGCL) test for the ALE solver. //! //! **Uniform flow must stay exactly uniform on an arbitrarily moving mesh.** //! A constant velocity field with a constant pressure is an exact solution of //! the incompressible Navier–Stokes equations regardless of how the mesh //! moves underneath it; an ALE discretisation preserves it if and only if its //! discrete volume increments equal the sums of its discrete face-swept //! volumes — the DGCL (Thomas & Lombard 1979; Farhat, Geuzaine & Grandmont //! 2001). Nothing about the physics is exercised, so any deviation is pure //! geometric inconsistency, and it shows up at machine precision rather than //! at truncation order. //! //! For tensor-product mesh motion the trapezoidal face rule is exactly //! conservative: //! //! ```text //! dx1*dy1 - dx0*dy0 = (dx1-dx0)*(dy0+dy1)/2 + (dy1-dy0)*(dx0+dx1)/2 //! ``` //! //! an algebraic identity, so with time-averaged face areas in both the fluid //! fluxes and the swept volumes the uniform state is a fixed point of the //! update to rounding error. The negative control replaces the averaged areas //! with end-of-step areas — the "obvious" choice that looks consistent and is //! first-order accurate — and per step each cell then picks up a relative //! error of exactly `dw*dh/V` (the cross term the identity above absorbs). //! That control failing loudly is what proves this test can fail. use rtx_cfd::solvers::incompressible::ale::{ AleField, AleParameters, AlePisoSolver, SweptFaceRule, }; use rtx_cfd::{CfdConfig, CfdResult}; use std::f64::consts::PI; const U_UNIFORM: f64 = 0.7; const V_UNIFORM: f64 = -0.4; const LX: f64 = 1.0; const LY: f64 = 0.75; const NX: usize = 16; const NY: usize = 12; /// Interior mesh-line motion: smooth, boundary-fixed, with different lines /// moving out of phase (the `phase * xi` term) and incommensurate frequencies /// in the two directions, so no symmetry can hide a conservation defect. /// The displacement gradient stays below 1 (`amp * (pi + phase) < 1` in the /// normalised coordinate), so mesh lines never cross. fn moved_x(xi: f64, t: f64) -> f64 { let s = xi / LX; xi + 0.06 * LX * (PI * s).sin() * (2.9 * t + 3.0 * s).sin() } fn moved_y(yj: f64, t: f64) -> f64 { let s = yj / LY; yj + 0.06 * LY * (PI * s).sin() * (4.3 * t + 2.0 * s).sin() } fn reference_x() -> Vec { (0..=NX).map(|i| LX * i as f64 / NX as f64).collect() } fn reference_y() -> Vec { (0..=NY).map(|j| LY * j as f64 / NY as f64).collect() } /// March uniform flow on the wiggling mesh and return the largest deviation /// from uniformity, over every velocity unknown and every step, plus the /// largest final pressure magnitude. async fn max_deviation(rule: SweptFaceRule, steps: usize, dt: f64) -> CfdResult<(f64, f64)> { let config = CfdConfig::new() .with_density(1.0) .with_viscosity(0.05) .with_reference_velocity(1.0) .with_reference_length(1.0); // The tolerance must sit at the rounding floor, not at an engineering // level: the projection's inner stop is floored at // `0.1 * tolerance * reference_flux`, and with a 1e-9 tolerance the SOR // quits after one sweep on the eps-level sources this test produces, // leaving a partial p' that accumulates into p (~3e-9 over 400 steps) // and whose gradient re-perturbs the velocities at ~1e-11 — five orders // above rounding, with the geometry entirely blameless. Measured before // and after: 3.6e-11 at tolerance 1e-9, rounding-level at 1e-13. let params = AleParameters { corrector_steps: 2, tolerance: 1e-13, swept_face_rule: rule, ..AleParameters::default() }; let mut solver = AlePisoSolver::new(config, params)?; solver.set_boundary_velocity(|_x, _y, _t| (U_UNIFORM, V_UNIFORM)); let mut field = AleField::new(reference_x(), reference_y())?; field.u.fill(U_UNIFORM); field.v.fill(V_UNIFORM); field.p.fill(0.0); let (rx, ry) = (reference_x(), reference_y()); let mut worst: f64 = 0.0; for step in 0..steps { let t_new = (step + 1) as f64 * dt; let new_x: Vec = rx.iter().map(|&xi| moved_x(xi, t_new)).collect(); let new_y: Vec = ry.iter().map(|&yj| moved_y(yj, t_new)).collect(); solver.advance(&mut field, &new_x, &new_y, dt).await?; for value in field.u.iter() { worst = worst.max((value - U_UNIFORM).abs()); } for value in field.v.iter() { worst = worst.max((value - V_UNIFORM).abs()); } } let max_p = field.p.iter().fold(0.0f64, |m, &p| m.max(p.abs())); Ok((worst, max_p)) } #[tokio::test] async fn uniform_flow_stays_exactly_uniform_on_an_arbitrarily_moving_mesh() -> CfdResult<()> { let (worst, max_p) = max_deviation(SweptFaceRule::Trapezoidal, 400, 1e-3).await?; println!(" DGCL: max |u - U| over 400 steps = {worst:.3e}, final max |p| = {max_p:.3e}"); // Machine precision relative to the velocity scale — not truncation // order. 400 steps of rounding accumulate to ~1e-13 at worst. let scale = U_UNIFORM.abs().max(V_UNIFORM.abs()); assert!( worst < 1e-11 * scale, "DGCL violated: uniform flow deviated by {worst:.3e} on the moving mesh" ); // With a uniform field the projection source is exactly zero, so the // pressure must never move off its initial constant. assert!(max_p < 1e-11, "pressure moved off constant: {max_p:.3e}"); Ok(()) } #[tokio::test] async fn end_of_step_face_areas_violate_the_gcl_visibly() -> CfdResult<()> { // The control: identical run, but fluxes and swept volumes use the // end-of-step face areas. Each cell's update then multiplies the uniform // state by (1 + dw*dh/V) per step — a defect this test must see and the // conservative rule must not have. let (worst, _) = max_deviation(SweptFaceRule::EndOfStep, 400, 1e-3).await?; println!(" GCL-violating control: max |u - U| over 400 steps = {worst:.3e}"); let scale = U_UNIFORM.abs().max(V_UNIFORM.abs()); assert!( worst > 1e-6 * scale, "the GCL-violating rule deviated only {worst:.3e} — the DGCL test \ has lost its teeth (motion too tame, or the rule is not actually \ reaching the fluxes)" ); Ok(()) }