Files
rustytorch/crates/specialized/rtx-fsi/tests/turek_hron_fsi2_overset.rs
T
Omar SobhandClaude Fable 5.1 f1714fb926
CI / Build (macos-latest) (push) Waiting to run
CI / Test (macos-latest) (push) Blocked by required conditions
CI / Test (ubuntu-latest) (push) Blocked by required conditions
CI / Python Bindings (maturin) (macos-latest) (push) Blocked by required conditions
CI / Python Bindings (maturin) (ubuntu-latest) (push) Blocked by required conditions
CI / WASM Build + Size Check (push) Blocked by required conditions
CI / Distributed Training Tests (push) Blocked by required conditions
CI / CI Success (push) Blocked by required conditions
CI / Build CPU-Only (Explicit) (push) Failing after 4s
Documentation / Build API Documentation (push) Failing after 4s
Documentation / Build User Guide (push) Successful in 6s
CI / Format Check (push) Failing after 13s
CI / Build (ubuntu-latest) (push) Failing after 1m56s
CI / Clippy Check (push) Failing after 2m12s
Performance Benchmarks / Run Benchmarks (push) Successful in 2m54s
S2-9: the flag on the recorded FSI2 kinematics — fsi2_overset_dump_centreline (rtx-fsi) exports the saved instants' wetted-node d/ḋ as a centreline per knot (clamp + 70 columns); tests/embedded3_flag_kinematics (cubic Hermite through the knots with their velocities, t = 0 at RTX_E3_FLAG_KIN_T0, a 0.5 s ramp from the undeflected line, arc-length interpolation along the stations) drives both the 3D/slab flag (RTX_E3_FLAG_KINEMATICS, both in-plane surface-velocity components, the record's period) and the 2D reference; the analytic paths unchanged (same tuples)
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-20 21:27:26 -05:00

