Files
rustytorch/crates/specialized/rtx-fsi/tests/turek_hron_fsi3.rs
T
Omar SobhandClaude Fable 5.1 c63d79c300
Performance Benchmarks / Run Benchmarks (push) Canceled after 0s
CI / Format Check (push) Canceled after 0s
Documentation / Build API Documentation (push) Canceled after 0s
Documentation / Build User Guide (push) Canceled after 0s
CI / Clippy Check (push) Canceled after 0s
CI / Build (macos-latest) (push) Canceled after 0s
CI / Build (ubuntu-latest) (push) Canceled after 0s
CI / Test (macos-latest) (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 / 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
rtx-cfd/rtx-fsi: overset A-P0 GATED + M1 precision probe — curvilinear collocated PISO: relative-reduction pressure stop (the absolute stop floored |du/dt| at 2e-4 on 64²), line-implicit-n sign fix, adjustPhi; gates: Cartesian reduction 1.37–1.40x the staggered error at orders 0.83/0.90; skewed stretched periodic annulus Stokes orders 2.30/2.06 (explicit and line-implicit), upwind 1.08/0.80; Poiseuille exact to 1e-9 on Cartesian and affine-sheared periodic channels (both diffusion variants), varying-skew channel order 2.02 (v 1.9), cell mass 1e-14; divergence ≤ 1e-11 relative every step; snapshot/restore bit-identical. M1: poisson.rs multigrid hierarchy generic over MgScalar (f32/f64), f64 CG keeps its own fine level; MgPrecision on MultigridParameters/EmbeddedParameters/PisoParameters, set_poisson_precision, harness RTX_FSI2_POISSON_F32 (march + noise probe, printed marker); f64 arm bit-identical in vivo (FSI2 default line-for-line with 08-31), f32 arm holds the noise floor and stall pins and the FSI2 band; poisson_equivalence f32 arm
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01X2GmJXeQ2njUecEKiJZ1G2
2026-09-04 12:40:43 -07:00

346 lines
16 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.
//! TurekHron FSI3: the added-mass flapping flag — rung C3 of the ladder
//! (omni-cortex `docs/turek_hron_geometry_decision.md`).
//!
//! Re = 200 channel flow (`U = 2`) past the rigid cylinder with the
//! elastic flag at density ratio `rho_s / rho_f = 1` and `E = 5.6e6`:
//! the flag is as light as the fluid it displaces, so the fluid's
//! added mass is comparable to the structural mass — the regime in
//! which a staggered partitioned coupling is repulsive and the
//! quasi-Newton interface solver is genuinely needed per step (the
//! coupled piston benchmark's territory). Reference (FEATFLOW level 4,
//! dt = 0.0005): `ux(A) = 2.86 ± 2.70 mm [10.92 Hz]`,
//! `uy(A) = 1.45 ± 34.90 mm [5.46 Hz]`, drag `460.2 ± 27.47`,
//! lift `2.37 ± 153.75`.
//!
//! The march is FSI2's (`fsi2_harness::march`) with the FSI3 case
//! parameters; the rigid-flag phase is checked against this solver's
//! own CFD3 drag on the same geometry. The vacuum modal frequencies do
//! NOT identify this cycle the way they identified FSI2's: with the
//! fluid as heavy as the flag, the wetted frequency is set by the added
//! mass (the reference 5.46 Hz sits far below the vacuum mode 2 of
//! ~12 Hz that E/rho alone would give).
//!
//! # What is pinned
//!
//! Machinery invariants every run (conservation, finite state, coupling
//! bookkeeping). The committed default (t_end = 4.2) pins the
//! deterministic release response (uy 10.77 ± 23.57 mm, ±35% bands).
//! Study horizons under the sticky mask (`RTX_FSI3_HYST=0.25
//! RTX_FSI3_STALLX=10 RTX_FSI3_T_END=8.5`, ny 62 or 82) pin the
//! settled flapping cycle measured 2026-08-27/28 — uy amp 5058 mm
//! across the grids (reference 34.90; two-grid agreement 2.3% by p95,
//! the gap grid-converged), honest drag median 454 vs reference 460.2.
//! The full study record lives in omni-cortex `solver_status.md`.
//!
//! Environment knobs: `RTX_FSI3_*` — the same set as `RTX_FSI2_*` (see
//! `turek_hron_fsi2.rs`) plus `RTX_FSI3_OMEGA0`, `RTX_FSI3_TRACE`,
//! `RTX_FSI3_C1`, `RTX_FSI3_PREDICTOR`, `RTX_FSI3_QUIESCENT`; defaults:
//! ny 62, t_release 4, t_end 4.2, subcycle 2, tol 6e-5, rtol 1e-2,
//! maxsub 30, coupler `iqn`, reuse 2, omega0 0.05, C^1 interface,
//! kinematic predictor, quiescent release — every one of them a
//! measured necessity at unit density ratio (see the config comments).
mod fsi2_harness;
use fsi2_harness::FSI3;
use fsi2_harness::march::{MarchConfig, run_march};
// FEATFLOW level-4, dt 0.0005 reference values.
const REF_UY_MEAN: f64 = 1.45e-3;
const REF_UY_AMP: f64 = 34.90e-3;
const REF_UY_FREQ: f64 = 5.46;
const REF_UX_MEAN: f64 = -2.86e-3;
const REF_UX_AMP: f64 = 2.70e-3;
const REF_DRAG_MEAN: f64 = 460.2;
const REF_DRAG_AMP: f64 = 27.47;
const REF_LIFT_MEAN: f64 = 2.37;
const REF_LIFT_AMP: f64 = 153.75;
#[test]
fn fsi3_added_mass_flag() {
// IQN by default: at density ratio 1 the per-step coupling gain is
// high enough that a scalar relaxation is the wrong tool. The
// tolerance floor is FSI2's loose-coupling value until FSI3's own is
// measured; the increment-relative part does the work in the
// meantime.
let config = MarchConfig::from_env(
"FSI3",
MarchConfig {
ny: 62,
flag_nx: 35,
t_release: 4.0,
t_end: 4.2,
// Subcycle 2, not FSI2's default 8: at unit density ratio the
// interface noise floor rides with the MOTION as well as the
// loads (mask flips per step ∝ velocity, each ~250 N — a
// step-scale displacement on a light flag). Traced at
// subcycle 8 through the release transient (flag at ~0.3
// m/s): a step bouncing between 4e-4 and 1e-3 for twelve
// passes, landing on the 13th by luck once and not at all
// the next run. Subcycle 2 moves the interface 4x less per
// step; the probe's subcycle-2 release map converges to
// 9e-10.
subcycle: 2,
// The MEASURED FSI3 floor at this coupling (noise probe,
// RTX_NOISE_CASE=fsi3, t = 4, ny = 62): flip-scan jumps
// 3.6e-4 (12x FSI2's — 4x the dynamic pressure into a 10x
// more compliant flag), the s = 8 release map stalling near
// 1e-6 in 12 passes, and a traced step-1 stall at ~5e-5 from
// ~250 N load flips; at subcycle 8 through the release
// transient the floor rode up to ~1e-3 with the motion, which
// is what moved the default to subcycle 2, budgeted at 3e-5
// (wall-velocity noise 0.09 m/s, 4.5% of U). It must NOT be
// FSI2's 2e-4 borrowed blindly with the constant-velocity
// closure: a step accepted at one pass then left an interface
// VELOCITY jump the incompressible fluid answered with an
// impulsive added-mass load (1,600 N at release, 48,000 N
// and 59 mm one step later, the flag's Newton dead by the
// pass after) — the C^1 interface below removed that
// channel.
// 6e-5, not the 3e-5 the rest-state probe suggested: the
// floor RIDES WITH THE MOTION, and through the cycle (flag
// at ~1 m/s) a subcycle-2 step stalled at 1.9e-4 against a
// 5x-floor window of 1.5e-4 at a turning point, where the
// increment-relative part of the window collapses while the
// flip noise does not (t = 6.18 s of the first study). The
// window at 5 x 6e-5 = 3e-4 sits above the cycle's measured
// stall level; wall-velocity noise 0.17 m/s against cycle
// velocities of ~1 m/s.
tol_floor: 6e-5,
rtol: 1e-2,
// 5 at the committed horizon (t_end 4.2 never stalls); the
// t = 10 studies run STALLX=10 — see the MarchConfig field:
// the developed cycle's bistable stalls sit at 3.5e-4 and
// riding the FLOOR up instead was measured to make it worse.
stall_accept: 5.0,
// Off by default (bit-identical). The t = 10 studies probe it:
// the s = 1 death and the s = 2 crossing stalls are both the
// bistable mask (one geometry, two load branches — traced),
// which a sticky band makes single-valued at an O(band) wall
// lag (measured +0.5% field error at 0.25h on the MMS).
mask_hysteresis: 0.0,
max_subiterations: 30,
coupler: "iqn".into(),
reuse: 2,
smooth_in_h: 0.0,
csv_path: None,
snap_path: None,
snap_every: 10,
// The blind first relaxation must not explode the structure
// on a map whose per-pass gain is in the hundreds; 0.05 keeps
// the exploratory pass bounded (the secant takes over from
// pass 3, and the divergence verdict waits for it).
initial_relaxation: 0.05,
trace_steps: 0,
trace_from: usize::MAX,
coupling_rescue: false,
poisson_f32: false,
coarse_episode: 0,
inc_trace: None,
increment_factor: 0.0,
speed_fraction: 0.0,
// Continuous interface velocity across step boundaries: the
// constant-velocity closure's jump at each step start drew an
// impulsive added-mass load the unit-ratio flag could not
// survive.
c1_interface: true,
// Extrapolate the converged interface motion: the
// structure-alone predictor overshoots 2-5x at unit density
// ratio and each step's first pass became a violent
// excursion (traced at the t = 4 release).
predictor: "kinematic".into(),
// The structure-alone consistent initial acceleration ignores
// the added mass; at unit density ratio it is wildly wrong
// and Newmark carries it as a sign-alternating mode.
quiescent_release: true,
ffld_dir: None,
},
);
let MarchConfig {
ny, flag_nx, t_end, ..
} = config;
let case = fsi2_harness::case_from_env("FSI3", FSI3);
let result = run_march(case, &config);
let w = result.window(2.0);
println!(
" loads over the window: drag {:.2} ± {:.2} median {:.2} (ref {REF_DRAG_MEAN} ± \
{REF_DRAG_AMP}), lift {:.2} ± {:.2} median {:.2} (ref {REF_LIFT_MEAN} ± {REF_LIFT_AMP})",
w.drag_mid, w.drag_amp, w.drag_median, w.lift_mid, w.lift_amp, w.lift_median
);
println!(
" FSI3 (fluid ny = {ny}, flag {flag_nx}x2 Quad8, dt = {:.2e}, {}): coupled {} \
steps in {:.0} s wall total; {:.1} subit/step (max {}); {} stalled steps, {} \
history-reset retries (worst residual {:.2e}); worst conservation {:.2e}; \
skipped samples {} (of which {} spike-clamped); Newton rescues {:?}; coupling \
rescues {} (+{} failed ladders)\n \
measured over [{:.1}, {t_end:.1}] s: \
uy(A) = {:.4} ± {:.4} mm (ref {:.2} ± {:.2}), ux(A) = {:.4} ± {:.4} mm (ref {:.2} ± \
{:.2}), f = {} Hz (ref {REF_UY_FREQ}); onset amp {:.3e} -> {:.3e} m",
result.dt,
config.coupler,
result.coupled_steps,
result.elapsed,
result.mean_subiterations,
result.max_subiterations,
result.stalled_steps,
result.retried_steps,
result.worst_stall,
result.worst_conservation,
result.skipped,
result.spiked,
result.newton_rescues,
result.coupling_rescues,
result.coupling_rescue_failures,
w.t_start,
w.uy_mid * 1e3,
w.uy_amp * 1e3,
REF_UY_MEAN * 1e3,
REF_UY_AMP * 1e3,
w.ux_mid * 1e3,
w.ux_amp * 1e3,
REF_UX_MEAN * 1e3,
REF_UX_AMP * 1e3,
w.frequency.map_or("n/a".to_string(), |f| format!("{f:.3}")),
w.amp_early,
w.amp_late,
);
// Machinery invariants — asserted at every resolution.
assert!(
result.worst_conservation < 1e-10,
"load transfer lost force: {:.3e}",
result.worst_conservation
);
assert!(result.final_state_finite, "flag state went non-finite");
assert!(
result.mean_subiterations < 15.0,
"coupling is grinding: {:.1} subiterations/step",
result.mean_subiterations
);
assert!(
result.stalled_steps * 5 < result.coupled_steps.max(1),
"coupling stalled on {} of {} steps (worst residual {:.2e})",
result.stalled_steps,
result.coupled_steps,
result.worst_stall
);
// Physics bands, by horizon and configuration (the FSI2 pattern:
// the march is deterministic, so the short committed horizon
// carries tight regression bands on the release response; study
// horizons pin the MEASURED settled cycle, band-generous across
// the two grids that measured it. If a change moves any of these
// numbers, that is a finding either way and must be loud.)
// Bands were measured at the BENCHMARK case; an overridden u_mean
// (`RTX_FSI3_UMEAN`) or e_s (`RTX_FSI3_ES`) pins nothing here.
// Machinery invariants above stay asserted at every inflow and
// stiffness.
let benchmark_case =
case.u_mean.to_bits() == FSI3.u_mean.to_bits() && case.e_s.to_bits() == FSI3.e_s.to_bits();
let default_release = benchmark_case
&& config.subcycle == 2
&& config.mask_hysteresis == 0.0
&& config.coupler == "iqn"
&& ny == 62
&& flag_nx == 35
&& (t_end - 4.2).abs() < 1e-9;
let hyst_cycle = benchmark_case
&& config.subcycle == 2
&& config.mask_hysteresis > 0.0
&& config.coupler == "iqn"
&& flag_nx == 35
&& (ny == 62 || ny == 82)
&& t_end >= 8.4;
if default_release {
// The committed default: the release response over [4.0, 4.2].
// Measured BRANCH-SENSITIVE in EVERY windowed observable: four
// solver-path changes (dense LU 2026-08-26 / banded LU
// 2026-08-29 / Poisson warm start drafts 2026-08-30, each a
// legitimate same-tolerance answer) gave
// uy mid 10.77 / 6.02 / 2.91 / 8.88 mm,
// uy amp 23.57 / 25.22 / 24.56 / 19.90 mm,
// ux mid -2.90 / -2.91 / -2.56 / -1.82 mm,
// growth 4.6x / 5.2x / 3.4x / 4.0x,
// with IQN retries 2 / 0 / 1 / 1. The release transient at
// unit density ratio amplifies tolerance-level perturbations
// through discrete retry/mask branches, so ANY tight band here
// re-fires on the next legitimate change. These bands are
// GROSS-PHYSICS tripwires around the measured scatter (sign,
// scale, oscillation-vs-drift); the load-bearing regression
// pins for solver changes are the SETTLED-CYCLE study bands
// (hyst_cycle below), which are statistics over 2 s of cycle,
// not 0.2 s of chaotic transient.
assert!(
(1.0e-3..=18.0e-3).contains(&w.uy_mid),
"release uy mid {:.4e} left the gross-scale band [1.0e-3, 18.0e-3] \
(measured branches 10.77/6.02/2.91/8.88 mm)",
w.uy_mid
);
assert!(
(14.0e-3..=34.0e-3).contains(&w.uy_amp),
"release uy amp {:.4e} left [14.0e-3, 34.0e-3] (measured branches \
23.57/25.22/24.56/19.90 mm)",
w.uy_amp
);
assert!(
(-4.5e-3..=-0.9e-3).contains(&w.ux_mid),
"release ux mid {:.4e} left [-4.5e-3, -0.9e-3] (measured branches \
-2.90/-2.91/-2.56/-1.82 mm)",
w.ux_mid
);
assert!(
w.amp_late > 2.0 * w.amp_early,
"the release is not growing: onset amp {:.3e} -> {:.3e} (measured 4.6x / \
5.2x / 3.4x / 4.0x across the four solver-path branches)",
w.amp_early,
w.amp_late
);
} else if hyst_cycle {
// Study horizons, the sticky-mask configuration (band 0.25h,
// STALLX 10): the settled flapping cycle, measured 2026-08-27/28
// at t_end = 8.5 / window(2.0) — ny 62: uy 3.07 ± 50.25 mm
// (run 7), ny 82: 8.13 ± 57.55 mm (run 8); reference
// 1.45 ± 34.90. The AMPLITUDE discriminates: the un-flapped
// release and every pre-hysteresis dead march sit far below
// 38 mm, and the reference itself sits outside the band — a
// change landing INSIDE the reference band is a loud finding,
// not a regression.
assert!(
(38.0e-3..=72.0e-3).contains(&w.uy_amp),
"cycle uy amp {:.4e} left [38e-3, 72e-3] (measured 50.2e-3 / 57.6e-3 at ny 62/82)",
w.uy_amp
);
assert!(
(-8.0e-3..=22.0e-3).contains(&w.uy_mid),
"cycle uy mid {:.4e} left [-8e-3, 22e-3] (measured 3.1e-3 / 8.1e-3)",
w.uy_mid
);
assert!(
(-14.0e-3..=-2.0e-3).contains(&w.ux_mid),
"cycle ux mid {:.4e} left [-14e-3, -2e-3] (measured -4.9e-3 / -9.4e-3)",
w.ux_mid
);
// The crossing estimator is beat-biased on 2 s windows (it read
// 4.87 / 6.25 Hz where the DFT puts both grids at 5.4-5.6, ref
// 5.46); its band covers the estimator's measured scatter, not
// the physics.
if let Some(f) = w.frequency {
assert!(
(4.4..=6.8).contains(&f),
"cycle crossing-frequency {f:.3} left [4.4, 6.8] (measured 4.87 / 6.25)"
);
}
// The honest central drag (window median of the interval-median
// record): 454.0 at ny 62 (run 10) vs reference 460.2 — a loud
// tripwire on the load path, not an amplitude claim (the
// oscillation amplitudes are surface-route-unmeasurable; see
// omni-cortex solver_status.md).
assert!(
(380.0..=530.0).contains(&w.drag_median),
"cycle drag median {:.1} left [380, 530] (measured 454.0, ref 460.2)",
w.drag_median
);
}
}