rtx-fsi: C3 — FSI3 opened: the added-mass regime, its impulse artefact, and the coupler hygiene it demanded
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 march is extracted from the FSI2 test into a shared
tests/fsi2_harness/march.rs (MarchConfig from env / MarchResult /
window statistics), the harness parameterised by BenchmarkCase (FSI2,
FSI3: inflow, solid density, modulus, rigid-flag drag reference), and
turek_hron_fsi3.rs written on top (Re 200, density ratio 1, E 5.6e6).
The committed FSI2 default is bit-identical through all of it —
every new knob defaults to FSI2's behaviour; re-verified twice to every
printed digit (uy 3.7732 / 3.7920 mm).

FSI3's first contact, traced pass by pass (RTX_FSI3_TRACE), exposed
the added-mass instability in its purest form and, one measured
mechanism at a time, what a partitioned coupling at unit density ratio
needs:

- C^1 interface motion (MarchConfig::c1_interface): a constant
  per-step interface velocity is a velocity JUMP at each step start,
  and the incompressible fluid answers with an impulsive added-mass
  load ~ rho L dv / dt_fluid (8x the physical reaction under
  subcycling): 1,600 N at release, 48,000 N and a 59 mm response one
  step later, the flag's Newton dead the pass after. Constant
  acceleration from the previous end velocity to 2 dd/dt - v_n removes
  the impulse (loads 1,700-2,400 N).
- IQN first-pass relaxation as a knob (initial_relaxation, FSI3 0.05):
  |1 - omega (1 + g)| must contract; 0.5 diverges past gain 3.
- The divergence verdict waits for the secant (IqnIls): the exploratory
  second pass on a high-gain map legitimately overshoots 10x before the
  first secant column exists. Pinned by a gain-40 model test.
- Kinematic predictor (predictor: "kinematic", velocity only): the
  structure-alone predictor ignores an added mass comparable to the
  flag's and overshoots 2-5x, drawing 5-6x loads every first pass; and
  NOT with the acceleration — Newmark average acceleration carries an
  inconsistent initial acceleration as a sign-alternating mode
  (d + dt v + dt^2 a / 2 predicted 22 mm at release; converged 0.14).
- Quiescent release (quiescent_release): the structure-alone
  "consistent" initial acceleration M^-1 F ignores the added mass.
- Coupler hygiene (IqnIls): a stalled or diverged step's secant columns
  are no longer retained (a bistable mask flip's columns extrapolated a
  30 mm interface jump on the next step); two-window stagnation
  detection reports a plateau early instead of bouncing to the budget
  (a single-window test misjudged a slowly converging step and is
  recorded as such); trust region tightened to 10x the residual. A
  noise-column filter at the tolerance was measured to HURT (stalled a
  converging step at 5.5e-4) and is disabled (threshold 0).
