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
442 lines
19 KiB
Rust
442 lines
19 KiB
Rust
//! 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::<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(¤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<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"
|
||
);
|
||
}
|
||
}
|
||
}
|