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
@@ -13,6 +13,7 @@
pub mod march;
pub mod overset;
pub mod overset_march;
pub mod replay;
pub mod rescue;
use std::cell::Cell;
@@ -0,0 +1,145 @@
//! Prescribed motion from saved instants — P5-3's energy discriminator.
//!
//! The FSI2 march saves the wetted interface's displacement `d` and
//! velocity `ḋ` every N steps (`OversetFluid::save_instant`). Replaying
//! that kinematics on the fluid ALONE, at any resolution, asks the
//! fluid a question the coupled march cannot: at a FIXED motion, how do
//! the fluid's work on the flag per cycle and the lift swing change with
//! h? A converging fluid converges both; a fluid with an h-growing
//! spurious energy source grows the work — the coupled limit cycle's
//! amplitude ladder (91.2 → 94.8 → 98.0 mm at ny 41 → 62 → 82) in fluid
//! terms.
//!
//! Between knots the displacement is the cubic Hermite interpolant with
//! the SAVED velocities as slopes, and the prescribed velocity is that
//! interpolant's exact derivative — so the wall moves exactly as its
//! velocity says (the GCL's premise) and passes through every saved
//! state with its saved velocity.
/// The saved kinematics: knot times, `d` and `ḋ` at each knot.
pub struct Replay {
pub times: Vec<f64>,
pub d: Vec<Vec<f64>>,
pub dd: Vec<Vec<f64>>,
}
fn read_f64s(path: &std::path::Path) -> Vec<f64> {
let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("replay {}: {e}", path.display()));
bytes
.chunks_exact(8)
.map(|c| f64::from_le_bytes(c.try_into().expect("8 bytes")))
.collect()
}
impl Replay {
/// Every `inst_*` directory under `dir`, ordered by time.
pub fn load(dir: &str) -> Self {
let mut knots: Vec<(f64, Vec<f64>, Vec<f64>)> = Vec::new();
for entry in std::fs::read_dir(dir).expect("replay dir") {
let path = entry.expect("entry").path();
if !path.is_dir()
|| !path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("inst_"))
{
continue;
}
let meta = std::fs::read_to_string(path.join("meta.txt")).expect("meta");
let t: f64 = meta
.lines()
.find_map(|l| l.strip_prefix("t "))
.expect("t")
.trim()
.parse()
.expect("t value");
knots.push((
t,
read_f64s(&path.join("patch_d.bin")),
read_f64s(&path.join("patch_dd.bin")),
));
}
knots.sort_by(|a, b| a.0.partial_cmp(&b.0).expect("finite t"));
assert!(knots.len() >= 2, "replay needs at least two instants");
Self::from_knots(knots)
}
/// From explicit knots (tests).
pub fn from_knots(knots: Vec<(f64, Vec<f64>, Vec<f64>)>) -> Self {
let n = knots[0].1.len();
for k in &knots {
assert_eq!(k.1.len(), n, "instants differ in size");
assert_eq!(k.2.len(), n, "instants differ in size");
}
let mut r = Replay {
times: Vec::new(),
d: Vec::new(),
dd: Vec::new(),
};
for (t, d, dd) in knots {
r.times.push(t);
r.d.push(d);
r.dd.push(dd);
}
r
}
/// The replay's time span.
pub fn span(&self) -> (f64, f64) {
(self.times[0], *self.times.last().unwrap())
}
/// `(d, ḋ)` at `t` (inside the span): the cubic Hermite interpolant
/// through the bracketing knots and its exact time derivative.
pub fn at(&self, t: f64) -> (Vec<f64>, Vec<f64>) {
let (t0, t1) = self.span();
assert!(
t >= t0 - 1e-12 && t <= t1 + 1e-12,
"t = {t} outside [{t0}, {t1}]"
);
let k = match self.times.binary_search_by(|x| x.partial_cmp(&t).unwrap()) {
Ok(i) => i.min(self.times.len() - 2),
Err(i) => i.saturating_sub(1).min(self.times.len() - 2),
};
let h = self.times[k + 1] - self.times[k];
let s = ((t - self.times[k]) / h).clamp(0.0, 1.0);
let (s2, s3) = (s * s, s * s * s);
let (h00, h10, h01, h11) = (
2.0 * s3 - 3.0 * s2 + 1.0,
s3 - 2.0 * s2 + s,
-2.0 * s3 + 3.0 * s2,
s3 - s2,
);
let (g00, g10, g01, g11) = (
6.0 * s2 - 6.0 * s,
3.0 * s2 - 4.0 * s + 1.0,
-6.0 * s2 + 6.0 * s,
3.0 * s2 - 2.0 * s,
);
let (d0, d1, v0, v1) = (&self.d[k], &self.d[k + 1], &self.dd[k], &self.dd[k + 1]);
let n = d0.len();
let mut d = Vec::with_capacity(n);
let mut v = Vec::with_capacity(n);
for i in 0..n {
d.push(h00 * d0[i] + h10 * h * v0[i] + h01 * d1[i] + h11 * h * v1[i]);
v.push((g00 * d0[i] + g10 * h * v0[i] + g01 * d1[i] + g11 * h * v1[i]) / h);
}
(d, v)
}
}
/// A smooth ramp `r(t)` from 0 at `t0` to 1 at `t0 + width` (and its
/// derivative): the prescribed motion is `r·d`, its velocity `r·ḋ + ṙ·d`.
pub fn ramp(t: f64, t0: f64, width: f64) -> (f64, f64) {
if width <= 0.0 || t >= t0 + width {
return (1.0, 0.0);
}
if t <= t0 {
return (0.0, 0.0);
}
let x = std::f64::consts::PI * (t - t0) / width;
(
0.5 * (1.0 - x.cos()),
0.5 * std::f64::consts::PI / width * x.sin(),
)
}