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]>
194 lines
6.8 KiB
Rust
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(())
|
|
}
|