P5-3 energy discriminator: prescribed motion on the overset fluid alone — fsi2_harness/replay.rs (a march's saved instants as cubic-Hermite kinematics with the saved velocities as slopes, the prescribed wall velocity the interpolant's exact derivative; a smooth ramp) with a knot/derivative pin, and fsi2_overset_prescribed_motion (RTX_FSI2O_REPLAY=dir: replay from RTX_FSI2O_REPLAY_T0 on RTX_FSI2O_LOAD's rigid state at any ny; per-step CSV of tip uy, drag, lift, the fluid's power on the flag Σ F·ḋ, wall flux vs area rate; window summary) — at a fixed motion, does the fluid's work per cycle grow with h the way the coupled amplitude ladder (91.2 → 94.8 → 98.0 mm) does?
CI / WASM Build + Size Check (push) Canceled after 0s
CI / Distributed Training Tests (push) Canceled after 0s
CI / CI Success (push) Canceled after 0s
Documentation / Build API Documentation (push) Canceled after 0s
Documentation / Build User Guide (push) Canceled after 0s
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Build CPU-Only (Explicit) (push) Canceled after 0s
CI / Python Bindings (maturin) (macos-latest) (push) Canceled after 0s
CI / Test (macos-latest) (push) Canceled after 0s
CI / Python Bindings (maturin) (ubuntu-latest) (push) Canceled after 0s
CI / Build (macos-latest) (push) Canceled after 0s
CI / Build (ubuntu-latest) (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

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 13:13:59 -07:00
co-authored by Claude Fable 5.1
parent 7e13d14e96
commit f61c427b6d
3 changed files with 309 additions and 0 deletions
@@ -312,3 +312,166 @@ fn fsi2_overset_probe_death_from_instant() {
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::{ramp, Replay};
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::{ramp, Replay};
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}");
let mut fluid = OversetFluid::build_case(case, ny, 35, 100, 3).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",
std::env::var("RTX_FSI2O_PATCH_OFFSET").unwrap_or_else(|_| "6".into()),
std::env::var("RTX_FSI2O_PATCH_ROWS").unwrap_or_else(|_| "12".into())
);
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").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 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:?}");
}
if let Err(e) = fluid.step() {
panic!("fluid step died at step {step}, t = {t_new:.4}: {e:?}");
}
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();
let (drag, lift) = fluid.measure_force();
let (_, wall_flux, _, _, poly_area) = fluid.level_and_wall_flux();
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}").unwrap();
}
if t_new >= 13.0 {
window.push((t_new, drag, lift, power));
}
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()
);
}