Files
rustytorch/crates/specialized/rtx-fsi/tests/turek_hron_fsi2_overset.rs
T
Omar SobhandClaude Fable 5.1 e54729241d
CI / Format Check (push) Failing after 5s
Documentation / Build User Guide (push) Successful in 0s
CI / Build (ubuntu-latest) (push) Failing after 6s
CI / Build CPU-Only (Explicit) (push) Failing after 53s
Performance Benchmarks / Run Benchmarks (push) Failing after 5s
Documentation / Build API Documentation (push) Failing after 13s
CI / Clippy Check (push) Failing after 23s
CI / Build (macos-latest) (push) Canceled after 0s
CI / Test (macos-latest) (push) Canceled after 0s
CI / Test (ubuntu-latest) (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
fsi2 overset harness: RTX_FSI2O_CASE=FSI3 (the FSI3 constants with FEATFLOW level-4 references in the summary), RTX_FSI2O_SAVE_FROM (instants only from a time on — a dense last period for the viewer)
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LzcjQX7tvgn87CQCyg9Cfr
2026-09-14 06:36:45 -05:00

652 lines
26 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])
);
}