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
@@ -62,6 +62,47 @@ pub enum SweptFaceRule {
EndOfStep,
}
/// What one side of the domain boundary is.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SideBoundary {
/// Prescribed velocity (the default): the boundary function supplies
/// the normal component (data for the projection) and the tangential
/// value for no-slip half-cell wall diffusion. If the boundary line
/// moves, the prescribed normal velocity must equal the line's motion
/// `(new - old)/dt` — a material wall — or mass bookkeeping will not
/// close.
#[default]
Velocity,
/// Impenetrable frictionless wall: normal velocity from the boundary
/// function (usually zero), zero tangential shear.
SlipWall,
/// Open boundary at gauge pressure zero: the normal velocity is an
/// unknown (zero-gradient predictor, corrected by the projection, whose
/// `p'` takes a Dirichlet zero on the face — which also makes the
/// Poisson system non-singular, so no cell is anchored). For a non-zero
/// outlet pressure, shift the gauge.
PressureOutlet,
}
/// Boundary type per domain side.
#[derive(Debug, Clone, Copy, Default)]
pub struct AleBoundaries {
/// x = x\[0\].
pub left: SideBoundary,
/// x = x\[nx\].
pub right: SideBoundary,
/// y = y\[0\].
pub bottom: SideBoundary,
/// y = y\[ny\].
pub top: SideBoundary,
}
impl AleBoundaries {
fn any_outlet(self) -> bool {
[self.left, self.right, self.bottom, self.top].contains(&SideBoundary::PressureOutlet)
}
}
/// Parameters for the ALE solver.
#[derive(Debug, Clone)]
pub struct AleParameters {
@@ -73,6 +114,9 @@ pub struct AleParameters {
pub tolerance: f64,
/// Face-area rule; see [`SweptFaceRule`].
pub swept_face_rule: SweptFaceRule,
/// Boundary type per domain side; all [`SideBoundary::Velocity`] by
/// default.
pub boundaries: AleBoundaries,
}
impl Default for AleParameters {
@@ -81,6 +125,7 @@ impl Default for AleParameters {
corrector_steps: 2,
tolerance: 1e-6,
swept_face_rule: SweptFaceRule::Trapezoidal,
boundaries: AleBoundaries::default(),
}
}
}
@@ -98,6 +143,7 @@ pub struct AleResult {
/// (length `nx + 1`) and `y` (length `ny + 1`) are part of the state and are
/// advanced by [`AlePisoSolver::advance`]; `x_old`/`y_old` hold the previous
/// step's lines so the solver can form swept volumes.
#[derive(Debug, Clone)]
pub struct AleField {
/// Cells in x.
pub nx: usize,
@@ -265,18 +311,29 @@ impl AlePisoSolver {
}
/// Write the prescribed normal velocities onto the boundary faces of the
/// given geometry at time `t`.
/// given geometry at time `t`. Outlet faces are unknowns and are left
/// alone.
fn apply_boundary_normals(&self, field: &mut AleField, t: f64, x: &[f64], y: &[f64]) {
let (nx, ny) = (field.nx, field.ny);
let b = self.parameters.boundaries;
let outlet = SideBoundary::PressureOutlet;
let yc = centres(y);
let xc = centres(x);
for j in 0..ny {
field.u[(j, 0)] = self.boundary(x[0], yc[j], t).0;
field.u[(j, nx)] = self.boundary(x[nx], yc[j], t).0;
if b.left != outlet {
field.u[(j, 0)] = self.boundary(x[0], yc[j], t).0;
}
if b.right != outlet {
field.u[(j, nx)] = self.boundary(x[nx], yc[j], t).0;
}
}
for i in 0..nx {
field.v[(0, i)] = self.boundary(xc[i], y[0], t).1;
field.v[(ny, i)] = self.boundary(xc[i], y[ny], t).1;
if b.bottom != outlet {
field.v[(0, i)] = self.boundary(xc[i], y[0], t).1;
}
if b.top != outlet {
field.v[(ny, i)] = self.boundary(xc[i], y[ny], t).1;
}
}
}
@@ -359,15 +416,21 @@ impl AlePisoSolver {
uo[(j, i)]
} else if j + 1 < ny {
uo[(j + 1, i)]
} else {
} else if self.parameters.boundaries.top == SideBoundary::Velocity {
self.boundary(xo[i], yo[ny], t_old).0
} else {
// Slip wall or outlet: no prescribed tangential value;
// carry the interior one.
uo[(j, i)]
};
let phi_s = if q_s >= 0.0 {
uo[(j, i)]
} else if j > 0 {
uo[(j - 1, i)]
} else {
} else if self.parameters.boundaries.bottom == SideBoundary::Velocity {
self.boundary(xo[i], yo[0], t_old).0
} else {
uo[(j, i)]
};
let conv = q_e * phi_e + q_w * phi_w + q_n * phi_n + q_s * phi_s;
@@ -377,15 +440,20 @@ impl AlePisoSolver {
let d_w = nu * (uo[(j, i - 1)] - uo[(j, i)]) / (xo[i] - xo[i - 1]) * h_o;
let d_n = if j + 1 < ny {
nu * (uo[(j + 1, i)] - uo[(j, i)]) / (yco[j + 1] - yco[j]) * w_o
} else {
} else if self.parameters.boundaries.top == SideBoundary::Velocity {
let u_wall = self.boundary(xo[i], yo[ny], t_old).0;
nu * (u_wall - uo[(j, i)]) / (yo[ny] - yco[j]) * w_o
} else {
// Slip wall or outlet: zero tangential shear.
0.0
};
let d_s = if j > 0 {
nu * (uo[(j - 1, i)] - uo[(j, i)]) / (yco[j] - yco[j - 1]) * w_o
} else {
} else if self.parameters.boundaries.bottom == SideBoundary::Velocity {
let u_wall = self.boundary(xo[i], yo[0], t_old).0;
nu * (u_wall - uo[(j, i)]) / (yco[j] - yo[0]) * w_o
} else {
0.0
};
let diff = d_e + d_w + d_n + d_s;
@@ -448,15 +516,19 @@ impl AlePisoSolver {
vo[(j, i)]
} else if i + 1 < nx {
vo[(j, i + 1)]
} else {
} else if self.parameters.boundaries.right == SideBoundary::Velocity {
self.boundary(xo[nx], yo[j], t_old).1
} else {
vo[(j, i)]
};
let phi_w = if q_w >= 0.0 {
vo[(j, i)]
} else if i > 0 {
vo[(j, i - 1)]
} else {
} else if self.parameters.boundaries.left == SideBoundary::Velocity {
self.boundary(xo[0], yo[j], t_old).1
} else {
vo[(j, i)]
};
let conv = q_e * phi_e + q_w * phi_w + q_n * phi_n + q_s * phi_s;
@@ -464,15 +536,19 @@ impl AlePisoSolver {
let d_s = nu * (vo[(j - 1, i)] - vo[(j, i)]) / (yo[j] - yo[j - 1]) * w_o;
let d_e = if i + 1 < nx {
nu * (vo[(j, i + 1)] - vo[(j, i)]) / (xco[i + 1] - xco[i]) * h_o
} else {
} else if self.parameters.boundaries.right == SideBoundary::Velocity {
let v_wall = self.boundary(xo[nx], yo[j], t_old).1;
nu * (v_wall - vo[(j, i)]) / (xo[nx] - xco[i]) * h_o
} else {
0.0
};
let d_w = if i > 0 {
nu * (vo[(j, i - 1)] - vo[(j, i)]) / (xco[i] - xco[i - 1]) * h_o
} else {
} else if self.parameters.boundaries.left == SideBoundary::Velocity {
let v_wall = self.boundary(xo[0], yo[j], t_old).1;
nu * (v_wall - vo[(j, i)]) / (xco[i] - xo[0]) * h_o
} else {
0.0
};
let diff = d_e + d_w + d_n + d_s;
@@ -488,6 +564,31 @@ impl AlePisoSolver {
}
}
// Outlet faces are unknowns without a control volume of their own:
// give them the zero-gradient (fully developed) predictor value and
// let the projection correct them.
let b = self.parameters.boundaries;
if b.left == SideBoundary::PressureOutlet {
for j in 0..ny {
field.u[(j, 0)] = field.u[(j, 1)];
}
}
if b.right == SideBoundary::PressureOutlet {
for j in 0..ny {
field.u[(j, nx)] = field.u[(j, nx - 1)];
}
}
if b.bottom == SideBoundary::PressureOutlet {
for i in 0..nx {
field.v[(0, i)] = field.v[(1, i)];
}
}
if b.top == SideBoundary::PressureOutlet {
for i in 0..nx {
field.v[(ny, i)] = field.v[(ny - 1, i)];
}
}
Ok(())
}
@@ -520,6 +621,8 @@ impl AlePisoSolver {
}
}
let b = self.parameters.boundaries;
let outlet = SideBoundary::PressureOutlet;
let reference_flux = rho * self.config.reference_velocity * self.config.reference_length;
let inner_stop =
(1e-2 * source_scale).max(0.1 * self.parameters.tolerance * reference_flux) + 1e-14;
@@ -529,33 +632,47 @@ impl AlePisoSolver {
for j in 0..ny {
let dy_j = yn[j + 1] - yn[j];
for i in 0..nx {
if i == 1 && j == 1 {
// With velocity prescribed on the whole boundary the
// system is pure Neumann and one cell anchors the level;
// any outlet contributes a Dirichlet face instead, and
// the anchor must NOT also be imposed.
if !b.any_outlet() && i == 1 && j == 1 {
field.p_prime[(j, i)] = 0.0;
continue;
}
let dx_i = xn[i + 1] - xn[i];
// A coefficient is zero exactly when its face is a
// domain boundary, where the normal velocity is data.
let ae = if i + 1 == nx {
0.0
} else {
// domain boundary with prescribed normal velocity; an
// outlet face instead carries `p' = 0` half a cell away,
// so its coefficient survives with no neighbour term.
let ae = if i + 1 < nx {
dt * dy_j / (xcn[i + 1] - xcn[i])
};
let aw = if i == 0 {
0.0
} else if b.right == outlet {
dt * dy_j / (xn[nx] - xcn[i])
} else {
0.0
};
let aw = if i > 0 {
dt * dy_j / (xcn[i] - xcn[i - 1])
};
let an = if j + 1 == ny {
0.0
} else if b.left == outlet {
dt * dy_j / (xcn[0] - xn[0])
} else {
0.0
};
let an = if j + 1 < ny {
dt * dx_i / (ycn[j + 1] - ycn[j])
};
let as_ = if j == 0 {
0.0
} else if b.top == outlet {
dt * dx_i / (yn[ny] - ycn[j])
} else {
0.0
};
let as_ = if j > 0 {
dt * dx_i / (ycn[j] - ycn[j - 1])
} else if b.bottom == outlet {
dt * dx_i / (ycn[0] - yn[0])
} else {
0.0
};
let ap = ae + aw + an + as_;
@@ -607,6 +724,32 @@ impl AlePisoSolver {
field.v[(j, i)] = field.v_star[(j, i)] - (dt / rho) * dp_dy;
}
}
// Outlet faces are correctable too, against the Dirichlet `p' = 0`
// on the face itself.
if b.right == outlet {
for j in 0..ny {
let dp_dx = (0.0 - field.p_prime[(j, nx - 1)]) / (xn[nx] - xcn[nx - 1]);
field.u[(j, nx)] = field.u_star[(j, nx)] - (dt / rho) * dp_dx;
}
}
if b.left == outlet {
for j in 0..ny {
let dp_dx = (field.p_prime[(j, 0)] - 0.0) / (xcn[0] - xn[0]);
field.u[(j, 0)] = field.u_star[(j, 0)] - (dt / rho) * dp_dx;
}
}
if b.top == outlet {
for i in 0..nx {
let dp_dy = (0.0 - field.p_prime[(ny - 1, i)]) / (yn[ny] - ycn[ny - 1]);
field.v[(ny, i)] = field.v_star[(ny, i)] - (dt / rho) * dp_dy;
}
}
if b.bottom == outlet {
for i in 0..nx {
let dp_dy = (field.p_prime[(0, i)] - 0.0) / (ycn[0] - yn[0]);
field.v[(0, i)] = field.v_star[(0, i)] - (dt / rho) * dp_dy;
}
}
for j in 0..ny {
for i in 0..nx {
field.p[(j, i)] += field.p_prime[(j, i)];
@@ -632,8 +775,11 @@ impl AlePisoSolver {
}
/// Advance one time step of size `dt`, moving the mesh nodes to
/// `new_x`/`new_y` (which must keep the domain endpoints fixed and the
/// lines strictly increasing — the motion may not invert a cell).
/// `new_x`/`new_y` (strictly increasing — the motion may not invert a
/// cell). Boundary lines may move: a moving `Velocity` side is a
/// material wall, so its prescribed normal velocity must equal the
/// line's motion `(new - old)/dt` or discrete mass bookkeeping will
/// not close.
pub async fn advance(
&mut self,
field: &mut AleField,
@@ -657,26 +803,19 @@ impl AlePisoSolver {
}
validate_lines(new_x, "new_x")?;
validate_lines(new_y, "new_y")?;
let eps_x = 1e-12 * (field.x[field.nx] - field.x[0]).abs();
let eps_y = 1e-12 * (field.y[field.ny] - field.y[0]).abs();
if (new_x[0] - field.x[0]).abs() > eps_x
|| (new_x[field.nx] - field.x[field.nx]).abs() > eps_x
|| (new_y[0] - field.y[0]).abs() > eps_y
|| (new_y[field.ny] - field.y[field.ny]).abs() > eps_y
{
return Err(CfdError::invalid_parameter(
"domain boundary must stay fixed: only interior node lines may move",
));
}
let t_old = self.time;
let t_new = t_old + dt;
// Boundary data at the start of the step, on the start-of-step
// geometry: this is what the explicit predictor differentiates.
let (x0, y0) = (field.x.clone(), field.y.clone());
self.apply_boundary_normals(field, t_old, &x0, &y0);
// The start-of-step boundary faces are whatever the previous step's
// end-of-step application (or the caller's initial condition) left
// there — the fluid's actual state at t_old. Re-stamping them here
// from the boundary function would silently substitute the *new*
// interval's wall velocity for the old one whenever the function
// carries per-step data (an FSI coupling does exactly that), and
// the resulting inconsistent old state leaves an O(dt) pressure
// artifact in the wall-adjacent cells. Found by the piston test:
// p exact to 6e-11 everywhere except the wall cell at 4.7e-5.
field.x_old.clone_from(&field.x);
field.y_old.clone_from(&field.y);
field.x.copy_from_slice(new_x);
@@ -27,7 +27,9 @@ pub mod simple;
pub mod simple_gpu;
// Re-export main types
pub use ale::{AleField, AleParameters, AlePisoSolver, AleResult, SweptFaceRule};
pub use ale::{
AleBoundaries, AleField, AleParameters, AlePisoSolver, AleResult, SideBoundary, SweptFaceRule,
};
pub use boundary_conditions::{
BoundaryCondition, BoundaryConditions, BoundaryLocation, BoundaryType,
};