rtx-cfd + rtx-fsi: the added-mass piston — partitioned FSI on the real ALE fluid
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

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]>
This commit is contained in:
Omar Sobh
2026-08-20 06:10:13 -07:00
co-authored by Claude Fable 5
parent 259c5baa63
commit 4bd98b5264
9 changed files with 764 additions and 57 deletions
@@ -0,0 +1,334 @@
//! The added-mass piston: partitioned FSI on a real discretised fluid,
//! against a closed form.
//!
//! An elastic piston (mass `M`, spring `K`, one degree of freedom) closes
//! the left end of a fluid channel `[s(t), L] x [0, H]`; slip walls top and
//! bottom, pressure outlet on the right. The incompressible column moves
//! rigidly with the piston, so the fluid acts on it as pure added mass and
//! the coupled dynamics have a closed form:
//!
//! ```text
//! (M + rho (L - s) H) s'' + K s = 0,
//! omega ~ sqrt( K / (M + rho L H) ) for s0 << L.
//! ```
//!
//! `rtx-cfd`'s `tests/ale_piston_channel.rs` verifies the fluid half alone
//! (the discrete column is exact in space); what this test adds is the
//! coupling: `rtx-fsi`'s `Subiterated` driver iterating a genuine
//! fluid-solve/structure-solve pass to the interface fixed point each step.
//!
//! The mass ratio is deliberately heavy — added mass `rho L H = 0.25`
//! against `M = 0.04`, ratio 6.25 — putting the staggered exchange beyond
//! its divergence threshold (Causin, Gerbeau & Nobile 2005: the interface
//! fixed point turns repulsive once the added mass outweighs what the
//! structure presents, at any time step). One discrete subtlety this test
//! itself surfaced: the *continuous* threshold `m_a > M` is not the
//! discrete one. Newmark average acceleration weights the interface force
//! by `beta dt^2`, so the staggered iteration gain here is
//! `beta m_a / (M + K beta dt^2)` — a mass ratio of 2.5 at `beta = 1/4`
//! gives gain 0.625 and *converges* (measured: ~17 passes/step). The
//! benchmark therefore runs at ratio 6.25, gain ~1.56, which is genuinely
//! repulsive. `rtx-fsi` reproduced the instability on a linear model map;
//! here it must reproduce it against the real solver, and Aitken
//! relaxation must recover it — in the *same* configuration. Three claims:
//!
//! 1. Plain staggered (unit relaxation) coupling **diverges**.
//! 2. Aitken converges in a handful of subiterations per step.
//! 3. The converged oscillation period matches the added-mass closed form
//! — and is nowhere near the dry-structure period `2 pi sqrt(M/K)`,
//! so the agreement could not have happened without the fluid.
//!
//! Measured: staggered diverges after 7 subiterations; Aitken runs at 3.0
//! subiterations/step and lands on T = 1.07009 vs the closed form's
//! 1.06999 (9.8e-5 relative, halving to 4.8e-5 at dt/2 — first order,
//! from the half-step centring between the fluid's backward-difference
//! acceleration and Newmark's); outlet flux matches the piston sweep to
//! ~1e-9 every step. Finding on the way in: the absolute epsilon guard in
//! `aitken_factor` disabled Aitken below residual ~1e-8 — see
//! `coupling.rs` and the `aitken_is_scale_invariant` unit test.
use rtx_cfd::CfdConfig;
use rtx_cfd::solvers::incompressible::ale::{
AleBoundaries, AleField, AleParameters, AlePisoSolver, SideBoundary,
};
use rtx_fsi::{FsiError, Subiterated};
use std::cell::RefCell;
use std::f64::consts::PI;
const RHO: f64 = 1.0;
const L: f64 = 1.0;
const H: f64 = 0.25;
const NX: usize = 32;
const NY: usize = 4;
const M: f64 = 0.04;
const K: f64 = 10.0;
const S0: f64 = 0.02;
const ADDED_MASS: f64 = RHO * L * H;
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()
}
/// One-DOF structure: Newmark average acceleration (beta 1/4, gamma 1/2),
/// unconditionally stable and second order.
#[derive(Debug, Clone, Copy)]
struct Piston {
s: f64,
v: f64,
a: f64,
}
impl Piston {
fn step(self, dt: f64, force: f64) -> Piston {
const BETA: f64 = 0.25;
const GAMMA: f64 = 0.5;
let s_pred = self.s + dt * self.v + 0.5 * dt * dt * (1.0 - 2.0 * BETA) * self.a;
let a_new = (force - K * s_pred) / (M + K * BETA * dt * dt);
Piston {
s: s_pred + BETA * dt * dt * a_new,
v: self.v + dt * ((1.0 - GAMMA) * self.a + GAMMA * a_new),
a: a_new,
}
}
}
/// The pressure on the piston face: the discrete field is exactly linear
/// in x, so two cell columns extrapolate it to the wall exactly.
fn wall_pressure(field: &AleField) -> f64 {
let xc0 = 0.5 * (field.x[0] + field.x[1]);
let xc1 = 0.5 * (field.x[1] + field.x[2]);
let mean =
|column: usize| -> f64 { (0..NY).map(|j| field.p[(j, column)]).sum::<f64>() / NY as f64 };
let p0 = mean(0);
let p1 = mean(1);
p0 + (p0 - p1) * (xc0 - field.x[0]) / (xc1 - xc0)
}
fn fluid_solver() -> AlePisoSolver {
let config = CfdConfig::new()
.with_density(RHO)
.with_viscosity(1e-3)
.with_reference_velocity(1.0)
.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()
};
AlePisoSolver::new(config, params).expect("valid solver configuration")
}
/// Consistent rest start: piston displaced to `s0` and released; the fluid
/// is at rest and the initial pressure field is the added-mass reaction to
/// the initial coupled acceleration.
fn initial_state() -> (AleField, Piston) {
let a0 = -K * S0 / (M + RHO * (L - S0) * H);
let mut field = AleField::new(lines_x(S0), lines_y()).expect("valid grid");
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 {
field.p[(j, i)] = RHO * a0 * (L - xc[i]);
}
}
let piston = Piston {
s: S0,
v: 0.0,
a: a0,
};
(field, piston)
}
struct CoupledRun {
/// Downward zero-crossing times of s(t).
crossings: Vec<f64>,
mean_subiterations: f64,
/// Worst |outlet volume flux - piston sweep rate| over the run.
worst_flux_mismatch: f64,
}
/// March the coupled system, driving each step's interface (the end-of-step
/// piston position) to a fixed point with the given scheme.
fn run_coupled(dt: f64, t_end: f64, scheme: &mut Subiterated) -> Result<CoupledRun, FsiError> {
let (field0, piston0) = initial_state();
let solver = RefCell::new(fluid_solver());
let base = RefCell::new(field0);
let piston_committed = RefCell::new(piston0);
// The state the most recent coupling pass produced, committed after
// the interface converges.
let latest: RefCell<Option<(AleField, Piston)>> = RefCell::new(None);
let steps = (t_end / dt).round() as usize;
let mut crossings = Vec::new();
let mut total_iterations = 0usize;
let mut worst_flux: f64 = 0.0;
let mut previous_s = piston0.s;
for step in 0..steps {
let t0 = step as f64 * dt;
let piston_n = *piston_committed.borrow();
let s_n = piston_n.s;
let pass = |state: &[f64]| -> Vec<f64> {
let s_candidate = state[0];
let mut field = base.borrow().clone();
let mut fluid = solver.borrow_mut();
fluid.set_time(t0);
let wall = (s_candidate - s_n) / dt;
fluid.set_boundary_velocity(
move |x, _y, _t| {
if x < 0.5 * L { (wall, 0.0) } else { (0.0, 0.0) }
},
);
let result = futures::executor::block_on(fluid.advance(
&mut field,
&lines_x(s_candidate),
&lines_y(),
dt,
))
.expect("fluid step");
assert!(
result.solver_result.converged,
"fluid mass residual {:.3e}",
result.solver_result.final_residual
);
// Pressure pushes the piston out of the fluid (toward -x).
let force = -wall_pressure(&field) * H;
let candidate = piston_n.step(dt, force);
*latest.borrow_mut() = Some((field, candidate));
vec![candidate.s]
};
// Predict the interface with the structure alone, then iterate.
let s_predicted = piston_n.step(dt, -wall_pressure(&base.borrow()) * H).s;
let converged = scheme.solve(&[s_predicted], pass)?;
// One final pass at the agreed interface leaves fluid and structure
// consistent with it.
pass(&converged.state);
let (field_new, piston_new) = latest.borrow_mut().take().expect("pass ran");
// Discrete mass bookkeeping: what leaves the outlet must equal what
// the piston sweeps, every step.
let outlet_flux: f64 = (0..NY)
.map(|j| field_new.u[(j, NX)] * (field_new.y[j + 1] - field_new.y[j]))
.sum();
let sweep_rate = (piston_new.s - s_n) / dt * H;
worst_flux = worst_flux.max((outlet_flux - sweep_rate).abs());
total_iterations += converged.iterations;
base.replace(field_new);
piston_committed.replace(piston_new);
let t1 = (step + 1) as f64 * dt;
if previous_s > 0.0 && piston_new.s <= 0.0 {
crossings.push(t1 - dt * piston_new.s / (piston_new.s - previous_s));
}
previous_s = piston_new.s;
}
Ok(CoupledRun {
crossings,
mean_subiterations: total_iterations as f64 / steps as f64,
worst_flux_mismatch: worst_flux,
})
}
fn mean_period(crossings: &[f64]) -> f64 {
assert!(
crossings.len() >= 3,
"need at least three crossings, got {}",
crossings.len()
);
let periods: Vec<f64> = crossings.windows(2).map(|w| w[1] - w[0]).collect();
periods.iter().sum::<f64>() / periods.len() as f64
}
#[test]
fn aitken_coupling_lands_on_the_added_mass_frequency() {
let t_exact = 2.0 * PI * ((M + ADDED_MASS) / K).sqrt();
let t_dry = 2.0 * PI * (M / K).sqrt();
let mut scheme = Subiterated::aitken(50, 1e-11).expect("valid scheme");
let coarse = run_coupled(2e-3, 4.0, &mut scheme).expect("coupled run");
let fine = run_coupled(1e-3, 4.0, &mut scheme).expect("coupled run");
let t_coarse = mean_period(&coarse.crossings);
let t_fine = mean_period(&fine.crossings);
let err_coarse = (t_coarse - t_exact).abs() / t_exact;
let err_fine = (t_fine - t_exact).abs() / t_exact;
println!(
" closed form T = {t_exact:.5} (dry {t_dry:.5})\n dt 2e-3: T = {t_coarse:.5} \
(err {err_coarse:.2e}), {:.1} subiterations/step, flux mismatch {:.2e}\n dt 1e-3: \
T = {t_fine:.5} (err {err_fine:.2e}), {:.1} subiterations/step, flux mismatch {:.2e}",
coarse.mean_subiterations,
coarse.worst_flux_mismatch,
fine.mean_subiterations,
fine.worst_flux_mismatch
);
// The coupled period matches the closed form and refines toward it.
assert!(
err_coarse < 0.01,
"period {t_coarse:.5} vs closed form {t_exact:.5}: error {err_coarse:.3e}"
);
assert!(
err_fine < err_coarse,
"period error did not fall with dt: {err_coarse:.3e} -> {err_fine:.3e}"
);
// The added mass is what it matched: the dry period is 47% shorter. If
// the fluid force were wrong or missing, the measurement would sit
// near t_dry, not t_exact.
assert!(
(t_coarse - t_dry).abs() > 0.4 * t_dry,
"measured period {t_coarse:.5} is suspiciously near the dry period {t_dry:.5}"
);
// Aitken earns its keep: a handful of passes per step, not the budget.
assert!(
coarse.mean_subiterations < 10.0,
"mean subiterations {:.1}",
coarse.mean_subiterations
);
// Conservation across the coupling: outlet flux equals piston sweep to
// solver tolerance, every step.
assert!(
coarse.worst_flux_mismatch < 1e-8,
"outlet flux vs piston sweep mismatch {:.3e}",
coarse.worst_flux_mismatch
);
}
#[test]
fn plain_staggered_coupling_diverges_under_heavy_added_mass() {
// Unit relaxation, generous budget: the added-mass ratio of 2.5 makes
// the interface fixed point repulsive, so this must report divergence
// - the same failure rtx-fsi reproduces on its linear model map, now
// on the real solver. If this ever starts converging, either the mass
// ratio changed or the fluid stopped pushing back; both are findings.
let mut scheme = Subiterated::relaxed(1.0, 50, 1e-11).expect("valid scheme");
match run_coupled(2e-3, 0.5, &mut scheme) {
Err(FsiError::CouplingDiverged {
iterations,
residual,
}) => {
println!(" diverged after {iterations} subiterations, residual {residual:.3e}");
}
Err(other) => panic!("expected CouplingDiverged, got {other:?}"),
Ok(run) => panic!(
"expected divergence, but the staggered scheme converged \
({:.1} subiterations/step on average)",
run.mean_subiterations
),
}
}