From ec0e4f157323300753034acfad55c10a188b2cdb Mon Sep 17 00:00:00 2001 From: Omar Sobh Date: Fri, 25 Sep 2026 10:23:21 -0500 Subject: [PATCH] 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) --- .../rtx-fsi/tests/fsi2_b4_added_mass.rs | 201 ++++++++++++++++++ .../rtx-fsi/tests/fsi2_harness/overset.rs | 12 +- 2 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 crates/specialized/rtx-fsi/tests/fsi2_b4_added_mass.rs diff --git a/crates/specialized/rtx-fsi/tests/fsi2_b4_added_mass.rs b/crates/specialized/rtx-fsi/tests/fsi2_b4_added_mass.rs new file mode 100644 index 0000000..de3cfce --- /dev/null +++ b/crates/specialized/rtx-fsi/tests/fsi2_b4_added_mass.rs @@ -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 = 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 = phi.iter().map(|p| q * p).collect(); + let v: Vec = 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() + ); +} diff --git a/crates/specialized/rtx-fsi/tests/fsi2_harness/overset.rs b/crates/specialized/rtx-fsi/tests/fsi2_harness/overset.rs index b6604d7..822e951 100644 --- a/crates/specialized/rtx-fsi/tests/fsi2_harness/overset.rs +++ b/crates/specialized/rtx-fsi/tests/fsi2_harness/overset.rs @@ -343,8 +343,18 @@ impl OversetFluid { " 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| { - if x <= 0.0 { + if x <= 0.0 && !still { (inflow_for(u_mean, y, t), 0.0) } else { (0.0, 0.0)