Files
rustytorch/crates/specialized/rtx-fsi/tests/fsi2_embedded3_impulse.rs
T
Omar SobhandClaude Opus 5.5 74fac32a91 R8-e: the coupled 3D FSI's small-dt instability located in the load/kinematics transfer, not a lagged fluid response
- tests/fsi2_embedded3_impulse.rs (new, ignored, cuda): the added-mass timing instrument — the R8-a fluid
  with a prescribed centreline velocity step (a one-step acceleration delta), baseline and impulse passes
  from one device snapshot, the load difference per step. Pitch shape on the slab ny 62: generalized added
  mass 39.11 / 39.09 / 39.07 kg/m at dt x 1 / 0.75 / 0.5, the step after carries 1.7 / 1.3 / 0.9 % above
  the steady tail — the cut-cell fluid answers in the same step (the lagged-added-mass hypothesis refuted).
- fsi2_embedded3.rs: `RTX_E3FSI_LINE_CSV` (per-step centreline + mid/bottom/top y per station) showed the
  growing 2dt mode is a through-thickness mode — the edge nodes move against the mid-plane nodes (~4:1):
  the fluid reads the mid-plane, the loads land on the edges, so the added mass acts on that mode with the
  wrong sign (a negative effective added mass; the scalar Newmark model is unstable for it at omega dt
  below ~1-2 with gamma 0.7, stable at larger dt or gamma 0.9 — R8-a's observed pattern).
  `RTX_E3FSI_KIN=surface` (default off, byte-identical when unset): the fluid's centreline = the mean of
  the bottom and top edge nodes (the nodes the loads land on) — dt x 0.75 / 0.5 / 0.25 stable at gamma 0.7.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 22:11:31 -05:00

221 lines
7.8 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.
//! R8-e: the added-mass timing instrument for the coupled 3D FSI's small-dt
//! instability. The R8-a fluid (the embedded3 cut-cell device path, the
//! slab by default) with the flag's centreline PRESCRIBED: rigid to `T0`,
//! then from one snapshot two passes of `N` steps each — the baseline (the
//! flag at rest) and the impulse (the centreline's velocity steps from 0 to
//! `V` × shape at step `N_PRE`: its wall acceleration is a single-step
//! delta V/dt; `MODE=pulse` returns the velocity to 0 one step later, a
//! +V/dt, −V/dt pair). The difference of the two passes' loads, step by
//! step, is the fluid's response to the impulse: an added-mass response
//! lives in the impulse's own step; a lagged part shows one step later.
//!
//! Knobs `RTX_E3IMP_*`: `NY` (62), `NZ` (4), `T0` (0.2 s), `N_PRE` (4),
//! `N` (24), `V` (0.05 m/s at the tip), `SHAPE` (`pitch` about the root,
//! 1 at A; `heave`; `zig`, `tip`), `MODE` (`step`, `pulse`), `CSV` (per-step series).
//! The fluid's own knobs as the R8-a harness (`RTX_E3FSI_DT_SCALE`, …).
#![cfg(feature = "cuda")]
#[path = "fsi2_embedded3/fluid.rs"]
mod fluid;
#[path = "fsi2_embedded3/state.rs"]
mod state;
use std::io::Write as _;
use fluid::{CX, CY, Contribution, E3Fluid, HALF, Line, R_CYL};
pub fn env_f(name: &str, default: f64) -> f64 {
std::env::var(name)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(default)
}
const X0: f64 = 0.25;
const STATIONS: usize = 35;
const CHORD: f64 = 0.35;
fn line(t: f64, c: &[f64], v: &[f64]) -> Line {
Line {
t,
pts: (0..STATIONS)
.map(|k| [X0 + 0.01 * k as f64, 0.2 + c[k]])
.collect(),
vel: (0..STATIONS).map(|k| [0.0, v[k]]).collect(),
}
}
/// The flag's loads from the route's contributions (per unit span): fy on
/// the flag, fy by part, and the generalized force Σ fy φ(x) with the
/// prescribed shape's φ.
fn flag_loads(
contrib: &[Contribution],
pts: &[[f64; 2]],
width: f64,
phi: impl Fn(f64) -> f64,
) -> (f64, f64, [f64; 4]) {
let (mut fy, mut q, mut parts) = (0.0, 0.0, [0.0; 4]);
for &(pos, c, part, v) in contrib {
if c != 1 {
continue;
}
let (x, y) = (pos[0], pos[1]);
let mut best = f64::INFINITY;
for m in 0..pts.len() - 1 {
let (a, b) = (pts[m], pts[m + 1]);
let (ex, ey) = (b[0] - a[0], b[1] - a[1]);
let u = (((x - a[0]) * ex + (y - a[1]) * ey) / (ex * ex + ey * ey)).clamp(0.0, 1.0);
best = best.min(((x - a[0] - u * ex).powi(2) + (y - a[1] - u * ey).powi(2)).sqrt());
}
let d_cyl = ((x - CX).powi(2) + (y - CY).powi(2)).sqrt() - R_CYL;
if d_cyl < best - HALF {
continue;
}
let v = v / width;
fy += v;
q += v * phi(x);
parts[part] += v * phi(x);
}
(fy, q, parts)
}
#[test]
#[ignore = "R8-e: the added-mass timing instrument (GPU, a minute)"]
fn impulse_response() {
let ny = env_f("RTX_E3IMP_NY", 62.0) as usize;
let nz = env_f("RTX_E3IMP_NZ", 4.0) as usize;
let t0 = env_f("RTX_E3IMP_T0", 0.2);
let n_pre = env_f("RTX_E3IMP_N_PRE", 4.0) as usize;
let n = env_f("RTX_E3IMP_N", 24.0) as usize;
let vel = env_f("RTX_E3IMP_V", 0.05);
let shape = std::env::var("RTX_E3IMP_SHAPE").unwrap_or_else(|_| "pitch".into());
let mode = std::env::var("RTX_E3IMP_MODE").unwrap_or_else(|_| "step".into());
// The shape per station (`zig`: alternate stations ±, the root clamped;
// `tip`: the last 5 stations), linear between stations.
let phis: Vec<f64> = (0..STATIONS)
.map(|k| {
let x = X0 + 0.01 * k as f64;
match shape.as_str() {
"heave" => 1.0,
"zig" => {
if k == 0 {
0.0
} else if k % 2 == 0 {
1.0
} else {
-1.0
}
}
"tip" => {
if k + 5 >= STATIONS {
1.0
} else {
0.0
}
}
_ => ((x - X0) / CHORD).clamp(0.0, 1.0),
}
})
.collect();
let phi = |x: f64| -> f64 {
let s = ((x - X0) / 0.01).clamp(0.0, (STATIONS - 1) as f64);
let m = (s.floor() as usize).min(STATIONS - 2);
let u = s - m as f64;
(1.0 - u) * phis[m] + u * phis[m + 1]
};
let zero = vec![0.0; STATIONS];
let rest = line(0.0, &zero, &zero);
let mut fl = E3Fluid::build(ny, nz, 3.0, rest.clone(), None);
let dt = fl.dt;
let rigid = (t0 / dt).round() as usize;
for s in 0..rigid {
let r = fl.step();
assert!(r.final_residual.is_finite(), "rigid death at {s}");
}
let t_start = fl.time();
println!(
" R8-e impulse: ny {ny} nz {nz} dt {dt:.4e}, rigid {rigid} steps to t {t_start:.4}; shape {shape}, mode {mode}, V {vel} m/s (acceleration V/dt {:.2} m/s²) at step {n_pre} of {n}",
vel / dt
);
let snap = fl.snapshot();
// Per pass: per step (t, tip velocity, fy, Q, Q parts, lift total).
let mut rows: Vec<Vec<[f64; 9]>> = Vec::new();
for pass in 0..2 {
fl.restore(&snap);
let mut c = zero.clone();
let mut prev = rest.clone();
prev.t = t_start;
let mut out = Vec::new();
for s in 0..n {
let t_new = t_start + (s + 1) as f64 * dt;
// The step's centreline velocity (constant over the step).
let v_tip = if pass == 0 || s < n_pre {
0.0
} else if mode == "pulse" {
if s == n_pre { vel } else { 0.0 }
} else {
vel
};
let v: Vec<f64> = phis.iter().map(|p| p * v_tip).collect();
for k in 0..STATIONS {
c[k] += dt * v[k];
}
let next = line(t_new, &c, &v);
fl.set_lines(prev.clone(), next.clone());
let r = fl.step();
assert!(
r.final_residual.is_finite(),
"death at pass {pass} step {s}"
);
let (tot, contrib) = fl.loads();
let (fy, q, parts) = flag_loads(&contrib, &next.pts, fl.load_width, &phi);
out.push([
t_new, v_tip, fy, q, parts[0], parts[1], parts[2], parts[3], tot[1],
]);
prev = next;
}
rows.push(out);
}
let mut csv = std::env::var("RTX_E3IMP_CSV")
.ok()
.map(|p| std::fs::File::create(p).expect("csv"));
if let Some(f) = csv.as_mut() {
writeln!(
f,
"k,t,v_tip,fy0,q0,fy,q,dfy,dq,dq_p,dq_shear,dq_xdiff,dq_xconv,dlift"
)
.unwrap();
}
let q_imp = rows[1][n_pre][3] - rows[0][n_pre][3];
for s in 0..n {
let (a, b) = (&rows[0][s], &rows[1][s]);
let k = s as i64 - n_pre as i64;
let d = |i: usize| b[i] - a[i];
if let Some(f) = csv.as_mut() {
writeln!(
f,
"{k},{:.6},{:.4e},{:.6},{:.6},{:.6},{:.6},{:.6e},{:.6e},{:.6e},{:.6e},{:.6e},{:.6e},{:.6e}",
b[0], b[1], a[2], a[3], b[2], b[3], d(2), d(3), d(4), d(5), d(6), d(7), d(8)
)
.unwrap();
}
if (-1..=6).contains(&k) {
println!(
" k {k:+}: v_tip {:.3e} ΔQ {:+.5e} ({:+.4} of the impulse step) parts p {:+.4e} shear {:+.4e} xdiff {:+.4e} xconv {:+.4e} Δfy {:+.5e}",
b[1],
d(3),
d(3) / q_imp,
d(4),
d(5),
d(6),
d(7),
d(2)
);
}
}
println!(
" IMPULSE ΔQ(k=0) {q_imp:+.5e} N/m → generalized added mass −ΔQ·dt/V {:.4} kg/m",
-q_imp * dt / vel
);
}