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

442 lines
19 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.
//! 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
//! 13 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 56 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 03 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::<f64>().sqrt()
}
fn sub(a: &[f64], b: &[f64]) -> Vec<f64> {
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<f64> = 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<f64> {
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<f64> = 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<f64> = pattern.iter().map(|p| eps * p).collect();
let current = pass(&d);
let jump = norm(&sub(&current, &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<f64> {
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::<f64>::new());
let harness_ref = &*harness;
let pass = |d_candidate: &[f64]| -> Vec<f64> {
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"
);
}
}
}