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
362 lines
17 KiB
Rust
362 lines
17 KiB
Rust
//! Turek–Hron FSI2: the self-excited flapping flag — rung C2 of the ladder
|
||
//! (omni-cortex `docs/turek_hron_geometry_decision.md`).
|
||
//!
|
||
//! Re = 100 channel flow (`U = 1`) past the rigid cylinder with the elastic
|
||
//! flag at density ratio `rho_s / rho_f = 10`: the flow destabilises the
|
||
//! flag into a large-amplitude limit cycle. Reference (FEATFLOW level 4,
|
||
//! dt = 0.0005): `ux(A) = −14.85 ± 12.70 mm [3.86 Hz]`,
|
||
//! `uy(A) = 1.30 ± 81.6 mm [1.93 Hz]`, drag `215.06 ± 77.65`,
|
||
//! lift `0.61 ± 237.8`.
|
||
//!
|
||
//! # The march
|
||
//!
|
||
//! Unlike FSI1's steady fixed point, FSI2 marches in time: per TIME STEP
|
||
//! the fluid step and the flag's nonlinear-Newmark step are subiterated
|
||
//! until the end-of-step interface displacement converges — the coupled
|
||
//! piston benchmark's structure, with the real 2-D solvers. The fluid
|
||
//! step is re-runnable inside a subiteration through
|
||
//! [`EmbeddedPisoSolver::snapshot`]/`restore` plus a [`FlowField`] clone;
|
||
//! the flag step is re-runnable because [`NonlinearDynamicStepper::step`]
|
||
//! commits nothing. The moving polygon carries the flag's actual interface
|
||
//! velocity — finite-differenced end-of-step positions over `dt`,
|
||
//! interpolated along the nearest edge (`polygon_interface_velocity`) —
|
||
//! replacing the zero-velocity closure FSI1's steady case allowed. The
|
||
//! fluid keeps TVD convection (shedding physics; limiter chatter is
|
||
//! harmless in time marching) and the multigrid projection.
|
||
//!
|
||
//! The geometry, load sampling (spike clamp + optional surface
|
||
//! smoothing), fluid configuration and the march itself live in
|
||
//! `fsi2_harness/` (shared with FSI3); the interface-noise-floor probe
|
||
//! `fsi2_interface_noise.rs` measures the same machinery's pass-to-pass
|
||
//! continuity.
|
||
//!
|
||
//! # Phases (the validation ladder inside FSI2)
|
||
//!
|
||
//! 1. **Rigid flag** to `t_release`: the ramped inflow over the fixed
|
||
//! geometry must land near the CFD2 steady state this solver already
|
||
//! measured (surface drag 121.4 / 123.3 at ny = 62 / 82 vs its CFD2
|
||
//! runs' ~121 / 122.6) — the harness's fluid configuration is checked
|
||
//! against a known number before anything couples.
|
||
//! 2. **Release**: the flag starts at rest under the sampled fluid load
|
||
//! (consistent initial acceleration), and the coupled march runs to
|
||
//! `t_end`.
|
||
//!
|
||
//! # What the 2026-08-21 study measured at the loose default (t = 30 s)
|
||
//!
|
||
//! **At the loosely-coupled default (8 fluid substeps per coupled step,
|
||
//! per-step Aitken, ~1 subiteration) the coupled system self-excites but
|
||
//! lands in a wake-forced cycle at 3.729 / 3.728 Hz with uy(A) ± 17.3 mm
|
||
//! at BOTH ny = 62 and ny = 82 — grid-converged, protocol-independent.
|
||
//! This is NOT the benchmark's cycle** (1.93 Hz, ± 81.6 mm). The
|
||
//! identification is clean: the flag's vacuum mode 2 is 1.9245 Hz (modal
|
||
//! analysis, 35x2 Quad8) — the reference cycle IS mode-2 resonance —
|
||
//! while 3.73 Hz matches no structural mode (mode 3 is 5.26 Hz); the
|
||
//! measured state is the heavy flag's off-resonance forced response at
|
||
//! the wake's own shedding frequency, and its ux mean (−0.8 mm) matches
|
||
//! the foreshortening scaling (amp/81.6)^2 x (−14.85).
|
||
//!
|
||
//! Why mode 2 does not win at the default coupling: the motion-load
|
||
//! staggered phase lag (~omega dt_c) starves the resonant channel. The
|
||
//! tenth session's attempt at tighter coupling (subcycle 2 at the SAME
|
||
//! 2e-4 tolerance) blew up — the accepted interface scatter feeds the
|
||
//! no-slip closure a wall-velocity noise of tol / dt_c, 30% of the mean
|
||
//! inflow there — and was read as an impassable interface noise floor.
|
||
//!
|
||
//! # What the eleventh session (2026-08-21) measured: the mode-2 cycle
|
||
//!
|
||
//! The noise probe (`fsi2_interface_noise.rs`) showed the floor was a
|
||
//! tolerance mis-budgeting: the real step map converges to ~1e-9 at
|
||
//! subcycle 2. With the `IqnIls` coupler and the tolerance budgeted
|
||
//! from that measurement (`RTX_FSI2_COUPLER=iqn RTX_FSI2_SUBCYCLE=2
|
||
//! RTX_FSI2_TOL=1.5e-5`), the march self-excites through resonant
|
||
//! growth (2.34 → 2.23 Hz, drifting toward mode 2) into **the
|
||
//! benchmark's mode-2 flapping cycle: uy = 3.7 ± 57.6 mm at 2.227 Hz
|
||
//! at ny = 62 (t_end = 16), 1.99 ± 57.1 mm at 2.236 Hz at ny = 82
|
||
//! (t_end = 20) — grid-converged to 0.9% / 0.4% — and stable over a
|
||
//! 24 s record (3.68 ± 55.9 mm at 2.224 Hz over [27, 30] s)**. The
|
||
//! ladder localizes mode selection in dt_c: subcycle 4 opens the
|
||
//! resonant channel for ~4 s (2.5 Hz) before the wake recaptures it
|
||
//! (3.67 Hz, ±15.5 mm); subcycle 1 reaches 1.955 Hz / ±73 mm mid-growth
|
||
//! (89% of the reference amplitude at essentially its frequency), so
|
||
//! the remaining gap to the reference is coupling lag, not grid. Cost:
|
||
//! 1.4 (subcycle 2) to 2.8 (subcycle 1) subiterations per step.
|
||
//!
|
||
//! Coupler failure modes met on the way, each measured and handled
|
||
//! (`rtx_fsi::coupling` docs): stale secant history overshooting during
|
||
//! rapid amplitude growth (history reset + one retry from the
|
||
//! predictor), an ill-conditioned secant step sweeping a candidate
|
||
//! interface to the domain wall (trust-region cap at 50x the
|
||
//! residual), and a rare violent step near peak motion stalling an
|
||
//! order below its own increment (accepted, counted, bounded).
|
||
//!
|
||
//! Robustness findings from the tenth session, both measured: rare wild
|
||
//! tractions from near-degenerate reconstructions (19 samples in 2.4
|
||
//! million) killed a t = 25.8 s march through the flag's Newton until
|
||
//! the spike CLAMP (20x the sample median, direction kept — clamping,
|
||
//! not dropping: a hard drop makes the coupling pass discontinuous and
|
||
//! the subiteration bounces at the step scale) and a 60-iteration Newton
|
||
//! budget; with both, the same march runs to t = 30 clean.
|
||
//!
|
||
//! Machinery invariants asserted every run: load-transfer conservation
|
||
//! (partition of unity) at 1e-10 (measured 8e-12 over 9,263 steps),
|
||
//! coupled convergence bookkeeping, finite fields. The committed default
|
||
//! (t_end = 7) pins the deterministic release response; study horizons
|
||
//! pin the measured attractor of each configuration (the wake state at
|
||
//! t_end >= 25 under the default; the mode-2 cycle at t_end >= 16 under
|
||
//! IQN / subcycle 2) so any material change is loud. Full trajectories:
|
||
//! the session scratchpad study logs.
|
||
//!
|
||
//! Environment knobs: `RTX_FSI2_NY` (fluid resolution, default 62),
|
||
//! `RTX_FSI2_T_RELEASE` (default 6 s), `RTX_FSI2_T_END` (default 7 s —
|
||
//! the committed onset segment; studies run 16–30), `RTX_FSI2_SUBCYCLE`
|
||
//! (fluid substeps per coupled step, default 8), `RTX_FSI2_TOL` /
|
||
//! `RTX_FSI2_RTOL` (interface tolerance floor and its
|
||
//! relative-to-increment part), `RTX_FSI2_MAXSUB` (subiteration budget,
|
||
//! default 12), `RTX_FSI2_FLAG_NX` (flag mesh, default 35),
|
||
//! `RTX_FSI2_SMOOTH` (traction smoothing radius in multiples of the cell
|
||
//! size, default 0 = off), `RTX_FSI2_COUPLER` (`aitken` default, or `iqn`
|
||
//! for IQN-ILS with `RTX_FSI2_REUSE` steps of secant history, default 2),
|
||
//! `RTX_FSI2_CSV` (trajectory dump path).
|
||
|
||
mod fsi2_harness;
|
||
|
||
use fsi2_harness::FSI2;
|
||
use fsi2_harness::march::{MarchConfig, run_march};
|
||
|
||
// FEATFLOW level-4, dt 0.0005 reference values.
|
||
const REF_UY_MEAN: f64 = 1.30e-3;
|
||
const REF_UY_AMP: f64 = 81.6e-3;
|
||
const REF_UY_FREQ: f64 = 1.93;
|
||
const REF_UX_MEAN: f64 = -14.85e-3;
|
||
const REF_UX_AMP: f64 = 12.70e-3;
|
||
const REF_DRAG_MEAN: f64 = 215.06;
|
||
const REF_LIFT_AMP: f64 = 237.8;
|
||
|
||
#[test]
|
||
fn fsi2_flapping_flag() {
|
||
// The tenth-session defaults. The tolerance floor is the measured
|
||
// interface noise floor of THIS configuration (loose coupling at
|
||
// subcycle 8): the floor rides with the loads and with dt_c, and a
|
||
// tighter coupling budgets its own (see the module docs and the
|
||
// noise probe).
|
||
let config = MarchConfig::from_env(
|
||
"FSI2",
|
||
MarchConfig {
|
||
ny: 62,
|
||
flag_nx: 35,
|
||
t_release: 6.0,
|
||
t_end: 7.0,
|
||
subcycle: 8,
|
||
tol_floor: 2e-4,
|
||
rtol: 1e-2,
|
||
stall_accept: 5.0,
|
||
mask_hysteresis: 0.0,
|
||
max_subiterations: 12,
|
||
coupler: "aitken".into(),
|
||
reuse: 2,
|
||
smooth_in_h: 0.0,
|
||
csv_path: None,
|
||
snap_path: None,
|
||
snap_every: 10,
|
||
initial_relaxation: 0.5,
|
||
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,
|
||
c1_interface: false,
|
||
predictor: "structure".into(),
|
||
quiescent_release: false,
|
||
ffld_dir: None,
|
||
},
|
||
);
|
||
let MarchConfig {
|
||
ny,
|
||
flag_nx,
|
||
t_release,
|
||
t_end,
|
||
subcycle,
|
||
smooth_in_h,
|
||
..
|
||
} = config;
|
||
let case = fsi2_harness::case_from_env("FSI2", FSI2);
|
||
let result = run_march(case, &config);
|
||
let w = result.window(3.0);
|
||
|
||
println!(
|
||
" loads over the window: drag {:.2} ± {:.2} (ref {REF_DRAG_MEAN} ± 77.65), \
|
||
lift {:.2} ± {:.2} (ref 0.61 ± {REF_LIFT_AMP})",
|
||
w.drag_mid, w.drag_amp, w.lift_mid, w.lift_amp
|
||
);
|
||
println!(
|
||
" FSI2 (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} ± {:.1}), 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 < 10.0,
|
||
"coupling is grinding: {:.1} subiterations/step",
|
||
result.mean_subiterations
|
||
);
|
||
// Stalls at the noise floor are tolerated but must stay the exception;
|
||
// a coupling stalling on most steps is not converging, it is drifting.
|
||
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 march is
|
||
// deterministic, so short horizons carry tight regression bands;
|
||
// long horizons pin MEASURED attractors — which attractor depends on
|
||
// the coupling (see the module docs): the loosely-coupled default
|
||
// (Aitken, subcycle 8) locks the wake-forced 3.73 Hz / ±17.3 mm
|
||
// state at BOTH grids; the IQN / subcycle-2 configuration locks the
|
||
// benchmark's mode-2 flapping cycle, grid-converged at ±57 mm /
|
||
// 2.23 Hz. If a change moves any of these numbers, that is a finding
|
||
// either way and must be loud. Smoothing changes the load path and
|
||
// is pinned nowhere (measured to change nothing that matters).
|
||
// Every physics band below was measured at the BENCHMARK case; an
|
||
// overridden u_mean (the TWIN-1 sweep) or e_s (the TWIN-2 sweep)
|
||
// pins nothing here — those bands live in the campaigns' composition
|
||
// harnesses. The machinery invariants above stay asserted at every
|
||
// inflow and stiffness.
|
||
let benchmark_case =
|
||
case.u_mean.to_bits() == FSI2.u_mean.to_bits() && case.e_s.to_bits() == FSI2.e_s.to_bits();
|
||
let default_coupling = benchmark_case && smooth_in_h == 0.0 && config.coupler == "aitken";
|
||
let mode2_coupling =
|
||
benchmark_case && smooth_in_h == 0.0 && config.coupler == "iqn" && subcycle == 2;
|
||
let s1_coupling =
|
||
benchmark_case && smooth_in_h == 0.0 && config.coupler == "iqn" && subcycle == 1;
|
||
if default_coupling
|
||
&& ny == 62
|
||
&& flag_nx == 35
|
||
&& (t_end - 7.0).abs() < 1e-9
|
||
&& (t_release - 6.0).abs() < 1e-9
|
||
{
|
||
// The committed default: the release response over [6, 7] s,
|
||
// measured 2026-08-21 as uy mid 3.773 mm, amp 3.792 mm. The band
|
||
// is ±35% for cross-platform floating-point drift in a growing
|
||
// transient, not an accuracy claim.
|
||
assert!(
|
||
(2.4e-3..5.2e-3).contains(&w.uy_mid),
|
||
"uy release-response mid {:.4e} outside the measured band [2.4e-3, 5.2e-3]",
|
||
w.uy_mid
|
||
);
|
||
assert!(
|
||
(2.4e-3..5.2e-3).contains(&w.uy_amp),
|
||
"uy release-response amp {:.4e} outside the measured band [2.4e-3, 5.2e-3]",
|
||
w.uy_amp
|
||
);
|
||
} else if default_coupling && t_end >= 25.0 {
|
||
// Study horizons: the measured attractor of the loosely-coupled
|
||
// (subcycle 8) march — f = 3.729 / 3.728 Hz and uy amp 17.3 mm at
|
||
// ny = 62 / 82 (2026-08-21).
|
||
if let Some(f) = w.frequency {
|
||
assert!(
|
||
(f - 3.73).abs() / 3.73 < 0.10,
|
||
"uy frequency {f:.3} left the measured 3.73 Hz attractor \
|
||
(benchmark reference {REF_UY_FREQ}) — a material change"
|
||
);
|
||
}
|
||
assert!(
|
||
(12e-3..24e-3).contains(&w.uy_amp),
|
||
"uy amplitude {:.4e} left the measured ±17.3 mm attractor band [12e-3, 24e-3]",
|
||
w.uy_amp
|
||
);
|
||
} else if mode2_coupling && flag_nx == 35 && t_end >= 16.0 {
|
||
// Study horizons, the mode-2 configuration: the flapping cycle
|
||
// measured 2026-08-21 at t_end = 16 (ny = 62: uy 3.71 ± 57.6 mm
|
||
// at 2.227 Hz), t_end = 20 (ny = 82: 1.99 ± 57.1 mm at 2.236 Hz)
|
||
// and t_end = 30 (ny = 62: 3.68 ± 55.9 mm at 2.224 Hz) — two-grid
|
||
// agreement 0.9% in amplitude, 0.4% in frequency, stable over 24 s.
|
||
// The bands are wide enough for a settled cycle at either grid
|
||
// and narrow enough that the wake attractor (3.73 Hz, ±17 mm)
|
||
// or the reference itself (1.93 Hz, ±82 mm) would both fail:
|
||
// either would be a material change to report.
|
||
if let Some(f) = w.frequency {
|
||
assert!(
|
||
(2.05..2.45).contains(&f),
|
||
"uy frequency {f:.3} left the measured mode-2 cycle band \
|
||
[2.05, 2.45] Hz (benchmark reference {REF_UY_FREQ})"
|
||
);
|
||
}
|
||
assert!(
|
||
(45e-3..70e-3).contains(&w.uy_amp),
|
||
"uy amplitude {:.4e} left the measured mode-2 cycle band [45e-3, 70e-3] \
|
||
(benchmark reference {REF_UY_AMP})",
|
||
w.uy_amp
|
||
);
|
||
} else if s1_coupling && ny == 62 && flag_nx == 35 && t_end >= 16.0 {
|
||
// Study horizons, subcycle 1: THE BENCHMARK CYCLE, hit 2026-08-24
|
||
// (run 3, floor 4e-6, MAXSUB 30; measured over [13, 16] s:
|
||
// uy 3.17 ± 81.68 mm at 1.925 Hz vs reference 1.30 ± 81.6
|
||
// [1.93] — 0.1% in amplitude, 0.26% in frequency; full record in
|
||
// omni-cortex solver_status.md §"C2 closed in displacement").
|
||
// The bands are deliberately generous: the s = 1 trajectory
|
||
// shifts legitimately whenever a structural-Newton rescue
|
||
// engages mid-march (runs 1 and 2 DIED where run 3's coupler
|
||
// fixes carried it), and the mode-2 s = 2 cycle (±57.6 mm at
|
||
// 2.23 Hz) still fails both bands — losing the benchmark cycle
|
||
// stays loud.
|
||
if let Some(f) = w.frequency {
|
||
assert!(
|
||
(1.85..2.0).contains(&f),
|
||
"uy frequency {f:.3} left the benchmark-cycle band [1.85, 2.0] Hz \
|
||
(measured 1.925, reference {REF_UY_FREQ})"
|
||
);
|
||
}
|
||
assert!(
|
||
(70e-3..92e-3).contains(&w.uy_amp),
|
||
"uy amplitude {:.4e} left the benchmark-cycle band [70e-3, 92e-3] \
|
||
(measured 81.68e-3, reference {REF_UY_AMP})",
|
||
w.uy_amp
|
||
);
|
||
}
|
||
}
|