//! 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 ); } }