Files
rustytorch/crates/specialized/rtx-cfd/tests/ale_dgcl.rs
T
Omar SobhandClaude Fable 5 4bd98b5264
Performance Benchmarks / Run Benchmarks (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
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
rtx-cfd + rtx-fsi: the added-mass piston — partitioned FSI on the real ALE fluid
The first coupled fluid-structure computation in the workspace, verified
against a closed form, and the first time rtx-fsi's added-mass claims run
against a real discretised fluid rather than a linear model map.

ALE extensions: per-side boundaries (Velocity / SlipWall / PressureOutlet)
and moving boundary lines. A moving Velocity side is a material wall whose
prescribed normal velocity must equal the line's own motion; a pressure
outlet takes Dirichlet p' = 0 in the projection (replacing the Neumann
anchor) with a zero-gradient predictor on its faces.

Fluid half verified alone (tests/ale_piston_channel.rs): prescribed piston
motion, slip walls, outlet. The incompressible rigid column is exact
DISCRETELY - continuity forces every u to the wall's discrete velocity
(8e-12) and the projected pressure is exactly linear with gradient rho
times the wall's backward-difference acceleration (2.5e-9).

Coupled benchmark (rtx-fsi/tests/piston_added_mass.rs): elastic piston
(Newmark average acceleration) against added mass rho*L*H at mass ratio
6.25, rtx-fsi's Subiterated driving a real fluid/structure pass per step:
- plain staggered diverges in 7 subiterations (Causin-Gerbeau-Nobile on a
  real solver);
- Aitken converges at 3.0 subiterations/step onto T = 1.07009 vs the
  closed form 1.06999 - 9.8e-5 relative, halving with dt;
- outlet flux matches the piston sweep to ~1e-9 every step.

Discrete-analysis finding: Newmark beta scales the staggered added-mass
threshold - the iteration gain is beta*m_a/(M + K*beta*dt^2), so the
continuous ratio 2.5 CONVERGES at beta = 1/4 (gain 0.625, measured ~17
passes/step) and the benchmark needs ratio 6.25 (gain 1.56).

Two real defects found and fixed, twelfth and thirteenth of the campaign:

1. rtx-cfd ale::advance re-stamped boundary faces at t_old from the
   current boundary function, which in a coupling loop carries the NEW
   interval's wall velocity - the predictor's old state had interior
   u = w0 but wall face u = w1, leaving an O(dt) pressure artifact
   confined to the wall-adjacent cells (p exact to 6e-11 everywhere
   except the wall cell at 4.7e-5). The start-of-step boundary faces are
   whatever the previous step's end-of-step application left there.

2. rtx-fsi aitken_factor guarded its denominator - a SQUARED residual-
   difference norm - against a bare f64::EPSILON, silently disabling
   Aitken below residual ~1e-8 and degrading to unit relaxation exactly
   in the well-converged regime; the repulsive fixed point then amplified
   1e-9 residuals back up and the coupling diverged. Third instance of
   the absolute-threshold species (NNLS, ECSW). The guard is relative
   now; aitken_is_scale_invariant pins it at initial residual 1e-9.

rtx-cfd 293 green (+1), rtx-fsi 29 green (+3). rtx-fsi's lib gains only
the relative guard; the coupling layer still depends on no solver
(rtx-cfd is a dev-dependency of its tests).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-20 06:10:13 -07:00

151 lines
6.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 NavierStokes 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<f64> {
(0..=NX).map(|i| LX * i as f64 / NX as f64).collect()
}
fn reference_y() -> Vec<f64> {
(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<f64> = rx.iter().map(|&xi| moved_x(xi, t_new)).collect();
let new_y: Vec<f64> = 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(())
}