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
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:
co-authored by
Claude Fable 5
parent
259c5baa63
commit
4bd98b5264
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user