Files
rustytorch/crates/specialized/rtx-cfd/tests/ale_piston_channel.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

194 lines
6.8 KiB
Rust

//! Prescribed-motion piston channel: the moving-boundary, slip-wall and
//! pressure-outlet machinery against an exact solution.
//!
//! A channel `[s(t), L] x [0, H]` whose left wall (the piston) moves with
//! prescribed `s(t) = s0 sin(Omega t)`, slip walls top and bottom, and a
//! pressure outlet at `x = L`. For an incompressible fluid the exact
//! solution is a rigid column:
//!
//! ```text
//! u(x, y, t) = s'(t) (spatially uniform)
//! v = 0
//! p(x, t) = rho s''(t) (L - x) (linear, zero at the outlet)
//! ```
//!
//! and the discretisation reproduces it **exactly** — in space and in
//! time. Continuity forces the discrete `u` to equal the wall's discrete
//! velocity `(s(t1) - s(t0))/dt` in every cell, and the projected pressure
//! is exactly the linear field whose gradient produces the wall's discrete
//! *acceleration* (the backward difference of its discrete velocity). So
//! the assertions here are at rounding/solver-tolerance level against the
//! discrete references, not truncation level against the analytic ones:
//! the analytic comparison would only measure the O(dt) difference between
//! `s'(t)` and its finite differences, which says nothing about the
//! solver. First measured: `|u - wall|` ~8e-12, `|p - exact_disc|` at
//! solver-tolerance, `|v|` ~2e-12.
//!
//! This isolates the fluid half of the added-mass FSI benchmark
//! (`rtx-fsi/tests/piston_added_mass.rs`): if that test misbehaves and
//! this one is green, the coupling is at fault, not the fluid.
use rtx_cfd::solvers::incompressible::ale::{
AleBoundaries, AleField, AleParameters, AlePisoSolver, SideBoundary,
};
use rtx_cfd::{CfdConfig, CfdResult};
const RHO: f64 = 1.0;
const L: f64 = 1.0;
const H: f64 = 0.25;
const NX: usize = 32;
const NY: usize = 4;
const S0: f64 = 0.02;
const OMEGA: f64 = 5.0;
fn s_of(t: f64) -> f64 {
S0 * (OMEGA * t).sin()
}
fn s_dot(t: f64) -> f64 {
S0 * OMEGA * (OMEGA * t).cos()
}
fn s_ddot(t: f64) -> f64 {
-S0 * OMEGA * OMEGA * (OMEGA * t).sin()
}
/// Node lines for piston position `s`: linear stretch of the reference
/// spacing onto `[s, L]`.
fn lines_x(s: f64) -> Vec<f64> {
(0..=NX)
.map(|i| s + (L - s) * i as f64 / NX as f64)
.collect()
}
fn lines_y() -> Vec<f64> {
(0..=NY).map(|j| H * j as f64 / NY as f64).collect()
}
struct Errors {
max_u: f64,
max_v: f64,
max_p: f64,
}
/// March to `t_end` and return worst-case deviations from the closed form,
/// measured over the second half of the run (past the start-up transient
/// of the impulsively consistent but discretely fresh initial state).
async fn measure(dt: f64, t_end: f64) -> CfdResult<Errors> {
let config = CfdConfig::new()
.with_density(RHO)
.with_viscosity(1e-3)
.with_reference_velocity(S0 * OMEGA)
.with_reference_length(L);
let params = AleParameters {
corrector_steps: 30,
tolerance: 1e-10,
boundaries: AleBoundaries {
left: SideBoundary::Velocity,
right: SideBoundary::PressureOutlet,
bottom: SideBoundary::SlipWall,
top: SideBoundary::SlipWall,
},
..AleParameters::default()
};
let mut solver = AlePisoSolver::new(config, params)?;
let mut field = AleField::new(lines_x(0.0), lines_y())?;
// Consistent initial state: u = s'(0) everywhere, p = rho s''(0) (L-x)
// = 0 at t = 0.
field.u.fill(s_dot(0.0));
let steps = (t_end / dt).round() as usize;
let mut worst = Errors {
max_u: 0.0,
max_v: 0.0,
max_p: 0.0,
};
let mut previous_wall = s_dot(0.0);
for step in 0..steps {
let t0 = step as f64 * dt;
let t1 = (step + 1) as f64 * dt;
// A material wall: the prescribed velocity must be the mesh line's
// own motion over the step, not the analytic s'(t).
let wall = (s_of(t1) - s_of(t0)) / dt;
solver
.set_boundary_velocity(move |x, _y, _t| if x < 0.5 { (wall, 0.0) } else { (0.0, 0.0) });
let result = solver
.advance(&mut field, &lines_x(s_of(t1)), &lines_y(), dt)
.await?;
assert!(
result.solver_result.converged,
"step {step}: mass residual {:.3e}",
result.solver_result.final_residual
);
if t1 >= 0.5 * t_end {
// The discrete references: continuity makes every u equal the
// wall's discrete velocity, and the projected pressure gradient
// produces the wall's discrete acceleration.
let a_disc = (wall - previous_wall) / dt;
for value in field.u.iter() {
worst.max_u = worst.max_u.max((value - wall).abs());
}
for value in field.v.iter() {
worst.max_v = worst.max_v.max(value.abs());
}
let xc: Vec<f64> = field.x.windows(2).map(|w| 0.5 * (w[0] + w[1])).collect();
for j in 0..NY {
for i in 0..NX {
let p_ref = RHO * a_disc * (L - xc[i]);
worst.max_p = worst.max_p.max((field.p[(j, i)] - p_ref).abs());
}
}
}
previous_wall = wall;
}
Ok(worst)
}
#[tokio::test]
async fn prescribed_piston_drives_the_exact_rigid_column() -> CfdResult<()> {
let t_end = 2.0;
let coarse = measure(2e-3, t_end).await?;
let fine = measure(1e-3, t_end).await?;
let u_scale = S0 * OMEGA;
let p_scale = RHO * S0 * OMEGA * OMEGA * L;
println!(
" dt 2e-3: |u - s'| {:.3e} |v| {:.3e} |p - exact| {:.3e}",
coarse.max_u, coarse.max_v, coarse.max_p
);
println!(
" dt 1e-3: |u - s'| {:.3e} |v| {:.3e} |p - exact| {:.3e}",
fine.max_u, fine.max_v, fine.max_p
);
// The rigid column is exact discretely: rounding level, at both step
// sizes, with no truncation term to refine away.
for errors in [&coarse, &fine] {
assert!(
errors.max_u < 1e-9 * u_scale,
"u deviates from the discrete wall velocity by {:.3e} \
(scale {u_scale:.3e}) — the rigid column should be exact",
errors.max_u
);
// Nothing drives v: it stays at rounding level.
assert!(
errors.max_v < 1e-9 * u_scale,
"v should be identically zero, got {:.3e}",
errors.max_v
);
// The pressure is the fluid's added-mass reaction — the quantity
// the FSI benchmark feeds back to the structure. Exactly linear,
// zero at the outlet, gradient rho times the wall's discrete
// acceleration; the residual here is the projection's inner-solve
// tolerance, not truncation.
assert!(
errors.max_p < 1e-5 * p_scale,
"p deviates from rho a_disc (L - x) by {:.3e} (scale {p_scale:.3e})",
errors.max_p
);
}
Ok(())
}