//! The FSI2 interface noise floor, measured directly — the lever the //! benchmark's resonant cycle waits on. //! //! # What is being measured //! //! The partitioned coupling iterates one pass: subcycled fluid march on a //! candidate interface geometry, traction sampling on that geometry, one //! structure step under the sampled load. The tenth-session study found //! that this map is not continuous at small scales: a vanishing interface //! change flips embedded-mask cells, the load jumps by a finite amount, //! and the jump maps through Newmark's `beta dt_c^2 / m` into an //! end-of-step displacement jump. That jump size is the **interface noise //! floor**: no coupling tolerance below it is reachable, and the //! step-to-step scatter of the accepted interface at the tolerance feeds //! the fluid wall-velocity noise of tolerance / dt_c (solver_status.md //! §"C2 — FSI2"). //! //! This test measures the floor as the modulus of continuity of the real //! pass map, on the real machinery (`fsi2_harness`), at full inflow: a //! fine sweep of bending amplitudes (every pass from the same snapshot), //! whose maximum successive difference is the mask-flip jump — plus a //! decades ladder around zero for the small-scale behaviour. //! //! # What the 2026-08-21 measurements found (ny = 62, t = 4 s) //! //! 1. **The floor is NOT in the traction sampling.** Surface smoothing of //! the sampled tractions ([`rtx_fsi::smooth_tractions`]) at radii of //! 1–3 cells leaves the flip-scan floor unchanged to 0.2% (3.05e-5 at //! every radius): the flip's load jump is **coherent through the //! fluid field itself** — the mask rebuild shifts the pressure //! solution around the flipped cell and every nearby sample moves //! together — and a surface moving-average preserves exactly such //! coherent shifts. Smoothing therefore stays available but OFF by //! default; reaching for it against this floor is a measured dead //! end. The committed run keeps one smoothed scan alive so this //! attribution stays loud. //! 2. **The flip scan does NOT transfer across subcycles** — a //! dt_c^2-scaling hypothesis for the floor (load jump through //! Newmark's `beta dt_c^2 / m`) was tried against a subcycle-2 scan //! and REFUTED in that operationalization: max jump 5.4e-5 vs 3.0e-5 //! at subcycle 8, median 15x LARGER. The scan's successive passes //! differ by a fixed geometry increment, so their wall-velocity //! difference is increment / dt_c, and the smooth velocity-response //! trend inflates as dt_c shrinks until it swamps the flip signal. //! A cross-subcycle floor claim needs the OPERATIONAL measurement //! instead: //! 3. **The stall measurement** — the real release-step map iterated at //! a deep tolerance with the per-pass residuals traced — is the //! operational floor, and it came out FAR below every march //! tolerance: s8 aitken 3.4e-9 / iqn 1.6e-9 in a 12-pass budget, s2 //! aitken 6.5e-10 / iqn 6.3e-10 CONVERGED below 1e-9 in 5–6 passes. //! The flip jumps are events at specific geometries, not a floor //! under every step; a typical step's map is locally smooth. The //! tenth session's subcycle-2 blowup (tolerance held at 2e-4 while //! dt_c shrank — wall-velocity noise tol / dt_c) was a tolerance //! mis-budgeting, not an impassable floor: tighter coupling is open //! at a tolerance the map demonstrably supports (~1e-5 leaves three //! decades of margin). Occasional flip-straddling steps still stall //! at the jump scale — the march's stall-accept handles those. //! //! Environment knobs: `RTX_NOISE_NY` (default 62), `RTX_NOISE_T` (rigid //! march horizon, default 4 s), `RTX_NOISE_SUBCYCLE` (baseline subcycle, //! default 8), `RTX_NOISE_SCAN` (flip-scan resolution, default 40 //! passes), `RTX_NOISE_EPS` (flip-scan amplitude, default 5e-4 m), //! `RTX_NOISE_FULL` (nonzero: sweep radii 0–3 h and subcycles {8, 4, 2, //! 1} instead of the committed set), `RTX_NOISE_CASE` (`fsi2` default or //! `fsi3` — the pins apply to FSI2 only; FSI3's floors are measured, not //! borrowed). mod fsi2_harness; use fsi2_harness::{FLAG_X0, FLAG_X1, Fsi2Harness, clamp_left, env_or}; use rtx_cfd::solvers::incompressible::{EmbeddedPisoSolver, FlowField}; use rtx_fea::analysis::{AnalysisConfig, ConvergenceCriteria, NonlinearDynamicAnalysis}; use rtx_fea::materials::{LinearElastic, MaterialDatabase}; use rtx_fea::mesh::MaterialId; fn norm(v: &[f64]) -> f64 { v.iter().map(|x| x * x).sum::().sqrt() } fn sub(a: &[f64], b: &[f64]) -> Vec { a.iter().zip(b).map(|(x, y)| x - y).collect() } #[test] #[allow(clippy::too_many_lines)] fn fsi2_interface_noise_floor() { let ny = env_or("RTX_NOISE_NY", 62.0) as usize; let t_probe = env_or("RTX_NOISE_T", 4.0); let base_subcycle = env_or("RTX_NOISE_SUBCYCLE", 8.0) as usize; let scan_passes = env_or("RTX_NOISE_SCAN", 40.0) as usize; let eps_max = env_or("RTX_NOISE_EPS", 5e-4); let full_sweep = env_or("RTX_NOISE_FULL", 0.0) != 0.0; let flag_nx = 35; let case = match std::env::var("RTX_NOISE_CASE").as_deref() { Ok("fsi3" | "FSI3") => fsi2_harness::FSI3, _ => fsi2_harness::FSI2, }; let (mut harness, mut solver, mut field) = Fsi2Harness::build_case(case, ny, flag_nx, 0.0); // M1 precision probe (`RTX_FSI2_POISSON_F32=1`): the pressure // multigrid's V-cycle in f32 inside the f64 CG. Printed so the arm can // never pass vacuously. if env_or("RTX_FSI2_POISSON_F32", 0.0) != 0.0 { solver.set_poisson_precision(rtx_cfd::solvers::incompressible::MgPrecision::F32); println!(" poisson V-cycle precision: F32 (M1 probe)"); } let dt_fluid = harness.dt_fluid; // Rigid march to operating loads (the floor rides with the loads — // measuring at startup would understate it by orders of magnitude). let start = std::time::Instant::now(); let rigid_steps = (t_probe / dt_fluid).round() as usize; for _ in 0..rigid_steps { futures::executor::block_on(solver.advance(&mut field, dt_fluid)).unwrap(); } println!( " rigid march: {rigid_steps} steps to t = {t_probe:.1} s in {:.0} s wall", start.elapsed().as_secs_f64() ); let zero_d = vec![0.0; 2 * harness.interface.wetted.len()]; let fluid_saved = solver.snapshot(); let field_saved = field.clone(); // A smooth cantilever-bending perturbation pattern, unit tip // amplitude: p_y = ((x - x0)/(x1 - x0))^2, p_x = 0 — the shape a // subiteration increment actually has. let pattern: Vec = harness .interface .reference .iter() .flat_map(|&(x, _)| { let s = (x - FLAG_X0) / (FLAG_X1 - FLAG_X0); [0.0, s * s] }) .collect(); // The flag stepper is rebuilt per subcycle: the coupled dt (and with // it Newmark's beta dt^2 / m response to a load jump) is exactly // what the scaling measurement varies. let flag_mesh = harness.mesh.clone(); let make_analysis = move |subcycle: usize| { let mut db = MaterialDatabase::new(); db.add_material( MaterialId(0), LinearElastic::new(case.e_s, case.nu_s).with_density(case.rho_s), None, ); NonlinearDynamicAnalysis::new( flag_mesh.clone(), db, clamp_left(&flag_mesh), dt_fluid * subcycle as f64, 1, AnalysisConfig::default(), ) .with_total_lagrangian() .with_convergence_criteria(ConvergenceCriteria { max_iterations: 60, ..ConvergenceCriteria::default() }) }; // One config's floor: pass-map continuity at a given subcycle and // smoothing radius, all passes from the same saved fluid state. let measure = |harness: &mut Fsi2Harness, solver: &mut EmbeddedPisoSolver, subcycle: usize, radius_in_h: f64, ladder: bool| -> (f64, f64) { harness.smooth_radius = radius_in_h * harness.h; // Reset the shared body geometry: a previous measurement's last // candidate must not leak into this one's initial load sampling // (it did, before this line — a 4.5e-5 phantom first residual). harness.set_geometry(&zero_d, &zero_d); let analysis = make_analysis(subcycle); let mut flag = analysis.stepper().unwrap(); let wetted_dofs: Vec<[usize; 2]> = harness .interface .wetted .iter() .map(|&id| { let dofs = flag.node_dofs(id); [dofs[0], dofs[1]] }) .collect(); let (nodal0, _, _) = harness.sample_load(solver, &field_saved, &zero_d); flag.set_nodal_forces(&nodal0); let flag_state = flag.rest_state().unwrap(); let mut pass = |d_candidate: &[f64]| -> Vec { solver.restore(&fluid_saved); let mut trial_field: FlowField = field_saved.clone(); harness.advance_subcycled( solver, &mut trial_field, &zero_d, d_candidate, subcycle, None, ); let (nodal, _, _) = harness.sample_load(solver, &trial_field, d_candidate); flag.set_nodal_forces(&nodal); let (candidate_state, _) = flag.step(&flag_state).unwrap(); let mut d = vec![0.0; 2 * wetted_dofs.len()]; for (k, dofs) in wetted_dofs.iter().enumerate() { d[2 * k] = candidate_state.displacement[dofs[0]]; d[2 * k + 1] = candidate_state.displacement[dofs[1]]; } d }; let base = pass(&zero_d); if ladder { print!(" subcycle {subcycle} smooth {radius_in_h:.1}h ladder:"); for exp in [-7.0f64, -6.0, -5.0, -4.0] { let eps = 10.0f64.powf(exp); let d: Vec = pattern.iter().map(|p| eps * p).collect(); let response = norm(&sub(&pass(&d), &base)); print!(" 1e{exp:.0} -> {response:.2e}"); } println!(); } let mut previous = base; let mut max_jump = 0.0f64; let mut jumps = Vec::with_capacity(scan_passes); for k in 1..=scan_passes { let eps = eps_max * k as f64 / scan_passes as f64; let d: Vec = pattern.iter().map(|p| eps * p).collect(); let current = pass(&d); let jump = norm(&sub(¤t, &previous)); jumps.push(jump); max_jump = max_jump.max(jump); previous = current; } jumps.sort_by(|a, b| a.partial_cmp(b).unwrap()); let median_jump = jumps[jumps.len() / 2]; println!( " subcycle {subcycle} smooth {radius_in_h:.1}h flip scan ({scan_passes} passes to \ eps = {eps_max:.1e}): max successive jump {max_jump:.3e}, median {median_jump:.3e}" ); (max_jump, median_jump) }; if full_sweep { for &s in &[base_subcycle, 4, 2, 1] { for r in [0.0, 1.0, 2.0, 3.0] { measure(&mut harness, &mut solver, s, r, r == 0.0); } } println!( " full sweep total {:.0} s wall", start.elapsed().as_secs_f64() ); return; } // The stall measurement: iterate the REAL release-step map at an // unreachable tolerance and trace every pass's residual. Where the // subiteration stalls is the operational noise floor — the number a // march's coupling tolerance must sit above — measured per subcycle // and per coupler on the same fluid state. let stall = |harness: &mut Fsi2Harness, solver: &mut EmbeddedPisoSolver, subcycle: usize, coupler: &str| -> (f64, f64, usize) { harness.smooth_radius = 0.0; harness.set_geometry(&zero_d, &zero_d); let analysis = make_analysis(subcycle); let flag = std::cell::RefCell::new(analysis.stepper().unwrap()); let wetted_dofs: Vec<[usize; 2]> = harness .interface .wetted .iter() .map(|&id| { let dofs = flag.borrow().node_dofs(id); [dofs[0], dofs[1]] }) .collect(); let extract = |state: &rtx_fea::analysis::DynamicState| -> Vec { let mut d = vec![0.0; 2 * wetted_dofs.len()]; for (k, dofs) in wetted_dofs.iter().enumerate() { d[2 * k] = state.displacement[dofs[0]]; d[2 * k + 1] = state.displacement[dofs[1]]; } d }; let (nodal0, _, _) = harness.sample_load(solver, &field_saved, &zero_d); flag.borrow_mut().set_nodal_forces(&nodal0); let flag_state = flag.borrow_mut().rest_state().unwrap(); // The predictor the march uses: the structure alone under the // committed load. let (predicted, _) = flag.borrow_mut().step(&flag_state).unwrap(); let d_pred = extract(&predicted); let solver = std::cell::RefCell::new(solver); let trace = std::cell::RefCell::new(Vec::::new()); let harness_ref = &*harness; let pass = |d_candidate: &[f64]| -> Vec { let mut solver_ref = solver.borrow_mut(); solver_ref.restore(&fluid_saved); let mut trial_field: FlowField = field_saved.clone(); harness_ref.advance_subcycled( &mut solver_ref, &mut trial_field, &zero_d, d_candidate, subcycle, None, ); let (nodal, _, _) = harness_ref.sample_load(&solver_ref, &trial_field, d_candidate); let mut flag_ref = flag.borrow_mut(); flag_ref.set_nodal_forces(&nodal); let (candidate_state, _) = flag_ref.step(&flag_state).unwrap(); let d_new = extract(&candidate_state); trace.borrow_mut().push(norm(&sub(&d_new, d_candidate))); d_new }; // A tolerance at the bottom of what the map could conceivably // support: the point is the trace, not the verdict. (It turned // out to be REACHABLE at subcycle 2 — that reachability is the // finding pinned below.) let budget = 12; let deep = 1e-9; let outcome = if coupler == "iqn" { rtx_fsi::IqnIls::new(budget, deep) .unwrap() .solve(&d_pred, pass) } else { rtx_fsi::Subiterated::aitken(budget, deep) .unwrap() .solve(&d_pred, pass) }; drop(outcome); // converged or budget-exhausted — the trace has the data let trace = trace.into_inner(); let min = trace.iter().copied().fold(f64::MAX, f64::min); let last = *trace.last().unwrap(); println!( " stall subcycle {subcycle} {coupler}: {} passes, residual first {:.3e} \ min {min:.3e} last {last:.3e}", trace.len(), trace.first().unwrap() ); (min, last, trace.len()) }; // The committed set: the baseline floor, the smoothed floor (the // attribution guard), the cross-subcycle scan (recorded, unpinned — // see the module docs for why it does not transfer), and the stall // levels per subcycle and coupler. let (floor_base, _) = measure(&mut harness, &mut solver, base_subcycle, 0.0, true); let (floor_smoothed, _) = measure(&mut harness, &mut solver, base_subcycle, 2.0, false); let (floor_tight_scan, _) = measure(&mut harness, &mut solver, base_subcycle / 4, 0.0, true); let stall_8_aitken = stall(&mut harness, &mut solver, base_subcycle, "aitken"); let stall_8_iqn = stall(&mut harness, &mut solver, base_subcycle, "iqn"); let stall_2_aitken = stall(&mut harness, &mut solver, base_subcycle / 4, "aitken"); let stall_2_iqn = stall(&mut harness, &mut solver, base_subcycle / 4, "iqn"); println!( " floors: scan base {floor_base:.3e}, smoothed(2h) {floor_smoothed:.3e}, \ subcycle/4 scan {floor_tight_scan:.3e} (trend-contaminated, unpinned); \ stalls (min): s8 aitken {:.3e} / iqn {:.3e}, s2 aitken {:.3e} / iqn {:.3e}; \ total {:.0} s wall", stall_8_aitken.0, stall_8_iqn.0, stall_2_aitken.0, stall_2_iqn.0, start.elapsed().as_secs_f64() ); for value in [ floor_base, floor_smoothed, floor_tight_scan, stall_8_aitken.0, stall_8_iqn.0, stall_2_aitken.0, stall_2_iqn.0, ] { assert!(value.is_finite() && value > 0.0); } // Pins are for the default configuration (FSI2) only. if case == fsi2_harness::FSI2 && ny == 62 && base_subcycle == 8 && (t_probe - 4.0).abs() < 1e-9 && scan_passes == 40 { // The unsmoothed floor at these loads, measured 2026-08-21 as // 3.05e-5. The band is generous (the maximum of 40 samples of a // jump process moves between platforms); leaving it is a // material change to the pass map's continuity either way. assert!( (1.0e-5..8.0e-5).contains(&floor_base), "the subcycle-8 noise floor {floor_base:.3e} left its measured \ band [1e-5, 8e-5] — the pass map's continuity changed" ); // Attribution: smoothing the sampled tractions does NOT move the // floor (measured ratio 1.002) — the flip noise is coherent // through the fluid field. If this ratio ever leaves [0.5, 2], // the noise has moved into the sampling channel and the // smoothing lever is worth revisiting. let attribution = floor_smoothed / floor_base; assert!( (0.5..2.0).contains(&attribution), "smoothing changed the floor by x{attribution:.2} — the noise \ channel attribution (coherent-through-the-fluid) no longer holds" ); // The stall measurement, 2026-08-21: on a clean release step the // subiteration converges DEEP at both subcycles — s8 aitken // 3.4e-9 / iqn 1.6e-9 (12-pass budget), s2 aitken 6.5e-10 / iqn // 6.3e-10 (converged below 1e-9 in 5-6 passes). The flip-scan // jumps are events at specific geometries, not a floor under // every step: a typical step's map is locally smooth far below // any march tolerance, and the tenth session's subcycle-2 blowup // (tolerance 2e-4 held fixed as dt_c shrank, wall-velocity noise // = tol / dt_c) was a tolerance mis-budgeting, not an // impassable floor. If any of these stalls rises above 1e-7, // the step map's local smoothness is gone and the tight-coupling // tolerance budget must be re-measured before the next ladder. for (label, value) in [ ("s8 aitken", stall_8_aitken.0), ("s8 iqn", stall_8_iqn.0), ("s2 aitken", stall_2_aitken.0), ("s2 iqn", stall_2_iqn.0), ] { assert!( value < 1e-7, "{label} stall {value:.3e} rose above 1e-7 — the release \ step's local smoothness is gone; re-measure the \ tight-coupling tolerance budget" ); } } }