720 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! P5 (`docs/overset_metal_campaign.md` §5.12): TurekHron FSI2 with the
//! fluid on the OVERSET — the body-fitted patch following the flag. The
//! structure, coupling and acceptance are the FSI2 harness's; the fluid
//! side is `fsi2_harness::overset`. Defaults are the P5-1 gate run
//! (ny = 41, s = 1, a short march); `RTX_FSI2O_*` knobs as the harness's.
//! Reference (TurekHron FSI2): uy(A) 1.23 ± 80.6 mm at 2.0 Hz, ux(A)
//! 14.58 ± 12.44 mm, drag 208.83 ± 73.75, lift 0.88 ± 234.2.
mod fsi2_harness;
use fsi2_harness::overset_march::{OversetMarchConfig, run_march_overset};
use fsi2_harness::{FSI2, FSI3, case_from_env};
/// The benchmark's reference cycle (FEATFLOW's tables, level 4 — FSI2 at
/// Δt = 0.01: uy 1.24 ± 81.7 mm at 1.93 Hz, ux 14.87 ± 12.73, drag 215.18 ±
/// 77.78, lift 0.87 ± 238.0; FSI3 at Δt = 0.0005: uy 1.45 ± 34.90 at 5.46 Hz,
/// ux 2.86 ± 2.70, drag 460.2 ± 27.47, lift 2.37 ± 153.75;
/// `docs/research_sweep_2026-09.md` §1. The 2006 paper's FSI2 row is level 2
/// (80.6 mm, drag 208.83) with the frequency rounded to 2.0).
struct Refs {
uy_mean: f64,
uy_amp: f64,
uy_freq: f64,
ux_mean: f64,
ux_amp: f64,
drag_mean: f64,
drag_amp: f64,
lift_mean: f64,
lift_amp: f64,
}
fn refs(case: &str) -> Refs {
if case == "FSI3" {
Refs {
uy_mean: 1.45e-3,
uy_amp: 34.90e-3,
uy_freq: 5.46,
ux_mean: -2.86e-3,
ux_amp: 2.70e-3,
drag_mean: 460.2,
drag_amp: 27.47,
lift_mean: 2.37,
lift_amp: 153.75,
}
} else {
Refs {
uy_mean: 1.24e-3,
uy_amp: 81.7e-3,
uy_freq: 1.93,
ux_mean: -14.87e-3,
ux_amp: 12.73e-3,
drag_mean: 215.18,
drag_amp: 77.78,
lift_mean: 0.87,
lift_amp: 238.0,
}
}
}
/// `RTX_FSI2O_AUDIT=dir`: the solver-metric chain on every saved instant
/// under `dir` (`inst_*`, from `RTX_FSI2O_SAVE_EVERY`), no march.
#[test]
fn fsi2_overset_audit_of_saved_instants() {
let Ok(dir) = std::env::var("RTX_FSI2O_AUDIT") else {
return;
};
let ny = std::env::var("RTX_FSI2O_NY")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(41);
let flag_nx = std::env::var("RTX_FSI2O_FLAG_NX")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(35);
let case = case_from_env("FSI2O", FSI2);
let mut dirs: Vec<_> = std::fs::read_dir(&dir)
.expect("audit dir")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("inst_"))
})
.collect();
dirs.sort();
// `RTX_FSI2O_AUDIT_ONLY=a,b,c`: only the named instants.
let only: Option<Vec<String>> = std::env::var("RTX_FSI2O_AUDIT_ONLY")
.ok()
.map(|v| v.split(',').map(|s| s.trim().to_string()).collect());
for d in dirs {
if let Some(only) = &only {
let name = d.file_name().unwrap().to_string_lossy().to_string();
if !only.iter().any(|o| name.contains(o.as_str())) {
continue;
}
}
let (fluid, dvec, t) =
fsi2_harness::overset::OversetFluid::from_instant(case, ny, flag_nx, &d)
.expect("instant");
let tip: f64 = dvec.iter().map(|v| v * v).sum::<f64>().sqrt();
println!(
" AUDIT {} t = {t:.4} |d| {tip:.3e}: {}",
d.file_name().unwrap().to_string_lossy(),
fluid.chain_line()
);
// `RTX_FSI2O_AUDIT_FACES=1`: the wall load by region (P5-3).
if std::env::var("RTX_FSI2O_AUDIT_FACES").is_ok() {
let (regions, level) = fluid.wall_regions(&dvec);
let uy_tip = dvec[2 * fluid.interface.tip[fluid.interface.tip.len() / 2] + 1];
let mut line =
format!(" REGIONS t = {t:.4} tip uy {uy_tip:+.4}: p level {level:+.1} Pa |");
for (name, n, len, fx, fy, tn_mean, tn_min, tn_max) in regions {
line += &format!(
" {name}: {n} faces {len:.3} m, drag {fx:+.1}, lift {fy:+.1}, t_n mean {tn_mean:+.0} [{tn_min:+.0}, {tn_max:+.0}] Pa |"
);
}
println!("{line}");
}
}
}
#[test]
fn fsi2_on_the_overset() {
let config = OversetMarchConfig::from_env(
"FSI2O",
OversetMarchConfig {
ny: 41,
flag_nx: 35,
t_release: 6.0,
t_end: 7.0,
subcycle: 1,
tol_floor: 2e-4,
rtol: 1e-2,
stall_accept: 5.0,
max_subiterations: 12,
coupler: "aitken".into(),
reuse: 2,
initial_relaxation: 0.5,
c1_interface: false,
predictor: "structure".into(),
sweeps: 100,
max_rounds: 3,
csv_path: None,
trace_steps: 0,
},
);
// `RTX_FSI2O_CASE=FSI3` runs the FSI3 constants (Re 200, ρ_s = ρ_f, E 5.6e6).
let base = if std::env::var("RTX_FSI2O_CASE").as_deref() == Ok("FSI3") {
FSI3
} else {
FSI2
};
let case = case_from_env("FSI2O", base);
let rf = refs(case.name);
let (cname, ruf, rdm, rda, rlm, rla) = (
case.name,
rf.uy_freq,
rf.drag_mean,
rf.drag_amp,
rf.lift_mean,
rf.lift_amp,
);
let r = run_march_overset(case, &config);
let m = &r.result;
let w = m.window(3.0);
println!(
" {cname} OVERSET (ny = {}, flag {}x2 Quad8, dt = {:.2e}, s = {}, sweeps {}, rounds cap {}, {}): coupled {} steps in {:.0} s wall; {:.1} subit/step (max {}); {} stalled, {} retries (worst residual {:.2e}); conservation {:.2e}; {} wall faces; Newton rescues {:?}; rounds mean {:.2}; reclassified/step {:.2} (fresh {:.2}); patch regenerations {} in {:.0} s; fluid {:.0} s, structure {:.0} s; death {:?}\n measured over [{:.1}, {:.1}] s: uy(A) = {:.3} ± {:.3} mm (ref {:.2} ± {:.1}), ux(A) = {:.3} ± {:.3} mm (ref {:.2} ± {:.2}), f = {:?} Hz (ref {ruf}); drag {:.2} ± {:.2} (ref {rdm} ± {rda}), lift {:.2} ± {:.2} (ref {rlm} ± {rla}); onset amp {:.3e}{:.3e} m; rigid drag {:.2}",
config.ny,
config.flag_nx,
m.dt,
config.subcycle,
config.sweeps,
config.max_rounds,
config.coupler,
m.coupled_steps,
m.elapsed,
m.mean_subiterations,
m.max_subiterations,
m.stalled_steps,
m.retried_steps,
m.worst_stall,
m.worst_conservation,
r.faces_used,
m.newton_rescues,
r.rounds_mean,
r.reclassified_mean,
r.fresh_mean,
r.regen_count,
r.regen_seconds,
r.fluid_seconds,
r.structure_seconds,
r.death,
w.t_start,
config.t_end,
w.uy_mid * 1e3,
w.uy_amp * 1e3,
rf.uy_mean * 1e3,
rf.uy_amp * 1e3,
w.ux_mid * 1e3,
w.ux_amp * 1e3,
rf.ux_mean * 1e3,
rf.ux_amp * 1e3,
w.frequency,
w.drag_mid,
w.drag_amp,
w.lift_mid,
w.lift_amp,
w.amp_early,
w.amp_late,
m.rigid_drag
);
assert!(m.final_state_finite, "the flag's state is not finite");
assert!(r.death.is_none(), "the coupling died: {:?}", r.death);
}
/// `RTX_FSI2O_PROBE_INSTANT=dir`: the P5-2 warm replica died at t = 10.99
/// s (tip 35 mm) with "acceptor p donor cell not active". From the
/// instant, extrapolate the interface along its velocity and find where
/// the overlap first refuses — with the warm chain from the instant's
/// mesh and with cold builds — reporting the acceptor, its donor cell's
/// class, and the patch's thickness there.
#[test]
fn fsi2_overset_probe_death_from_instant() {
use rtx_cfd::mesh::patch_gen::{
cylinder_flag_patch_deformed, cylinder_flag_patch_deformed_from,
};
use rtx_cfd::solvers::incompressible::{CellClass, OverlapMap};
let Ok(dir) = std::env::var("RTX_FSI2O_PROBE_INSTANT") else {
return;
};
let dir = std::path::Path::new(&dir);
let case = case_from_env("FSI2O", FSI2);
let (fluid, d, t) =
fsi2_harness::overset::OversetFluid::from_instant(case, 41, 35, dir).expect("instant");
let dd: Vec<f64> = {
let bytes = std::fs::read(dir.join("patch_dd.bin")).expect("dd");
bytes
.chunks_exact(8)
.map(|c| f64::from_le_bytes(c.try_into().unwrap()))
.collect()
};
let h = fluid.h;
let nx = fluid.nx;
let tip_y = d[2 * fluid.interface.tip[fluid.interface.tip.len() / 2] + 1];
println!(
" instant t = {t:.4}: tip uy {tip_y:+.4} m, hole {} fringe {} acceptors {}",
fluid.solver.overlap().hole_cells(),
fluid.solver.overlap().fringe_count(),
fluid.solver.overlap().acceptors.len()
);
let mut warm_prev = fluid.solver.patch().mesh().clone();
for k in 0..=12 {
let tau = 0.005 * k as f64;
let dk: Vec<f64> = d.iter().zip(&dd).map(|(a, v)| a + v * tau).collect();
let edges = fluid.interface.edges(&dk);
let fillet = 0.5 * 0.41 / 41.0;
let cold = cylinder_flag_patch_deformed(
[0.2, 0.2],
0.05,
0.01,
&edges,
0.6,
h,
fillet,
6.0 * h,
12,
4.0,
100,
)
.expect("cold");
let warm = cylinder_flag_patch_deformed_from(
Some(&warm_prev),
[0.2, 0.2],
0.05,
0.01,
&edges,
0.6,
h,
fillet,
6.0 * h,
12,
4.0,
20,
)
.expect("warm");
let tip_now = dk[2 * fluid.interface.tip[fluid.interface.tip.len() / 2] + 1];
for (name, mesh) in [("cold", &cold.0), ("warm", &warm.0)] {
let valid = mesh.validate(80.0).err();
let r = OverlapMap::build(mesh, nx, 41, h, h, 4);
match r {
Ok(map) => println!(
" τ = {tau:.3} tip {tip_now:+.4}: {name} valid {:?}, overlap ok (hole {} fringe {})",
valid.is_none(),
map.hole_cells(),
map.fringe_count()
),
Err(e) => {
let msg = format!("{e:?}");
// The failing acceptor's geometry, and the overlap depth
// that would build.
let mut extra = String::new();
if let Some(a0) = msg.find("acceptor ") {
let k: usize = msg[a0 + 9..].split(' ').next().unwrap().parse().unwrap();
let c = mesh.cell(mesh.nn() - 1, k);
let ac = mesh.centre(c);
let inner = mesh.node_xy(mesh.node(0, k));
let outer = mesh.node_xy(mesh.node(mesh.nn(), k));
let r8 = mesh.node_xy(mesh.node(8, k));
let thick =
((outer[0] - inner[0]).powi(2) + (outer[1] - inner[1]).powi(2)).sqrt();
let hole_depth =
((r8[0] - inner[0]).powi(2) + (r8[1] - inner[1]).powi(2)).sqrt();
let mut rows_ok = Vec::new();
for rows in [5usize, 6, 7, 8] {
if OverlapMap::build(mesh, nx, 41, h, h, rows).is_ok() {
rows_ok.push(rows);
}
}
extra = format!(
" — acceptor {k} centre ({:.3}, {:.3}); its ray: wall node ({:.3}, {:.3}), outer ({:.3}, {:.3}), thickness {:.2} h, hole depth {:.2} h, band {:.2} h; overlap_rows that build: {rows_ok:?}",
ac[0],
ac[1],
inner[0],
inner[1],
outer[0],
outer[1],
thick / h,
hole_depth / h,
(thick - hole_depth) / h
);
}
if let Some(i0) = msg.find("cell (") {
let coords: Vec<usize> = msg[i0 + 6..]
.split(')')
.next()
.unwrap()
.split(',')
.map(|s| s.trim().parse().unwrap())
.collect();
let (j, i) = (coords[0], coords[1]);
let (x, y) = ((i as f64 + 0.5) * h, (j as f64 + 0.5) * h);
// The nearest inner-ring node and the patch thickness along its ray.
let mut best = (f64::INFINITY, 0usize);
for s in 0..=mesh.ns() {
let p = mesh.node_xy(mesh.node(0, s));
let dd2 = (p[0] - x).powi(2) + (p[1] - y).powi(2);
if dd2 < best.0 {
best = (dd2, s);
}
}
let s = best.1;
let inner = mesh.node_xy(mesh.node(0, s));
let outer = mesh.node_xy(mesh.node(mesh.nn(), s));
let r8 = mesh.node_xy(mesh.node(8, s));
let thick =
((outer[0] - inner[0]).powi(2) + (outer[1] - inner[1]).powi(2)).sqrt();
let hole_depth =
((r8[0] - inner[0]).powi(2) + (r8[1] - inner[1]).powi(2)).sqrt();
extra += &format!(
" — cell ({j}, {i}) at ({x:.3}, {y:.3}); nearest wall node s = {s} at ({:.3}, {:.3}); ray thickness {:.3} h, hole depth (row 8) {:.3} h, overlap band {:.3} h; outer node ({:.3}, {:.3})",
inner[0],
inner[1],
thick / h,
hole_depth / h,
(thick - hole_depth) / h,
outer[0],
outer[1]
);
let _ = CellClass::Hole;
}
println!(
" τ = {tau:.3} tip {tip_now:+.4}: {name} valid {:?}, overlap REFUSED: {msg}{extra}",
valid.is_none()
);
}
}
}
warm_prev = warm.0;
}
}
/// The replay's Hermite interpolant passes through every knot with its
/// saved velocity, and its prescribed velocity is the interpolant's exact
/// derivative between knots (the GCL's premise for a prescribed wall).
#[test]
fn replay_passes_through_knots_with_their_velocities() {
use fsi2_harness::replay::{Replay, ramp};
let f = |t: f64| vec![(3.0 * t).sin(), 0.5 * t * t];
let df = |t: f64| vec![3.0 * (3.0 * t).cos(), t];
let knots: Vec<(f64, Vec<f64>, Vec<f64>)> = [0.0, 0.4, 0.9, 1.5]
.iter()
.map(|&t| (t, f(t), df(t)))
.collect();
let r = Replay::from_knots(knots);
for &t in &[0.0, 0.4, 0.9, 1.5] {
let (d, v) = r.at(t);
for i in 0..2 {
assert!((d[i] - f(t)[i]).abs() < 1e-12, "d at knot {t}");
assert!((v[i] - df(t)[i]).abs() < 1e-12, "ḋ at knot {t}");
}
}
let eps = 1e-6;
for &t in &[0.1, 0.55, 1.2] {
let (_, v) = r.at(t);
let (dp, _) = r.at(t + eps);
let (dm, _) = r.at(t - eps);
for i in 0..2 {
let fd = (dp[i] - dm[i]) / (2.0 * eps);
assert!(
(v[i] - fd).abs() < 1e-6,
"ḋ is the derivative at {t}: {} vs {fd}",
v[i]
);
}
}
assert_eq!(ramp(-1.0, 0.0, 0.5), (0.0, 0.0));
assert_eq!(ramp(0.7, 0.0, 0.5), (1.0, 0.0));
let (r0, r1) = ramp(0.25, 0.0, 0.5);
assert!((r0 - 0.5).abs() < 1e-12 && r1 > 0.0);
}
/// `RTX_FSI2O_REPLAY=dir`: the P5-3 energy discriminator — the saved
/// instants' kinematics (`d`, `ḋ` every N steps of a march) replayed on
/// the fluid ALONE at `RTX_FSI2O_NY`, from `RTX_FSI2O_LOAD`'s rigid state,
/// from `RTX_FSI2O_REPLAY_T0` (9.0) with a `RTX_FSI2O_REPLAY_RAMP` (0.5 s)
/// ramp to `RTX_FSI2O_T_END` (16). Per step the CSV (`RTX_FSI2O_CSV`)
/// records t, tip uy, drag, lift, the fluid's power on the flag Σ F·ḋ,
/// the wall net flux and the polygon area rate; the summary reads the
/// window [13, 16]: mean power, lift swing, drag median.
#[test]
fn fsi2_overset_prescribed_motion() {
use fsi2_harness::overset::OversetFluid;
use fsi2_harness::replay::{Replay, ramp};
let Ok(replay_dir) = std::env::var("RTX_FSI2O_REPLAY") else {
println!(" RTX_FSI2O_REPLAY unset — nothing to replay");
return;
};
let env_f = |k: &str, d: f64| {
std::env::var(k)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(d)
};
let ny = env_f("RTX_FSI2O_NY", 41.0) as usize;
let t0 = env_f("RTX_FSI2O_REPLAY_T0", 9.0);
let ramp_w = env_f("RTX_FSI2O_REPLAY_RAMP", 0.5);
let t_end = env_f("RTX_FSI2O_T_END", 16.0);
let case = case_from_env("FSI2O", FSI2);
let replay = Replay::load(&replay_dir);
let (r0, r1) = replay.span();
println!(
" replay: {} instants over [{r0:.4}, {r1:.4}] s from {replay_dir}",
replay.times.len()
);
let t_end = t_end.min(r1);
assert!(t0 >= r0, "replay starts at {r0}, after t0 = {t0}");
// `RTX_FSI2O_MAX_ROUNDS` (3): the Schwarz rounds cap — the coupled march's
// 3-round cap is load-bearing for the coupling's stability (every tighter
// cap dies at release), so the fluid side of that question is measured here.
let max_rounds = env_f("RTX_FSI2O_MAX_ROUNDS", 3.0) as usize;
let mut fluid = OversetFluid::build_case(case, ny, 35, 100, max_rounds).expect("overset fluid");
let n = 2 * fluid.interface.wetted.len();
assert_eq!(
replay.d[0].len(),
n,
"the instants' d does not match this interface"
);
let load_dir = std::env::var("RTX_FSI2O_LOAD").expect("RTX_FSI2O_LOAD (the rigid state)");
fluid.load(&load_dir).expect("load");
fluid.commit_base();
fluid.solver.set_time(t0);
let dt = fluid.dt_fluid;
let (d_rigid, l_rigid) = fluid.measure_force();
println!(
" PRESCRIBED ny = {ny}: dt {dt:.3e}, rigid drag {d_rigid:.2} lift {l_rigid:.2}, replay from t = {t0} (ramp {ramp_w} s) to {t_end}; patch offset {} h × {} rows, patch convection {:?}, bg convection {:?}, patch stretch {}, fillet {} m, tip corner {} m, rounds cap {max_rounds}",
std::env::var("RTX_FSI2O_PATCH_OFFSET").unwrap_or_else(|_| "6".into()),
std::env::var("RTX_FSI2O_PATCH_ROWS").unwrap_or_else(|_| "12".into()),
fsi2_harness::overset::patch_convection(),
fsi2_harness::overset::bg_convection(),
fsi2_harness::overset::patch_stretch(),
fsi2_harness::overset::fillet(),
fsi2_harness::overset::tip_corner(),
);
let mut csv = std::env::var("RTX_FSI2O_CSV").ok().map(|p| {
use std::io::Write as _;
let mut f = std::fs::File::create(p).expect("csv");
writeln!(
f,
"t,uy_tip,drag,lift,power,wall_flux,area_rate,p_faces,p_normal,p_tangential,p_root,p_mid,p_tip,rounds_max,schwarz_ok,bg_residual,patch_div,defect_patch,defect_bg,reclassified,e_stamp,e_reclass"
)
.unwrap();
f
});
let tip = fluid.interface.tip.clone();
let start = std::time::Instant::now();
let mut prev_area = fluid.shared.read().unwrap().area();
let mut window: Vec<(f64, f64, f64, f64)> = Vec::new();
let mut faces_window: Vec<(f64, f64, f64, [f64; 3])> = Vec::new();
let save_at: Vec<f64> = std::env::var("RTX_FSI2O_SAVE_AT")
.ok()
.map(|v| v.split(',').filter_map(|x| x.trim().parse().ok()).collect())
.unwrap_or_default();
let mut step = 0usize;
let mut t_now = t0;
while t_now < t_end - 0.5 * dt {
let t_new = t_now + dt;
let (d, v) = replay.at(t_new.min(r1));
let (rr, rd) = ramp(t_new, t0, ramp_w);
let d_r: Vec<f64> = d.iter().map(|x| rr * x).collect();
let v_r: Vec<f64> = v.iter().zip(&d).map(|(vi, di)| rr * vi + rd * di).collect();
if let Err(e) = fluid.set_geometry(&d_r, &v_r) {
panic!("set_geometry died at step {step}, t = {t_new:.4}: {e:?}");
}
let (rounds_max, schwarz_ok, bg_res, patch_div, reclassified, e_stamp, e_reclass) =
match fluid.step() {
Ok(r) => (
r.rounds.iter().copied().max().unwrap_or(0),
r.schwarz_converged,
r.background_residual,
r.patch_max_divergence,
r.reclassified_cells,
r.stamp_energy,
r.reclass_energy,
),
Err(e) => panic!("fluid step died at step {step}, t = {t_new:.4}: {e:?}"),
};
let (defect_patch, defect_bg, _) = fluid.last_defects.get();
fluid.commit_base();
let (nodal, _, _) = fluid.sample_load(&d_r);
let power: f64 = nodal
.iter()
.enumerate()
.map(|(k, (_, f))| f.x * v_r[2 * k] + f.y * v_r[2 * k + 1])
.sum();
// The same power face by face: normal vs tangential traction, and
// the flag's root / middle / tip thirds (x < 0.3667 / < 0.4833 /
// the rest, the tip arc included); the cylinder's faces excluded.
let (mut p_faces, mut p_normal, mut p_tangential) = (0.0, 0.0, 0.0);
let mut p_thirds = [0.0; 3];
{
let wall = fluid.shared.read().unwrap();
for (centre, normal, len, traction) in fluid.solver.patch().wall_tractions(
&fluid.field.patch,
rtx_cfd::mesh::PatchSide::Inner,
fluid.solver.time(),
) {
let on_cylinder =
((centre[0] - 0.2).powi(2) + (centre[1] - 0.2).powi(2)).sqrt() < 0.05 + 1e-9;
if on_cylinder {
continue;
}
let (u, v) = wall.velocity_at(centre[0], centre[1]);
let tn = traction[0] * normal[0] + traction[1] * normal[1];
let vn = u * normal[0] + v * normal[1];
let pf = (traction[0] * u + traction[1] * v) * len;
let pn = tn * vn * len;
p_faces += pf;
p_normal += pn;
p_tangential += pf - pn;
let third = if centre[0] < 0.3667 {
0
} else if centre[0] < 0.4833 {
1
} else {
2
};
p_thirds[third] += pf;
}
}
let (drag, lift) = fluid.measure_force();
let (_, wall_flux, _, _, poly_area) = fluid.level_and_wall_flux();
// `RTX_FSI2O_SAVE=dir` + `RTX_FSI2O_SAVE_EVERY=N` or
// `RTX_FSI2O_SAVE_AT=t1,t2,…` (the step that first reaches each
// time): replay instants for the per-region pressure audit at one
// physical instant across h.
if let Ok(dir) = std::env::var("RTX_FSI2O_SAVE") {
let hit = save_at.iter().any(|&ts| t_now < ts && ts <= t_new + 1e-12);
if hit {
fluid
.save_instant(&dir, step + 1, &d_r, &v_r)
.expect("save instant");
}
}
if let (Ok(dir), Some(every)) = (
std::env::var("RTX_FSI2O_SAVE"),
std::env::var("RTX_FSI2O_SAVE_EVERY")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.filter(|&n| n > 0),
) {
if (step + 1) % every == 0 {
fluid
.save_instant(&dir, step + 1, &d_r, &v_r)
.expect("save instant");
}
}
let area_rate = (poly_area - prev_area) / dt;
prev_area = poly_area;
let uy_tip = tip.iter().map(|&k| d_r[2 * k + 1]).sum::<f64>() / tip.len().max(1) as f64;
if let Some(f) = csv.as_mut() {
use std::io::Write as _;
writeln!(
f,
"{t_new:.6},{uy_tip:.6e},{drag:.4},{lift:.4},{power:.6e},{wall_flux:.6e},{area_rate:.6e},{p_faces:.6e},{p_normal:.6e},{p_tangential:.6e},{:.6e},{:.6e},{:.6e},{rounds_max},{},{bg_res:.3e},{patch_div:.3e},{defect_patch:.3e},{defect_bg:.3e},{reclassified},{e_stamp:.4e},{e_reclass:.4e}",
p_thirds[0], p_thirds[1], p_thirds[2], schwarz_ok as u8
)
.unwrap();
}
if t_new >= 13.0 {
window.push((t_new, drag, lift, power));
faces_window.push((p_faces, p_normal, p_tangential, p_thirds));
}
step += 1;
if step % 500 == 0 {
println!(
" t = {t_new:.3} s ({step} steps): uy_tip {uy_tip:+.4}, drag {drag:.1} lift {lift:.1} power {power:+.2e} W/m, wall flux {wall_flux:+.2e} vs area rate {area_rate:+.2e}, {:.0} s wall",
start.elapsed().as_secs_f64()
);
}
t_now = t_new;
}
let n_w = window.len().max(1) as f64;
let mean_power = window.iter().map(|w| w.3).sum::<f64>() / n_w;
let (lmin, lmax) = window
.iter()
.fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), w| {
(a.min(w.2), b.max(w.2))
});
let mut drags: Vec<f64> = window.iter().map(|w| w.1).collect();
drags.sort_by(|a, b| a.partial_cmp(b).unwrap());
let drag_med = drags.get(drags.len() / 2).copied().unwrap_or(f64::NAN);
println!(
" PRESCRIBED ny = {ny} over [13, 16] s ({} samples): mean fluid power on the flag {mean_power:+.3} W/m, lift swing [{lmin:.1}, {lmax:.1}] (± {:.1}), drag median {drag_med:.1}; {step} steps in {:.0} s wall",
window.len(),
0.5 * (lmax - lmin),
start.elapsed().as_secs_f64()
);
let nf = faces_window.len().max(1) as f64;
let mean = |f: &dyn Fn(&(f64, f64, f64, [f64; 3])) -> f64| {
faces_window.iter().map(f).sum::<f64>() / nf
};
println!(
" PRESCRIBED ny = {ny} power by face over [13, 16] s: faces {:+.3} W/m (nodal {mean_power:+.3}) = normal {:+.3} + tangential {:+.3}; root {:+.3} / middle {:+.3} / tip {:+.3}",
mean(&|w| w.0),
mean(&|w| w.1),
mean(&|w| w.2),
mean(&|w| w.3[0]),
mean(&|w| w.3[1]),
mean(&|w| w.3[2])
);
}
/// S2-9: the recorded FSI2 kinematics as a CENTRELINE per instant for the
/// 3D flag (`RTX_FSI2O_REPLAY=dir`, `RTX_FSI2O_NY`, `RTX_FSI2O_CENTRELINE_CSV=out`):
/// per knot, t and per station (the clamp, then each bottom/top column
/// root → tip) the deformed midpoint (x, y) and its velocity (vx, vy).
#[test]
#[ignore = "S2-9 export: needs RTX_FSI2O_REPLAY (the saved instants)"]
fn fsi2_overset_dump_centreline() {
use fsi2_harness::overset::OversetFluid;
use fsi2_harness::replay::Replay;
use std::io::Write as _;
let Ok(replay_dir) = std::env::var("RTX_FSI2O_REPLAY") else {
println!(" RTX_FSI2O_REPLAY unset");
return;
};
let out = std::env::var("RTX_FSI2O_CENTRELINE_CSV").expect("RTX_FSI2O_CENTRELINE_CSV");
let ny: usize = std::env::var("RTX_FSI2O_NY").ok().and_then(|v| v.parse().ok()).unwrap_or(62);
let case = case_from_env("FSI2O", FSI2);
let fluid = OversetFluid::build_case(case, ny, 35, 100, 3).expect("overset fluid");
let itf = &fluid.interface;
let replay = Replay::load(&replay_dir);
let n = 2 * itf.wetted.len();
assert_eq!(replay.d[0].len(), n, "the instants' d does not match this interface");
// Columns root → tip: bottom is root → tip, top is tip → root.
let cols: Vec<(usize, usize)> = itf
.bottom
.iter()
.copied()
.zip(itf.top.iter().rev().copied())
.collect();
for &(b, t) in &cols {
assert!((itf.reference[b].0 - itf.reference[t].0).abs() < 1e-9, "bottom / top columns disagree");
}
let mut f = std::fs::File::create(&out).expect("csv");
write!(f, "t").unwrap();
for i in 0..=cols.len() {
write!(f, ",x{i},y{i},vx{i},vy{i}").unwrap();
}
writeln!(f).unwrap();
let clamp = (fsi2_harness::FLAG_X0, 0.5 * (fsi2_harness::FLAG_Y0 + fsi2_harness::FLAG_Y1));
for (k, &tk) in replay.times.iter().enumerate() {
let (d, dd) = (&replay.d[k], &replay.dd[k]);
write!(f, "{tk:.6}").unwrap();
write!(f, ",{:.9},{:.9},0,0", clamp.0, clamp.1).unwrap();
for &(b, t) in &cols {
let (xb, yb) = (itf.reference[b].0 + d[2 * b], itf.reference[b].1 + d[2 * b + 1]);
let (xt, yt) = (itf.reference[t].0 + d[2 * t], itf.reference[t].1 + d[2 * t + 1]);
let (vxb, vyb, vxt, vyt) = (dd[2 * b], dd[2 * b + 1], dd[2 * t], dd[2 * t + 1]);
write!(
f,
",{:.9},{:.9},{:.9},{:.9}",
0.5 * (xb + xt),
0.5 * (yb + yt),
0.5 * (vxb + vxt),
0.5 * (vyb + vyt)
)
.unwrap();
}
writeln!(f).unwrap();
}
println!(
" centreline: {} instants over [{:.4}, {:.4}] s, {} stations → {out}",
replay.times.len(),
replay.times[0],
replay.times.last().unwrap(),
cols.len() + 1
);
}