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
Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01X2GmJXeQ2njUecEKiJZ1G2
346 lines
16 KiB
Rust
346 lines
16 KiB
Rust
//! Turek–Hron 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 50–58 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
|
||
);
|
||
}
|
||
}
|