b4am: prescribed b4 motion on the overset (modal added mass / fluid damping instrument)

- tests/fsi2_b4_added_mass.rs: #[ignore] instrument fsi2_b4_prescribed_mode. The flag's wetted
  surface moves as q(t) phi (phi from B4_MODE, M-orthonormal in-vacuo b4), q = q0 r(t) sin(w t);
  per step the CSV records q, qd, qdd and the generalised fluid force sum_k f_k . phi_k from the
  march's own nodal load (sample_load); optional per-node load CSV. B4_DT overrides the fluid step.
- fsi2_harness/overset.rs: RTX_FSI2O_STILL=1 turns the inflow off (still fluid) while keeping
  u_mean for the CFL step and the solvers' reference flux. Unset = unchanged; gate: a short
  prescribed replay (fsi2_overset_prescribed_motion, ny 62, 185 steps) byte-identical to main.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-09-25 10:23:21 -05:00
co-authored by Claude Opus 5.5
parent 973274f2c0
commit ec0e4f1573
2 changed files with 212 additions and 1 deletions
@@ -0,0 +1,201 @@
//! The FSI2 flag's 4th bending mode b4 as a PRESCRIBED motion on the
//! overset fluid (track 1 round 5, `b4am`): the fluid-side test of the
//! round-4 verdict that FSI2's 5f lift excess is the wet b4 resonating
//! near 5f = 9.67 Hz. The flag's wetted surface moves as
//! `d(t) = q(t) φ` with `φ` the in-vacuo b4 shape at the wetted DoFs
//! (M-orthonormal, from `fsi2_flag_modes`' operators) and
//! `q(t) = q0 r(t) sin(ω (t − t0))` (`r` a smooth start ramp); per fluid
//! step the CSV records `q`, `q̇`, `q̈` and the generalised fluid force
//! `F = Σ_k f_k · φ_k` over the wetted nodes (the march's own nodal load,
//! `sample_load`), from which the analysis fits the part in phase with
//! the acceleration (the modal added mass) and with the velocity (the
//! modal fluid damping).
//!
//! `#[ignore]`d instrument; test-only code, nothing in the solver or the
//! march changes. Knobs:
//!
//! * `B4_MODE` (required): lines `node_id φx φy` for the wetted nodes
//! (other nodes are ignored; missing wetted nodes are zero).
//! * `B4_Q0` modal amplitude (1e-3), `B4_FREQ` Hz (9.67), `B4_PERIODS`
//! (10), `B4_RAMP_PERIODS` (2).
//! * `B4_DT`: override the fluid step (to run still fluid at the mean
//! flow's step; unset = the case's CFL step).
//! * `RTX_FSI2O_LOAD` (optional): the rigid state to start from (the mean
//! flow); unset = start from rest (with `RTX_FSI2O_UMEAN=0`: still fluid).
//! * `B4_CSV` (required), `B4_NODE_CSV` (optional: t + fx, fy per wetted
//! node, for projections onto other modes).
//! * `RTX_FSI2O_NY` (62), and every `RTX_FSI2O_*` fluid knob the overset
//! builder reads (tip corner, warm sweeps, red-black, …).
mod fsi2_harness;
use std::io::Write as _;
use fsi2_harness::overset::OversetFluid;
use fsi2_harness::replay::ramp;
use fsi2_harness::{FSI2, case_from_env};
fn env_f(k: &str, d: f64) -> f64 {
std::env::var(k)
.ok()
.map(|v| v.parse().unwrap_or_else(|_| panic!("{k}")))
.unwrap_or(d)
}
#[test]
#[ignore = "instrument: prescribed b4 motion on the overset (needs B4_MODE, B4_CSV)"]
fn fsi2_b4_prescribed_mode() {
let mode_path = std::env::var("B4_MODE").expect("B4_MODE");
let csv_path = std::env::var("B4_CSV").expect("B4_CSV");
let ny = env_f("RTX_FSI2O_NY", 62.0) as usize;
let q0 = env_f("B4_Q0", 1e-3);
let freq = env_f("B4_FREQ", 9.67);
let periods = env_f("B4_PERIODS", 10.0);
let ramp_periods = env_f("B4_RAMP_PERIODS", 2.0);
let max_rounds = env_f("RTX_FSI2O_MAX_ROUNDS", 3.0) as usize;
let case = case_from_env("FSI2O", FSI2);
let mut fluid = OversetFluid::build_case(case, ny, 35, 100, max_rounds).expect("overset fluid");
if let Ok(v) = std::env::var("B4_DT") {
let dt: f64 = v.parse().expect("B4_DT");
println!(
" fluid step OVERRIDDEN {:.6e} → {dt:.6e} (B4_DT)",
fluid.dt_fluid
);
fluid.dt_fluid = dt;
}
let t0 = match std::env::var("RTX_FSI2O_LOAD") {
Ok(dir) => fluid.load(&dir).expect("load"),
Err(_) => {
println!(
" no RTX_FSI2O_LOAD: starting from rest (u_mean {})",
fluid.case.u_mean
);
0.0
}
};
fluid.commit_base();
fluid.solver.set_time(t0);
// φ at the wetted DoFs, in the interface's order.
let wetted = fluid.interface.wetted.clone();
let n = 2 * wetted.len();
let mut phi = vec![0.0; n];
let mut matched = 0usize;
for line in std::fs::read_to_string(&mode_path)
.expect("B4_MODE")
.lines()
{
let f: Vec<f64> = line
.split_whitespace()
.filter_map(|t| t.parse().ok())
.collect();
if f.len() < 3 {
continue;
}
if let Some(k) = wetted.iter().position(|id| id.0 == f[0] as usize) {
phi[2 * k] = f[1];
phi[2 * k + 1] = f[2];
matched += 1;
}
}
assert_eq!(
matched,
wetted.len(),
"B4_MODE must cover every wetted node"
);
let peak_y = phi
.iter()
.skip(1)
.step_by(2)
.fold(0.0f64, |a, &b| a.max(b.abs()));
let dt = fluid.dt_fluid;
let omega = 2.0 * std::f64::consts::PI * freq;
let period = 1.0 / freq;
let t_end = t0 + periods * period;
let ramp_w = ramp_periods * period;
let (drag0, lift0) = fluid.measure_force();
println!(
" B4 PRESCRIBED ny = {ny}: {} wetted nodes, q0 {q0:.4e} (peak lateral {:.4e} m), f {freq} Hz, {periods} periods from t0 = {t0:.4} (ramp {ramp_periods} periods), dt {dt:.6e} ({:.1} steps/period), u_mean {}, tip corner {} m, rounds cap {max_rounds}; start drag {drag0:.3} lift {lift0:.3}",
wetted.len(),
q0 * peak_y,
period / dt,
fluid.case.u_mean,
fsi2_harness::overset::tip_corner(),
);
let mut csv = std::fs::File::create(&csv_path).expect("B4_CSV");
writeln!(
csv,
"t,q,qd,qdd,F,Fx_sum,Fy_sum,drag,lift,rounds_max,schwarz_ok,conservation"
)
.unwrap();
let mut node_csv = std::env::var("B4_NODE_CSV").ok().map(|p| {
let mut f = std::fs::File::create(p).expect("B4_NODE_CSV");
let mut head = String::from("t");
for id in &wetted {
head.push_str(&format!(",fx{},fy{}", id.0, id.0));
}
writeln!(f, "{head}").unwrap();
f
});
let start = std::time::Instant::now();
let mut step = 0usize;
let mut t_now = t0;
while t_now < t_end - 0.5 * dt {
let t_new = t_now + dt;
let s = omega * (t_new - t0);
let (r, rd) = ramp(t_new, t0, ramp_w);
let q = q0 * r * s.sin();
let qd = q0 * (r * omega * s.cos() + rd * s.sin());
// q̈ of the steady part (the ramp's own terms are dropped: the fit
// reads only the post-ramp window).
let qdd = -q0 * r * omega * omega * s.sin();
let d: Vec<f64> = phi.iter().map(|p| q * p).collect();
let v: Vec<f64> = phi.iter().map(|p| qd * p).collect();
if let Err(e) = fluid.set_geometry(&d, &v) {
panic!("set_geometry died at step {step}, t = {t_new:.5}: {e:?}");
}
let res = fluid
.step()
.unwrap_or_else(|e| panic!("fluid step died at step {step}, t = {t_new:.5}: {e:?}"));
fluid.commit_base();
let (nodal, conservation, _) = fluid.sample_load(&d);
let mut gen_f = 0.0;
let (mut fx, mut fy) = (0.0, 0.0);
for (k, (_, f)) in nodal.iter().enumerate() {
gen_f += f.x * phi[2 * k] + f.y * phi[2 * k + 1];
fx += f.x;
fy += f.y;
}
let (drag, lift) = fluid.measure_force();
writeln!(
csv,
"{t_new:.9},{q:.9e},{qd:.9e},{qdd:.9e},{gen_f:.9e},{fx:.9e},{fy:.9e},{drag:.6},{lift:.6},{},{},{conservation:.3e}",
res.rounds.iter().copied().max().unwrap_or(0),
res.schwarz_converged as u8
)
.unwrap();
if let Some(f) = node_csv.as_mut() {
let mut line = format!("{t_new:.9}");
for (_, fv) in &nodal {
line.push_str(&format!(",{:.6e},{:.6e}", fv.x, fv.y));
}
writeln!(f, "{line}").unwrap();
}
step += 1;
if step % 500 == 0 {
println!(
" t = {t_new:.4} ({step} steps): q {q:+.3e} F {gen_f:+.4e} drag {drag:.2} lift {lift:.2}, {:.0} s wall",
start.elapsed().as_secs_f64()
);
}
t_now = t_new;
}
println!(
" B4 PRESCRIBED DONE: {step} steps to t = {t_now:.5} in {:.0} s wall → {csv_path}",
start.elapsed().as_secs_f64()
);
}
@@ -343,8 +343,18 @@ impl OversetFluid {
" multigrid V-cycle: CUDA DEVICE, f32 red-black (PERF-2 regime, RTX_FSI2O_MG_DEVICE)" " multigrid V-cycle: CUDA DEVICE, f32 red-black (PERF-2 regime, RTX_FSI2O_MG_DEVICE)"
); );
} }
// `RTX_FSI2O_STILL=1` (b4am): no inflow — still fluid — with the
// case's u_mean kept for the CFL step and the solvers' reference
// flux (their inner stops), so a still-fluid run differs from the
// mean-flow run in the inflow alone. Unset = the benchmark inflow.
let still = std::env::var("RTX_FSI2O_STILL").is_ok_and(|v| v == "1");
if still {
println!(
" STILL FLUID: inflow off (RTX_FSI2O_STILL), u_mean {u_mean} kept for dt and references"
);
}
background.set_boundary_velocity(move |x, y, t| { background.set_boundary_velocity(move |x, y, t| {
if x <= 0.0 { if x <= 0.0 && !still {
(inflow_for(u_mean, y, t), 0.0) (inflow_for(u_mean, y, t), 0.0)
} else { } else {
(0.0, 0.0) (0.0, 0.0)