Files
rustytorch/crates/specialized/rtx-fsi/tests/turek_hron_fsi2.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

362 lines
17 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 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 1630), `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
);
}
}