- The floor measured, not borrowed (fsi2_interface_noise.rs gains
  RTX_NOISE_CASE=fsi3): flip jumps 3.6e-4 (12x FSI2's), the subcycle-8
  release map stalling near 1e-6, the subcycle-2 map converging to
  9e-10; and through the release transient (flag at ~0.3 m/s) the
  subcycle-8 floor rides up to ~1e-3 with the motion — which moved the
  FSI3 default to subcycle 2 at a 3e-5 floor.

FSI3 committed default (ny 62, release t = 4, t_end 4.2, subcycle 2):
581 steps, 3.8 subit/step (max 8), 0 stalls, 0 retries, conservation
4.3e-13; uy 10.1 +- 27.5 mm and ux -3.1 +- 3.2 mm over the first 0.2 s
(reference cycle 1.45 +- 34.90, -2.86 +- 2.70). Machinery invariants
pinned; physics bands await the study record. Rigid-flag drag 426.9 vs
CFD3's 439.45 (-2.9%).

The FSI2 mode-2 study pin (IQN / subcycle 2, t_end >= 16) was measured
with the pre-hygiene coupler; re-verify on the next s = 2 study run.

48 lib tests green, clippy clean.

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-23 19:43:02 -05:00
co-authored by Claude Fable 5
parent be04e0e233
commit c0666bf22a
6 changed files with 1155 additions and 450 deletions
@@ -4,7 +4,7 @@
//! 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.001): `ux(A) = 14.85 ± 12.70 mm [3.86 Hz]`,
//! 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`.
//!
@@ -12,9 +12,9 @@
//!
//! 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
//! with Aitken 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
//! 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
@@ -25,9 +25,10 @@
//! 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.
//! 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)
//!
@@ -40,23 +41,19 @@
//! (consistent initial acceleration), and the coupled march runs to
//! `t_end`.
//!
//! # What the 2026-08-21 study measured (t = 30 s marches, release)
//! # What the 2026-08-21 study measured at the loose default (t = 30 s)
//!
//! **The coupled system self-excites at every configuration tried, and at
//! the loosely-coupled default (8 fluid substeps per coupled step, ~1
//! subiteration) it 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, and
//! protocol-independent (release at t = 6 and coupled-from-t = 0 reach
//! the same state). 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).
//! Loads at ny = 82: drag 141.6 ± 53.9 (ref 215.06 ± 77.65), lift
//! 49 ± 508 (ref 0.61 ± 237.8) — consistent with the small-amplitude
//! state.
//! **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
@@ -92,13 +89,13 @@
//! 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.
//! 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),
@@ -111,10 +108,10 @@
//!
//! 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 30), `RTX_FSI2_SUBCYCLE`
//! 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` (Aitken budget,
//! 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`
@@ -123,19 +120,10 @@
mod fsi2_harness;
use std::cell::RefCell;
use std::io::Write as _;
use fsi2_harness::FSI2;
use fsi2_harness::march::{MarchConfig, run_march};
use fsi2_harness::{Fsi2Harness, crossing_frequency, env_or, mid_amp};
use nalgebra::Vector3;
use rtx_fea::analysis::{
AnalysisConfig, ConvergenceCriteria, DynamicState, NonlinearDynamicAnalysis,
};
use rtx_fea::materials::{LinearElastic, MaterialDatabase};
use rtx_fea::mesh::{MaterialId, NodeId};
use rtx_fsi::{IqnIls, Subiterated};
// FEATFLOW level-4, dt 0.001 reference values.
// 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;
@@ -145,380 +133,106 @@ const REF_DRAG_MEAN: f64 = 215.06;
const REF_LIFT_AMP: f64 = 237.8;
#[test]
#[allow(clippy::too_many_lines)]
fn fsi2_flapping_flag() {
let ny = env_or("RTX_FSI2_NY", 62.0) as usize;
let t_release = env_or("RTX_FSI2_T_RELEASE", 6.0);
let t_end = env_or("RTX_FSI2_T_END", 7.0);
// Per-step interface tolerance: max(absolute floor, RTOL x that step's
// interface increment). The floor is measured, not wished, and it
// RIDES WITH THE LOADS: the interface map carries a noise floor from
// discrete mask flips under vanishing geometry changes (each flip's
// load jump maps through Newmark's beta dt^2 / m into displacement) —
// measured ~5.8e-7 per pass at 2% inflow and ~1.3e-4 at full inflow
// on ny = 62. Sub-cell interface accuracy is not the fluid's to
// promise. A step that stalls at the floor — including an Aitken
// "divergence" verdict within 5x of the tolerance, which is noise
// bouncing over a well-predicted (tiny) first residual, not added
// mass (the ratio-10 flag's per-node added-mass gain is far below
// one) — is ACCEPTED at its last candidate and counted
// (`stalled_steps`), never silently retried to a budget: the count
// and the worst stalled residual are reported and bounded at the
// end. Genuine runaway (residual far beyond the noise scale) still
// panics. At the limit cycle the floor is ~1-2% of the per-step
// interface increment, so the committed trajectory carries
// noise-level interface error each step — recorded, and bounded by
// the two-grid amplitude rule before belief.
let tol_floor = env_or("RTX_FSI2_TOL", 2e-4);
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 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 interface = &harness.interface;
let zero_d = vec![0.0; 2 * interface.wetted.len()];
// Phase 1: rigid flag to t_release.
let start = std::time::Instant::now();
let rigid_steps = (t_release / dt_fluid).round() as usize;
for _ in 0..rigid_steps {
futures::executor::block_on(solver.advance(&mut field, dt_fluid)).unwrap();
}
// 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) = 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), \
lift {rigid_lift:.1}",
start.elapsed().as_secs_f64()
);
// The flag: nonlinear Newmark stepper at the coupled dt.
let mut db = MaterialDatabase::new();
db.add_material(
MaterialId(0),
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
// large sudden load change (the coupled lift swings hundreds of N
// 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(
harness.mesh.clone(),
db,
fsi2_harness::clamp_left(&harness.mesh),
dt,
1,
AnalysisConfig::default(),
)
.with_total_lagrangian()
.with_convergence_criteria(ConvergenceCriteria {
max_iterations: 60,
..ConvergenceCriteria::default()
});
let flag = RefCell::new(analysis.stepper().unwrap());
let wetted_dofs: Vec<[usize; 2]> = interface
.wetted
.iter()
.map(|&id| {
let dofs = flag.borrow().node_dofs(id);
[dofs[0], dofs[1]]
})
.collect();
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() {
d[2 * k] = state.displacement[dofs[0]];
d[2 * k + 1] = state.displacement[dofs[1]];
}
d
};
// 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, _) = 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;
let mut worst_conservation = conservation0;
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);
let mut uy_series = Vec::with_capacity(coupled_steps);
let mut total_subiterations = 0usize;
let mut max_subiterations = 0usize;
let mut total_skipped = 0usize;
let mut stalled_steps = 0usize;
let mut retried_steps = 0usize;
let mut worst_stall = 0.0f64;
let mut force_times: Vec<f64> = Vec::new();
let mut drag_series: Vec<f64> = Vec::new();
let mut lift_series: Vec<f64> = Vec::new();
let mut csv = csv_path.map(|p| std::fs::File::create(p).expect("csv path"));
let phase_start = std::time::Instant::now();
for step in 0..coupled_steps {
let d_n = extract(&flag_state);
// Predictor: the structure alone under the committed load.
flag.borrow_mut().set_nodal_forces(&committed_nodal);
let (predicted, _) = flag.borrow_mut().step(&flag_state).unwrap();
let d_predicted = extract(&predicted);
let fluid_saved = solver.borrow().snapshot();
let field_saved = field.borrow().clone();
type PassResult = (
rtx_cfd::solvers::incompressible::FlowField,
DynamicState,
Vec<(NodeId, Vector3<f64>)>,
f64,
usize,
);
let latest: RefCell<Option<PassResult>> = RefCell::new(None);
let pass = |d_candidate: &[f64]| -> Vec<f64> {
// Subcycled fluid steps from the SAME start-of-step state,
// 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();
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) =
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();
let d_new = extract(&candidate_state);
*latest.borrow_mut() =
Some((trial_field, candidate_state, nodal, conservation, skipped));
d_new
};
let increment: f64 = d_predicted
.iter()
.zip(&d_n)
.map(|(a, b)| (a - b) * (a - b))
.sum::<f64>()
.sqrt();
let tol_step = tol_floor.max(rtol * increment);
let mut 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)
};
// Stale-history recovery: cross-step secant columns assume the
// interface Jacobian drifts slowly, and during a rapid resonant
// growth they can steer the quasi-Newton update into an
// overshoot the divergence guard reads as added mass (measured:
// a first residual of 1e-4 driven to 1e-3 by the first update at
// 2.7x the previously seen amplitude, killing a t = 11.1 s
// march). The map itself converges deeply from a clean start
// (the noise probe's stall measurement), so: reset the history
// and retry the step ONCE from the predictor. Aitken carries no
// history — a retry would repeat the identical iteration, so
// this path is IQN-only.
if let (Err(e), Some(iqn_ref)) = (&outcome, iqn.as_mut()) {
let recoverable = matches!(
e,
rtx_fsi::FsiError::CouplingNotConverged { residual, .. }
| rtx_fsi::FsiError::CouplingDiverged { residual, .. }
if *residual >= (5.0 * tol_step).max(0.1 * increment)
);
if recoverable {
iqn_ref.reset_history();
retried_steps += 1;
outcome = iqn_ref.solve(&d_predicted, pass);
}
}
match outcome {
Ok(converged) => {
total_subiterations += converged.iterations;
max_subiterations = max_subiterations.max(converged.iterations);
}
Err(
rtx_fsi::FsiError::CouplingNotConverged {
iterations,
residual,
..
}
| rtx_fsi::FsiError::CouplingDiverged {
iterations,
residual,
},
) if residual < (5.0 * tol_step).max(0.1 * increment) => {
// The noise floor, not divergence: accept the last
// candidate, count it, and bound it at the end. The
// second bound is for the rare violent step near peak
// motion (the s = 1 run died at residual = 9% of its own
// increment after retrying): a residual an order below
// the step's own physical increment is an occasional
// acceptable error, counted like every stall and bounded
// by the stall-fraction assert — a SYSTEMATIC scatter at
// that scale would trip it.
stalled_steps += 1;
worst_stall = worst_stall.max(residual);
total_subiterations += iterations;
max_subiterations = max_subiterations.max(iterations);
}
Err(e) => panic!("coupling failed at step {step}: {e:?}"),
}
// `latest` holds the response to the accepted interface (the last
// pass) — commit it directly; the fluid, mask and flag are
// consistent with that interface without an extra pass.
let (new_field, new_flag_state, nodal, conservation, skipped) =
latest.borrow_mut().take().expect("pass ran");
*field.borrow_mut() = new_field;
flag_state = new_flag_state;
committed_nodal = nodal;
worst_conservation = worst_conservation.max(conservation);
total_skipped += skipped;
let t = t_release + (step + 1) as f64 * dt;
let ux = flag_state.displacement[a_dofs[0]];
let uy = flag_state.displacement[a_dofs[1]];
times.push(t);
ux_series.push(ux);
uy_series.push(uy);
if (step + 1) % 10 == 0 {
let (drag, lift) = harness.measure_force(&solver.borrow(), &field.borrow());
force_times.push(t);
drag_series.push(drag);
lift_series.push(lift);
if let Some(file) = csv.as_mut() {
writeln!(file, "{t:.6},{ux:.6e},{uy:.6e},{drag:.6e},{lift:.6e}").unwrap();
}
} else if let Some(file) = csv.as_mut() {
writeln!(file, "{t:.6},{ux:.6e},{uy:.6e},,").unwrap();
}
if (step + 1) % 1000 == 0 {
let window = &uy_series[uy_series.len().saturating_sub(1000)..];
let (w_mid, w_amp) = mid_amp(window);
println!(
" t = {t:.3} s ({step} steps): uy(A) = {uy:.3e} (window mid {w_mid:.3e} \
amp {w_amp:.3e}), {:.1} subit/step, {:.0} s wall",
total_subiterations as f64 / (step + 1) as f64,
phase_start.elapsed().as_secs_f64()
);
}
}
let elapsed = start.elapsed().as_secs_f64();
let mean_subiterations = total_subiterations as f64 / coupled_steps.max(1) as f64;
// Measure over the last three seconds (or the last half, if shorter).
let window_start = times
.iter()
.position(|&t| t >= t_end - 3.0)
.unwrap_or(times.len() / 2);
let uy_window = &uy_series[window_start..];
let ux_window = &ux_series[window_start..];
let t_window = &times[window_start..];
let (uy_mid, uy_amp) = mid_amp(uy_window);
let (ux_mid, ux_amp) = mid_amp(ux_window);
let frequency = crossing_frequency(t_window, uy_window);
// Onset: amplitude of the first quarter of the coupled march vs the
// last quarter.
let quarter = uy_series.len() / 4;
let (_, amp_early) = mid_amp(&uy_series[..quarter.max(1)]);
let (_, amp_late) = mid_amp(&uy_series[uy_series.len() - quarter.max(1)..]);
// Loads over the same window.
let force_start = force_times
.iter()
.position(|&t| t >= t_end - 3.0)
.unwrap_or(force_times.len() / 2);
let (drag_mid, drag_amp) = mid_amp(&drag_series[force_start..]);
let (lift_mid, lift_amp) = mid_amp(&lift_series[force_start..]);
println!(
" loads over the window: drag {drag_mid:.2} ± {drag_amp:.2} (ref {REF_DRAG_MEAN} ± \
77.65), lift {lift_mid:.2} ± {lift_amp:.2} (ref 0.61 ± {REF_LIFT_AMP})"
// 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,
max_subiterations: 12,
coupler: "aitken".into(),
reuse: 2,
smooth_in_h: 0.0,
csv_path: None,
initial_relaxation: 0.5,
trace_steps: 0,
c1_interface: false,
predictor: "structure".into(),
quiescent_release: false,
},
);
let MarchConfig {
ny,
flag_nx,
t_release,
t_end,
subcycle,
smooth_in_h,
..
} = config;
let result = run_march(FSI2, &config);
let w = result.window(3.0);
println!(
" FSI2 (fluid ny = {ny}, flag {flag_nx}x2 Quad8, dt = {dt:.2e}): coupled {coupled_steps} \
steps in {:.0} s wall total; {mean_subiterations:.1} subit/step (max \
{max_subiterations}); {stalled_steps} stalled steps, {retried_steps} \
history-reset retries (worst residual \
{worst_stall:.2e}); worst conservation {worst_conservation:.2e}; skipped samples \
{total_skipped} (of which {} spike-rejected)\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",
elapsed,
harness.spiked_total.get(),
t_window.first().unwrap_or(&t_release),
uy_mid * 1e3,
uy_amp * 1e3,
" 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)\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,
w.t_start,
w.uy_mid * 1e3,
w.uy_amp * 1e3,
REF_UY_MEAN * 1e3,
REF_UY_AMP * 1e3,
ux_mid * 1e3,
ux_amp * 1e3,
w.ux_mid * 1e3,
w.ux_amp * 1e3,
REF_UX_MEAN * 1e3,
REF_UX_AMP * 1e3,
frequency.map_or("n/a".to_string(), |f| format!("{f:.3}")),
amp_early,
amp_late,
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!(
worst_conservation < 1e-10,
"load transfer lost force: {worst_conservation:.3e}"
result.worst_conservation < 1e-10,
"load transfer lost force: {:.3e}",
result.worst_conservation
);
assert!(result.final_state_finite, "flag state went non-finite");
assert!(
flag_state.displacement.iter().all(|v| v.is_finite()),
"flag state went non-finite"
);
assert!(
mean_subiterations < 10.0,
"coupling is grinding: {mean_subiterations:.1} subiterations/step"
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!(
stalled_steps * 5 < coupled_steps.max(1),
"coupling stalled on {stalled_steps} of {coupled_steps} steps \
(worst residual {worst_stall:.2e})"
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
);
let _ = (amp_early, amp_late);
// 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
@@ -529,8 +243,8 @@ fn fsi2_flapping_flag() {
// 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).
let default_coupling = smooth_in_h == 0.0 && coupler_kind == "aitken";
let mode2_coupling = smooth_in_h == 0.0 && coupler_kind == "iqn" && subcycle == 2;
let default_coupling = smooth_in_h == 0.0 && config.coupler == "aitken";
let mode2_coupling = smooth_in_h == 0.0 && config.coupler == "iqn" && subcycle == 2;
if default_coupling
&& ny == 62
&& flag_nx == 35
@@ -542,20 +256,20 @@ fn fsi2_flapping_flag() {
// is ±35% for cross-platform floating-point drift in a growing
// transient, not an accuracy claim.
assert!(
(2.4e-3..5.2e-3).contains(&uy_mid),
"uy release-response mid {uy_mid:.4e} outside the measured band \
[2.4e-3, 5.2e-3]"
(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(&uy_amp),
"uy release-response amp {uy_amp:.4e} outside the measured band \
[2.4e-3, 5.2e-3]"
(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) = frequency {
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 \
@@ -563,9 +277,9 @@ fn fsi2_flapping_flag() {
);
}
assert!(
(12e-3..24e-3).contains(&uy_amp),
"uy amplitude {uy_amp:.4e} left the measured ±17.3 mm attractor \
band [12e-3, 24e-3]"
(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
@@ -577,7 +291,7 @@ fn fsi2_flapping_flag() {
// 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) = frequency {
if let Some(f) = w.frequency {
assert!(
(2.05..2.45).contains(&f),
"uy frequency {f:.3} left the measured mode-2 cycle band \
@@ -585,9 +299,10 @@ fn fsi2_flapping_flag() {
);
}
assert!(
(45e-3..70e-3).contains(&uy_amp),
"uy amplitude {uy_amp:.4e} left the measured mode-2 cycle band \
[45e-3, 70e-3] (benchmark reference {REF_UY_AMP})"
(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
);
}
}