rtx-fsi: the noise floor interrogated — smoothing refuted, IQN-ILS lands, tight coupling reopened
Performance Benchmarks / Run Benchmarks (push) Canceled after 0s
CI / Format Check (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
Documentation / Build API Documentation (push) Canceled after 0s
Documentation / Build User Guide (push) Canceled after 0s

The tenth session ended on "lower the interface noise floor". This
builds the levers and measures them, and the measurements overturn the
diagnosis:

- smooth_tractions: arclength moving average over the wetted surface,
  area-weighted, smooth normal-similarity factor so corners do not mix
  and the smoothed load stays continuous in the geometry. Nine unit
  tests. MEASURED NEGATIVE RESULT: the flip-scan floor is unchanged to
  0.2% at radii 1-3h — the flip's load jump is coherent through the
  fluid field (mask rebuild shifts the pressure around the flipped
  cell), and a surface average preserves coherent shifts. Default off;
  the probe pins the attribution so nobody re-reaches for this lever.

- IqnIls: interface quasi-Newton with inverse least squares (Degroote
  2009) — filtered MGS least squares over secant columns (filter
  RELATIVE to column norm), cross-step history reuse, per-step
  set_tolerance. Model-map tests: exact on anisotropic linear maps
  within dim+2 passes (scalar Aitken provably cannot be), scale
  invariant, history reuse shortens the next step, stalls at the noise
  scale instead of diverging (fixture lesson: per-pass noise, not
  state-dependent noise — the latter has a genuine fixed point).

- tests/fsi2_harness/: the FSI2 machinery extracted shared; verified
  pure code motion (committed release response reproduced to every
  printed digit). March gains RTX_FSI2_SMOOTH / RTX_FSI2_COUPLER=iqn /
  RTX_FSI2_REUSE knobs; pinned bands guard the default configuration.

- tests/fsi2_interface_noise.rs: the probe. Flip-scan floor at
  subcycle 8: 3.05e-5 (pinned); smoothing attribution pinned; the
  cross-subcycle scan recorded but unpinned (the fixed geometry
  increment's wall-velocity trend, increment/dt_c, swamps the flip
  signal at small dt_c — a dt_c^2 scaling hypothesis died in that
  operationalization). THE OPERATIONAL FLOOR — the real release step
  subiterated at tolerance 1e-9 with residuals traced — converges DEEP
  at both subcycles: s8 aitken 3.4e-9 / iqn 1.6e-9, s2 both ~6.4e-10
  in 5-6 passes. The flip jumps are events at specific geometries, not
  a floor under every step: the tenth session's subcycle-2 blowup was
  tolerance mis-budgeting (2e-4 held fixed while dt_c shrank), not an
  impassable floor. Probe bug found and fixed on the way: stale shared
  geometry leaked a 4.5e-5 phantom first residual into the first stall
  run; every measurement now resets the geometry on entry.

All 924+17 tests green: lib 44 (was 27), piston 2, curved edge 1,
FSI1, the committed FSI2 march (release response identical), the probe.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Lnyrw33Lu6rUhW42E9KHwq
This commit is contained in:
Omar Sobh
2026-08-21 06:42:05 -07:00
co-authored by Claude Fable 5
parent c36cf2f8a7
commit 140310b223
6 changed files with 1842 additions and 470 deletions
@@ -24,6 +24,11 @@
//! 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) and fluid configuration live in `fsi2_harness/`; 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
@@ -90,42 +95,24 @@
//! `RTX_FSI2_RTOL` (interface tolerance floor and its
//! relative-to-increment part), `RTX_FSI2_MAXSUB` (Aitken 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 std::cell::RefCell;
use std::io::Write as _;
use std::sync::{Arc, RwLock};
use fsi2_harness::{Fsi2Harness, crossing_frequency, env_or, mid_amp};
use nalgebra::Vector3;
use rtx_cfd::CfdConfig;
use rtx_cfd::solvers::incompressible::{
AleBoundaries, ConvectionScheme, EmbeddedBody, EmbeddedParameters, EmbeddedPisoSolver,
FlowField, PoissonSolverKind, SideBoundary, polygon_interface_velocity,
polygon_signed_distance,
};
use rtx_fea::analysis::{
AnalysisConfig, ConvergenceCriteria, DynamicState, NonlinearDynamicAnalysis,
};
use rtx_fea::assembly::dof_mapping::DofComponent;
use rtx_fea::boundary::dirichlet::{DirichletBC, DirichletType};
use rtx_fea::boundary::{BoundaryCondition, BoundaryConditionSet, SpatialFunction};
use rtx_fea::materials::{LinearElastic, MaterialDatabase};
use rtx_fea::mesh::{Element, ElementType, MaterialId, Mesh, Node, NodeId};
use rtx_fsi::{FluidFace, Subiterated, WettedSurface};
const L: f64 = 2.5;
const H: f64 = 0.41;
const RHO_F: f64 = 1000.0;
const NU_F: f64 = 1e-3;
const U_MEAN: f64 = 1.0;
const RHO_S: f64 = 10_000.0;
const E_S: f64 = 1.4e6;
const NU_S: f64 = 0.4;
const FLAG_X0: f64 = 0.25;
const FLAG_X1: f64 = 0.6;
const FLAG_Y0: f64 = 0.19;
const FLAG_Y1: f64 = 0.21;
use rtx_fea::mesh::{MaterialId, NodeId};
use rtx_fsi::{IqnIls, Subiterated};
// FEATFLOW level-4, dt 0.001 reference values.
const REF_UY_MEAN: f64 = 1.30e-3;
@@ -136,221 +123,6 @@ const REF_UX_AMP: f64 = 12.70e-3;
const REF_DRAG_MEAN: f64 = 215.06;
const REF_LIFT_AMP: f64 = 237.8;
fn circle_sdf(x: f64, y: f64) -> f64 {
((x - 0.2).powi(2) + (y - 0.2).powi(2)).sqrt() - 0.05
}
/// The ramped parabolic inflow of the benchmark definition.
fn inflow(y: f64, t: f64) -> f64 {
let ramp = if t < 2.0 {
0.5 * (1.0 - (std::f64::consts::PI * t / 2.0).cos())
} else {
1.0
};
ramp * 1.5 * U_MEAN * y * (H - y) / (0.5 * H).powi(2)
}
fn env_or(name: &str, default: f64) -> f64 {
std::env::var(name)
.map(|v| v.parse().expect(name))
.unwrap_or(default)
}
/// The flag's Quad8 mesh (as in FSI1 and the CSM tests).
fn flag_mesh(nx: usize, ny: usize) -> Mesh {
let mut mesh = Mesh::new(2).unwrap();
let (lx, ly) = (2 * nx + 1, 2 * ny + 1);
let mut grid = vec![vec![None; ly]; lx];
for (i, column) in grid.iter_mut().enumerate() {
for (j, slot) in column.iter_mut().enumerate() {
if i % 2 == 1 && j % 2 == 1 {
continue;
}
let x = FLAG_X0 + (FLAG_X1 - FLAG_X0) * i as f64 / (2 * nx) as f64;
let y = FLAG_Y0 + (FLAG_Y1 - FLAG_Y0) * j as f64 / (2 * ny) as f64;
*slot = Some(mesh.add_node(Node::new_2d(x, y)));
}
}
for i in 0..nx {
for j in 0..ny {
let (a, b) = (2 * i, 2 * j);
let nodes = vec![
grid[a][b].unwrap(),
grid[a + 2][b].unwrap(),
grid[a + 2][b + 2].unwrap(),
grid[a][b + 2].unwrap(),
grid[a + 1][b].unwrap(),
grid[a + 2][b + 1].unwrap(),
grid[a + 1][b + 2].unwrap(),
grid[a][b + 1].unwrap(),
];
mesh.add_element(Element::new(ElementType::Quad8, nodes, MaterialId(0)).unwrap())
.unwrap();
}
}
mesh
}
/// The wetted-interface bookkeeping (FSI1's, plus vertex velocities).
struct Interface {
wetted: Vec<NodeId>,
reference: Vec<(f64, f64)>,
/// Ordered boundary walk: indices into `wetted` (`usize::MAX` marks
/// the fixed anchor vertices inside the cylinder / at the clamp).
walk: Vec<(usize, (f64, f64))>,
}
impl Interface {
fn build(mesh: &Mesh) -> Self {
let eps = 1e-9;
let on_bottom = |p: Vector3<f64>| (p.y - FLAG_Y0).abs() < eps;
let on_top = |p: Vector3<f64>| (p.y - FLAG_Y1).abs() < eps;
let on_tip = |p: Vector3<f64>| (p.x - FLAG_X1).abs() < eps;
let clamped = |p: Vector3<f64>| (p.x - FLAG_X0).abs() < eps;
let mut wetted: Vec<(NodeId, (f64, f64))> = mesh
.nodes
.iter()
.filter(|(_, node)| {
let p = node.position();
(on_bottom(p) || on_top(p) || on_tip(p)) && !clamped(p)
})
.map(|(&id, node)| (id, (node.position().x, node.position().y)))
.collect();
wetted.sort_by_key(|(id, _)| *id);
let index_of = |id: NodeId| wetted.iter().position(|(w, _)| *w == id).unwrap();
let mut bottom: Vec<(NodeId, f64)> = mesh
.nodes
.iter()
.filter(|(_, n)| on_bottom(n.position()) && !clamped(n.position()))
.map(|(&id, n)| (id, n.position().x))
.collect();
bottom.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
let mut tip: Vec<(NodeId, f64)> = mesh
.nodes
.iter()
.filter(|(_, n)| {
let p = n.position();
on_tip(p) && !on_bottom(p) && !on_top(p)
})
.map(|(&id, n)| (id, n.position().y))
.collect();
tip.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
let mut top: Vec<(NodeId, f64)> = mesh
.nodes
.iter()
.filter(|(_, n)| on_top(n.position()) && !clamped(n.position()))
.map(|(&id, n)| (id, n.position().x))
.collect();
top.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
let mut walk: Vec<(usize, (f64, f64))> = Vec::new();
walk.push((usize::MAX, (0.22, FLAG_Y0)));
walk.push((usize::MAX, (FLAG_X0, FLAG_Y0)));
for (id, _) in &bottom {
walk.push((index_of(*id), (0.0, 0.0)));
}
for (id, _) in &tip {
walk.push((index_of(*id), (0.0, 0.0)));
}
for (id, _) in &top {
walk.push((index_of(*id), (0.0, 0.0)));
}
walk.push((usize::MAX, (FLAG_X0, FLAG_Y1)));
walk.push((usize::MAX, (0.22, FLAG_Y1)));
let reference = wetted.iter().map(|(_, p)| *p).collect();
Self {
wetted: wetted.into_iter().map(|(id, _)| id).collect(),
reference,
walk,
}
}
/// Deformed polygon vertices for the interface vector `d`.
fn polygon(&self, d: &[f64]) -> Vec<(f64, f64)> {
self.walk
.iter()
.map(|&(k, anchor)| {
if k == usize::MAX {
anchor
} else {
let (x0, y0) = self.reference[k];
(x0 + d[2 * k], y0 + d[2 * k + 1])
}
})
.collect()
}
/// Per-vertex velocities for the interface velocity vector `ddot`
/// (anchors do not move).
fn walk_velocities(&self, ddot: &[f64]) -> Vec<(f64, f64)> {
self.walk
.iter()
.map(|&(k, _)| {
if k == usize::MAX {
(0.0, 0.0)
} else {
(ddot[2 * k], ddot[2 * k + 1])
}
})
.collect()
}
/// Deformed wetted node positions for the transfer.
fn deformed_nodes(&self, d: &[f64]) -> Vec<Vector3<f64>> {
self.reference
.iter()
.enumerate()
.map(|(k, &(x0, y0))| Vector3::new(x0 + d[2 * k], y0 + d[2 * k + 1], 0.0))
.collect()
}
}
fn clamp_left(mesh: &Mesh) -> BoundaryConditionSet {
let clamped: Vec<NodeId> = mesh
.nodes
.iter()
.filter(|(_, node)| (node.position().x - FLAG_X0).abs() < 1e-9)
.map(|(&id, _)| id)
.collect();
let mut set = BoundaryConditionSet::new();
for component in [DofComponent::DisplacementX, DofComponent::DisplacementY] {
set.add_condition(BoundaryCondition::Dirichlet(DirichletBC {
nodes: clamped.clone(),
components: vec![component],
condition_type: DirichletType::Spatial(SpatialFunction(Box::new(|_| 0.0))),
time_range: None,
ramping_factor: 1.0,
gradual_enforcement: false,
}));
}
set
}
fn mid_amp(series: &[f64]) -> (f64, f64) {
let max = series.iter().copied().fold(f64::MIN, f64::max);
let min = series.iter().copied().fold(f64::MAX, f64::min);
(0.5 * (max + min), 0.5 * (max - min))
}
/// Frequency from linearly interpolated upward crossings of the mean.
fn crossing_frequency(times: &[f64], series: &[f64]) -> Option<f64> {
let (mean, _) = mid_amp(series);
let mut crossings: Vec<f64> = Vec::new();
for k in 1..series.len() {
let (a, b) = (series[k - 1] - mean, series[k] - mean);
if a < 0.0 && b >= 0.0 {
crossings.push(times[k - 1] + (a / (a - b)) * (times[k] - times[k - 1]));
}
}
if crossings.len() < 3 {
return None;
}
Some((crossings.len() - 1) as f64 / (crossings.last().unwrap() - crossings.first().unwrap()))
}
#[test]
#[allow(clippy::too_many_lines)]
fn fsi2_flapping_flag() {
@@ -380,92 +152,16 @@ fn fsi2_flapping_flag() {
let rtol = env_or("RTX_FSI2_RTOL", 1e-2);
let max_subiterations_budget = env_or("RTX_FSI2_MAXSUB", 12.0) as usize;
let flag_nx = env_or("RTX_FSI2_FLAG_NX", 35.0) as usize;
let smooth_in_h = env_or("RTX_FSI2_SMOOTH", 0.0);
let csv_path = std::env::var("RTX_FSI2_CSV").ok();
let h = H / ny as f64;
let nx = (L / h).round() as usize;
let mu = RHO_F * NU_F;
let u_peak = 1.5 * 1.5 * U_MEAN;
// The fluid's explicit limit; the coupling (and the flag's Newmark)
// run at `subcycle` fluid steps per coupled step — the structure and
// the transfer need nowhere near the fluid's dt (CSM3 measured 0.23%
// frequency error at dt = 5e-3; dt_c here is ~2.6e-3 at ny = 62), and
// the per-pass cost is dominated by the structure solve. Within a
// pass the interface geometry is interpolated linearly across the
// substeps, so the mask still moves less than a cell per fluid step.
let dt_fluid = 0.25 / (2.0 * u_peak / h + 4.0 * NU_F / (h * h));
let subcycle = env_or("RTX_FSI2_SUBCYCLE", 8.0) as usize;
let (harness, mut solver, mut field) = Fsi2Harness::build(ny, flag_nx, smooth_in_h);
let dt_fluid = harness.dt_fluid;
let dt = dt_fluid * subcycle as f64;
let mesh = flag_mesh(flag_nx, 2);
let interface = Interface::build(&mesh);
let a_node = mesh
.nodes
.iter()
.find(|(_, n)| (n.position().x - 0.6).abs() < 1e-9 && (n.position().y - 0.2).abs() < 1e-9)
.map(|(&id, _)| id)
.expect("point A");
// The deformable geometry AND its velocity, behind one lock: the
// fluid's per-step mask rebuild reads the polygon; the no-slip closure
// reads both.
let interface = &harness.interface;
let zero_d = vec![0.0; 2 * interface.wetted.len()];
let shared = Arc::new(RwLock::new((
interface.polygon(&zero_d),
interface.walk_velocities(&zero_d),
)));
let sdf_shared = shared.clone();
let vel_shared = shared.clone();
let config = CfdConfig::new()
.with_density(RHO_F)
.with_viscosity(mu)
.with_reference_velocity(U_MEAN)
.with_reference_length(0.1);
let params = EmbeddedParameters {
corrector_steps: 2,
tolerance: 1e-7,
boundaries: AleBoundaries {
left: SideBoundary::Velocity,
right: SideBoundary::PressureOutlet,
bottom: SideBoundary::Velocity,
top: SideBoundary::Velocity,
},
poisson_solver: PoissonSolverKind::Multigrid,
// TVD, deliberately: FSI2 marches in time and needs the shedding
// physics upwind's numerical viscosity killed on these grids
// (CFD3's finding). The limiter chatter that defeats steady fixed
// points (FSI1's finding) is harmless here — each step's fixed
// point is the interface displacement of THAT step, not a steady
// load.
convection_scheme: ConvectionScheme::TvdVanAlbada,
};
let mut solver = EmbeddedPisoSolver::new(config, params).unwrap();
solver.set_boundary_velocity(|x, y, t| {
if x <= 0.0 {
(inflow(y, t), 0.0)
} else {
(0.0, 0.0)
}
});
solver.set_moving_body(
EmbeddedBody::from_sdf(move |x, y, _| {
let geometry = sdf_shared.read().unwrap();
circle_sdf(x, y).min(polygon_signed_distance(&geometry.0, x, y))
})
.with_surface_velocity(move |x, y, _| {
let geometry = vel_shared.read().unwrap();
if circle_sdf(x, y) <= polygon_signed_distance(&geometry.0, x, y) {
(0.0, 0.0)
} else {
polygon_interface_velocity(&geometry.0, &geometry.1, x, y)
}
}),
);
// Start at rest; the ramp brings the inflow up from zero.
let mut field = FlowField::new(nx, ny, h, h).unwrap();
solver.initialize(&mut field).unwrap();
// Phase 1: rigid flag to t_release.
let start = std::time::Instant::now();
@@ -473,43 +169,10 @@ fn fsi2_flapping_flag() {
for _ in 0..rigid_steps {
futures::executor::block_on(solver.advance(&mut field, dt_fluid)).unwrap();
}
// Surface drag and lift on cylinder + flag at the current geometry.
let measure_force = |solver: &EmbeddedPisoSolver, field: &FlowField| -> (f64, f64) {
let mask = solver.mask().unwrap();
let body = solver.body().unwrap();
let vertices = shared.read().unwrap().0.clone();
let mut drag = 0.0;
let mut lift = 0.0;
let poly_probe = EmbeddedBody::polygon(vertices.clone());
for s in poly_probe.surface_samples(0.5 * h) {
if circle_sdf(s.x, s.y) < 1e-9 {
continue;
}
if let Some((tx, ty)) = mask.traction_at(
body, &field.u, &field.v, &field.p, mu, 0.0, s.x, s.y, s.nx, s.ny,
) {
drag += tx * s.ds;
lift += ty * s.ds;
}
}
let circle_probe = EmbeddedBody::circle(0.2, 0.2, 0.05);
for s in circle_probe.surface_samples(0.5 * h) {
if polygon_signed_distance(&vertices, s.x, s.y) < 1e-9 {
continue;
}
if let Some((tx, ty)) = mask.traction_at(
body, &field.u, &field.v, &field.p, mu, 0.0, s.x, s.y, s.nx, s.ny,
) {
drag += tx * s.ds;
lift += ty * s.ds;
}
}
(drag, lift)
};
// The fluid harness check: surface drag on cylinder + flag near the
// CFD2 value this solver measured on this geometry (ny = 62: ~121;
// the reference is 136.700 with the boundary layer barely a cell).
let (rigid_drag, rigid_lift) = measure_force(&solver, &field);
let (rigid_drag, rigid_lift) = harness.measure_force(&solver, &field);
println!(
" rigid phase: {rigid_steps} steps to t = {t_release:.1} s in {:.0} s wall; \
surface drag {rigid_drag:.1} (CFD2 ref 136.7, this grid measured ~121), \
@@ -521,7 +184,7 @@ fn fsi2_flapping_flag() {
let mut db = MaterialDatabase::new();
db.add_material(
MaterialId(0),
LinearElastic::new(E_S, NU_S).with_density(RHO_S),
LinearElastic::new(fsi2_harness::E_S, fsi2_harness::NU_S).with_density(fsi2_harness::RHO_S),
None,
);
// A deep Newton budget: a mid-swing subiteration can hand the flag a
@@ -529,9 +192,9 @@ fn fsi2_flapping_flag() {
// within a period); typical steps converge in 1-2 iterations, and a
// t = 25.8 s failure at the default budget of 25 is what set this.
let analysis = NonlinearDynamicAnalysis::new(
mesh.clone(),
harness.mesh.clone(),
db,
clamp_left(&mesh),
fsi2_harness::clamp_left(&harness.mesh),
dt,
1,
AnalysisConfig::default(),
@@ -550,7 +213,7 @@ fn fsi2_flapping_flag() {
[dofs[0], dofs[1]]
})
.collect();
let a_dofs = flag.borrow().node_dofs(a_node);
let a_dofs = flag.borrow().node_dofs(harness.a_node);
let extract = |state: &DynamicState| -> Vec<f64> {
let mut d = vec![0.0; 2 * wetted_dofs.len()];
for (k, dofs) in wetted_dofs.iter().enumerate() {
@@ -560,88 +223,10 @@ fn fsi2_flapping_flag() {
d
};
// Tractions on the flag's wetted surface for a given geometry, from
// the solver's current field/mask; returns the transferred nodal
// forces, the conservation defect, and the samples dropped (probe
// failures plus spike rejections).
let spiked_total = std::cell::Cell::new(0usize);
let sample_load = |solver: &EmbeddedPisoSolver,
field: &FlowField,
d: &[f64]|
-> (Vec<(NodeId, Vector3<f64>)>, f64, usize) {
let vertices = interface.polygon(d);
let poly_probe = EmbeddedBody::polygon(vertices);
let mask = solver.mask().unwrap();
let body = solver.body().unwrap();
let mut faces = Vec::new();
let mut tractions: Vec<Vector3<f64>> = Vec::new();
let mut skipped = 0usize;
for s in poly_probe.surface_samples(0.5 * h) {
if circle_sdf(s.x, s.y) < 1e-9 {
continue; // buried in the cylinder
}
match mask.traction_at(
body, &field.u, &field.v, &field.p, mu, 0.0, s.x, s.y, s.nx, s.ny,
) {
Some((tx, ty)) => {
faces.push(FluidFace {
centroid: Vector3::new(s.x, s.y, 0.0),
normal: Vector3::new(s.nx, s.ny, 0.0),
area: s.ds,
});
tractions.push(Vector3::new(tx, ty, 0.0));
}
None => skipped += 1,
}
}
// Spike guard: a near-degenerate reconstruction can return a
// finite but wild traction (the linear-fit condition sits just
// above its truncation threshold at concave junctions). CLAMP
// samples to 20x the median magnitude, keeping their direction —
// the physical load varies smoothly along the surface — and COUNT
// them: a non-zero count is a measurement of the pathology, not a
// silent repair. Clamping, not dropping: a hard drop threshold
// makes the coupling pass discontinuous in the candidate geometry
// (a boundary sample flips in/out of the kept set between
// subiterations, and the load jumps by the spike magnitude —
// measured as a residual bouncing at the scale of the step
// increment); the clamp is continuous.
let mut magnitudes: Vec<f64> = tractions.iter().map(nalgebra::Vector3::norm).collect();
magnitudes.sort_by(|a, b| a.partial_cmp(b).unwrap());
let median = magnitudes.get(magnitudes.len() / 2).copied().unwrap_or(0.0);
if median > 0.0 {
let cap = 20.0 * median;
for traction in &mut tractions {
let norm = traction.norm();
if norm > cap {
*traction *= cap / norm;
spiked_total.set(spiked_total.get() + 1);
}
}
}
let nodes_now = interface.deformed_nodes(d);
let surface = WettedSurface::build(&faces, &nodes_now).expect("transfer build");
let nodal = surface.transfer_load(&faces, &tractions).unwrap();
let total_sampled: Vector3<f64> =
faces.iter().zip(&tractions).map(|(f, t)| t * f.area).sum();
let total_nodal: Vector3<f64> = nodal.iter().sum();
let conservation = (total_nodal - total_sampled).norm() / total_sampled.norm().max(1e-30);
(
interface
.wetted
.iter()
.zip(nodal)
.map(|(&id, f)| (id, f))
.collect(),
conservation,
skipped,
)
};
// Phase 2: release. The flag starts at rest under the current fluid
// load (consistent initial acceleration — the step response about the
// steady deflection is the seed perturbation for the instability).
let (nodal0, conservation0, _) = sample_load(&solver, &field, &zero_d);
let (nodal0, conservation0, _) = harness.sample_load(&solver, &field, &zero_d);
flag.borrow_mut().set_nodal_forces(&nodal0);
let mut flag_state = flag.borrow_mut().rest_state().unwrap();
let mut committed_nodal = nodal0;
@@ -650,6 +235,16 @@ fn fsi2_flapping_flag() {
let solver = RefCell::new(solver);
let field = RefCell::new(field);
// The interface driver: per-step Aitken (the tenth-session default),
// or a persistent IQN-ILS whose secant history carries across steps.
let coupler_kind = std::env::var("RTX_FSI2_COUPLER").unwrap_or_else(|_| "aitken".into());
let reuse = env_or("RTX_FSI2_REUSE", 2.0) as usize;
let mut iqn = (coupler_kind == "iqn").then(|| {
IqnIls::new(max_subiterations_budget, 1.0)
.unwrap()
.with_reuse(reuse)
});
let coupled_steps = ((t_end - t_release) / dt).round() as usize;
let mut times = Vec::with_capacity(coupled_steps);
let mut ux_series = Vec::with_capacity(coupled_steps);
@@ -676,7 +271,7 @@ fn fsi2_flapping_flag() {
let fluid_saved = solver.borrow().snapshot();
let field_saved = field.borrow().clone();
type PassResult = (
FlowField,
rtx_cfd::solvers::incompressible::FlowField,
DynamicState,
Vec<(NodeId, Vector3<f64>)>,
f64,
@@ -685,36 +280,23 @@ fn fsi2_flapping_flag() {
let latest: RefCell<Option<PassResult>> = RefCell::new(None);
let pass = |d_candidate: &[f64]| -> Vec<f64> {
// Interface velocity of THIS candidate, constant over the step.
let ddot: Vec<f64> = d_candidate
.iter()
.zip(&d_n)
.map(|(new, old)| (new - old) / dt)
.collect();
// Subcycled fluid steps from the SAME start-of-step state,
// geometry interpolated to each substep's end time.
// geometry interpolated to each substep's end time, interface
// velocity of THIS candidate constant over the step.
let mut solver_ref = solver.borrow_mut();
solver_ref.restore(&fluid_saved);
let mut trial_field = field_saved.clone();
for m in 1..=subcycle {
let fraction = m as f64 / subcycle as f64;
let d_sub: Vec<f64> = d_n
.iter()
.zip(d_candidate)
.map(|(old, new)| old + fraction * (new - old))
.collect();
{
let mut geometry = shared.write().unwrap();
geometry.0 = interface.polygon(&d_sub);
geometry.1 = interface.walk_velocities(&ddot);
}
futures::executor::block_on(solver_ref.advance(&mut trial_field, dt_fluid))
.unwrap();
}
harness.advance_subcycled(
&mut solver_ref,
&mut trial_field,
&d_n,
d_candidate,
subcycle,
);
// Load on the candidate geometry, flag answers from the
// committed state.
let (nodal, conservation, skipped) =
sample_load(&solver_ref, &trial_field, d_candidate);
harness.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();
@@ -731,8 +313,15 @@ fn fsi2_flapping_flag() {
.sum::<f64>()
.sqrt();
let tol_step = tol_floor.max(rtol * increment);
let mut scheme = Subiterated::aitken(max_subiterations_budget, tol_step).unwrap();
match scheme.solve(&d_predicted, pass) {
let outcome = if let Some(iqn) = iqn.as_mut() {
iqn.set_tolerance(tol_step).unwrap();
iqn.solve(&d_predicted, pass)
} else {
Subiterated::aitken(max_subiterations_budget, tol_step)
.unwrap()
.solve(&d_predicted, pass)
};
match outcome {
Ok(converged) => {
total_subiterations += converged.iterations;
max_subiterations = max_subiterations.max(converged.iterations);
@@ -775,7 +364,7 @@ fn fsi2_flapping_flag() {
ux_series.push(ux);
uy_series.push(uy);
if (step + 1) % 10 == 0 {
let (drag, lift) = measure_force(&solver.borrow(), &field.borrow());
let (drag, lift) = harness.measure_force(&solver.borrow(), &field.borrow());
force_times.push(t);
drag_series.push(drag);
lift_series.push(lift);
@@ -839,7 +428,7 @@ fn fsi2_flapping_flag() {
(ref {:.2} ± {:.1}), ux(A) = {:.4} ± {:.4} mm (ref {:.2} ± {:.2}), f = {} Hz \
(ref {REF_UY_FREQ}); onset amp {:.3e} -> {:.3e} m",
elapsed,
spiked_total.get(),
harness.spiked_total.get(),
t_window.first().unwrap_or(&t_release),
uy_mid * 1e3,
uy_amp * 1e3,
@@ -883,7 +472,16 @@ fn fsi2_flapping_flag() {
// 81.6 mm is not reached by this coupling; the measured state is the
// wake-forced 3.73 Hz / ±17.3 mm cycle at BOTH grids). If a change
// moves these numbers, that is a finding either way and must be loud.
if ny == 62 && flag_nx == 35 && (t_end - 7.0).abs() < 1e-9 && (t_release - 6.0).abs() < 1e-9 {
// The bands pin the UNSMOOTHED, Aitken-coupled sampling (the
// defaults): smoothing or the IQN coupler change the load path and
// re-pin deliberately.
let default_coupling = smooth_in_h == 0.0 && coupler_kind == "aitken";
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
@@ -898,7 +496,7 @@ fn fsi2_flapping_flag() {
"uy release-response amp {uy_amp:.4e} outside the measured band \
[2.4e-3, 5.2e-3]"
);
} else if t_end >= 25.0 {
} 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).