overset added-mass pin (P5-3): a no-slip cylinder on the moving patch oscillating in a closed box of still fluid, the force fitted over the last two periods — C_m = 1.069 (n = 32) / 1.097 (n = 64) against Stokes's 1.064 plus box blockage: the composite's unsteady pressure response is right, the FSI2 lift excess is not the unsteady term's scale
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
Performance Benchmarks / Run Benchmarks (push) Canceled after 0s
CI / Format Check (push) Canceled after 0s
CI / Clippy Check (push) Canceled after 0s
Documentation / Build API Documentation (push) Canceled after 0s
Documentation / Build User Guide (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

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_0116sg1Qz1gMv9hdcKP1XUam
This commit is contained in:
Omar Sobh
2026-09-09 18:01:53 -07:00
co-authored by Claude Fable 5.1
parent aeb70eb507
commit 33534ee688
@@ -0,0 +1,206 @@
//! P5-3's added-mass pin (`docs/overset_metal_campaign.md`, the replay-to-
//! replay reading): the composite's UNSTEADY pressure response on the
//! moving patch against potential theory.
//!
//! A no-slip circular cylinder of radius `r` on an O-grid patch translates
//! through a closed box of still fluid, `x_c(t) = A sin ωt`, the patch
//! carried with it every step (`set_patch_mesh`, the A-P2 moving path)
//! and the wall carrying the exact velocity. The in-phase force is the
//! added-mass reaction, `F = m_a ẍ` with `m_a = ρ π r²` (unbounded
//! potential flow), plus Stokes's viscous correction `4 / √(π β)` with
//! `β = r² ω / ν` and a small blockage term for the box. The FSI2
//! ladder's motion-fixed lift response is ~1.4× the reference's at every
//! h and dt (2026-09-09); nothing rigid (CFD13, all within 1 %) can see
//! the unsteady scale, this can. Registered before the run: `C_m` in
//! [1.0, 1.25] at n = 64 means the unsteady scale is right and the
//! discrepancy is the wake's; `C_m ≥ 1.4` locates it here.
//!
//! `RTX_OVERSET_N` (64), `RTX_OVERSET_AM_PERIODS` (4).
use rtx_cfd::mesh::patch_gen::annulus_skewed;
use rtx_cfd::mesh::{PatchMesh, PatchSide};
use rtx_cfd::solvers::incompressible::{
CurvilinearParameters, CurvilinearPisoSolver, EmbeddedParameters, EmbeddedPisoSolver,
FlowField, NormalDiffusion, OversetField, OversetParameters, OversetPisoSolver, PatchField,
PoissonSolverKind,
};
use rtx_cfd::{CfdConfig, CfdResult};
use std::f64::consts::PI;
const RHO: f64 = 1.0;
const NU: f64 = 5e-5;
const R: f64 = 0.1;
const R_OUT: f64 = 0.25;
const CX: f64 = 0.5;
const CY: f64 = 0.5;
const AMP: f64 = 0.005;
const OMEGA: f64 = 2.0 * PI;
fn patch_at(n: usize, x: f64) -> CfdResult<PatchMesh> {
annulus_skewed([CX + x, CY], R, R_OUT, 9 * n / 4, n / 4, 0.0, 3.0)
}
fn env_usize(k: &str, d: usize) -> usize {
std::env::var(k)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(d)
}
#[tokio::test]
async fn oscillating_cylinder_added_mass_on_the_moving_patch() -> CfdResult<()> {
let n = env_usize("RTX_OVERSET_N", 64);
let periods = env_usize("RTX_OVERSET_AM_PERIODS", 4);
let h = 1.0 / n as f64;
let config = CfdConfig::new()
.with_density(RHO)
.with_viscosity(RHO * NU)
.with_reference_velocity(AMP * OMEGA)
.with_reference_length(2.0 * R);
let mut background = EmbeddedPisoSolver::new(
config.clone(),
EmbeddedParameters {
corrector_steps: 2,
tolerance: 1e-8,
poisson_solver: PoissonSolverKind::Multigrid,
..EmbeddedParameters::default()
},
)?;
background.set_boundary_velocity(|_, _, _| (0.0, 0.0));
let mut patch = CurvilinearPisoSolver::new(
config,
CurvilinearParameters {
tolerance: 1e-6,
normal_diffusion: NormalDiffusion::LineImplicit,
..CurvilinearParameters::default()
},
patch_at(n, 0.0)?,
)?;
// The wall's exact velocity: the patch's clock is the end of the step.
patch.set_side_velocity(PatchSide::Inner, |_, _, t| {
(AMP * OMEGA * (OMEGA * t).cos(), 0.0)
});
let mut patch_field = PatchField::new(patch.mesh());
patch.initialize(&mut patch_field, |_, _| (0.0, 0.0));
let bg_field = FlowField::new(n, n, h, h)?;
let params = OversetParameters {
stall_rounds: 0,
..OversetParameters::default()
};
let mut solver = OversetPisoSolver::new(background, patch, (n, n, h, h), params)?;
let mut field = OversetField {
background: bg_field,
patch: patch_field,
};
solver.initialize(&mut field)?;
// Step: the patch's smallest edge against the explicit limits, and the
// wall's own displacement per step well under a cell.
let mut hp = f64::INFINITY;
for c in 0..solver.patch().mesh().cell_count() {
for (f, _) in solver.patch().mesh().cell_faces(c) {
let d = solver.patch().mesh().faces()[f].d;
hp = hp.min((d[0] * d[0] + d[1] * d[1]).sqrt());
}
}
let period = 2.0 * PI / OMEGA;
let dt_raw = 0.4 * (hp * hp / (4.0 * NU)).min(h).min(0.2 * hp / (AMP * OMEGA));
let steps_per_period = (period / dt_raw).ceil() as usize;
let dt = period / steps_per_period as f64;
let beta = R * R * OMEGA / NU;
println!(
" n = {n}, h = {h:.4}, patch inner edge {hp:.4}, dt = {dt:.3e} ({steps_per_period} per period), A/r = {:.3}, KC = {:.3}, β = {beta:.0}: Stokes C_m ≈ {:.3}",
AMP / R,
2.0 * PI * AMP / R,
1.0 + 4.0 / (PI * beta).sqrt()
);
let mut samples: Vec<(f64, f64, f64, f64)> = Vec::new();
let mut rounds_total = 0usize;
let mut reclass_total = 0usize;
let start = std::time::Instant::now();
for step in 0..periods * steps_per_period {
let t_new = (step + 1) as f64 * dt;
solver.set_patch_mesh(patch_at(n, AMP * (OMEGA * t_new).sin())?)?;
let r = solver.advance(&mut field, dt).await?;
rounds_total += r.rounds.iter().sum::<usize>();
reclass_total += r.reclassified_cells;
let load = solver
.patch()
.surface_force(&field.patch, PatchSide::Inner, solver.time());
let f = load.total();
samples.push((t_new, f[0], load.pressure[0], load.viscous[0]));
if (step + 1) % steps_per_period == 0 {
println!(
" period {}: F_x at the end {:+.4e} (pressure {:+.4e}, viscous {:+.4e}), rounds {:.2}/step, reclassified {:.1}/step, {:.0} s",
(step + 1) / steps_per_period,
f[0],
load.pressure[0],
load.viscous[0],
rounds_total as f64 / (step + 1) as f64,
reclass_total as f64 / (step + 1) as f64,
start.elapsed().as_secs_f64()
);
}
}
// Least squares over the last two periods: F = a sin ωt + b cos ωt (+ c).
// ẍ = A ω² sin ωt, so the added-mass reaction m_a ẍ = m_a A ω² sin ωt:
// C_m = a / (ρ π r² A ω²). ẋ = A ω cos ωt, so b = c_d A ω (damping).
let fit = |col: usize| -> (f64, f64, f64) {
let last: Vec<&(f64, f64, f64, f64)> = samples
.iter()
.filter(|s| s.0 > (periods as f64 - 2.0) * period - 1e-12)
.collect();
let g = |s: &(f64, f64, f64, f64)| match col {
1 => s.1,
2 => s.2,
_ => s.3,
};
// Normal equations for [sin, cos, 1].
let mut m = [[0.0f64; 3]; 3];
let mut rhs = [0.0f64; 3];
for s in &last {
let b = [(OMEGA * s.0).sin(), (OMEGA * s.0).cos(), 1.0];
for i in 0..3 {
rhs[i] += b[i] * g(s);
for j in 0..3 {
m[i][j] += b[i] * b[j];
}
}
}
// 3×3 solve by Cramer's rule.
let det = |a: [[f64; 3]; 3]| {
a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
- a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
+ a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0])
};
let d = det(m);
let mut sol = [0.0; 3];
for k in 0..3 {
let mut mk = m;
for i in 0..3 {
mk[i][k] = rhs[i];
}
sol[k] = det(mk) / d;
}
(sol[0], sol[1], sol[2])
};
let (a, b, c) = fit(1);
let (ap, bp, _) = fit(2);
let (av, bv, _) = fit(3);
let m_a = RHO * PI * R * R;
let cm = a / (m_a * AMP * OMEGA * OMEGA);
let cm_p = ap / (m_a * AMP * OMEGA * OMEGA);
let cm_v = av / (m_a * AMP * OMEGA * OMEGA);
let damping = -b / (AMP * OMEGA);
println!(
" ADDED MASS n = {n}: C_m = {cm:.4} (pressure {cm_p:.4} + viscous {cm_v:.4}) against 1 + Stokes {:.3}; damping coefficient {damping:.4e} (pressure {:+.3e}, viscous {:+.3e}) [N·s/m per m]; mean force {c:+.3e}; {} steps in {:.0} s",
1.0 + 4.0 / (PI * beta).sqrt(),
-bp / (AMP * OMEGA),
-bv / (AMP * OMEGA),
samples.len(),
start.elapsed().as_secs_f64()
);
assert!(cm.is_finite(), "non-finite added-mass coefficient");
Ok(())
}