Files
rustytorch/crates/specialized/rtx-fsi/src/coupling.rs
T
Omar SobhandClaude Fable 5 c0666bf22a
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
rtx-fsi: C3 — FSI3 opened: the added-mass regime, its impulse artefact, and the coupler hygiene it demanded
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
2026-08-23 19:43:02 -05:00

1182 lines
47 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.
//! Driving the fluid and structure to agree at the interface.
//!
//! # Why a partitioned coupling needs to iterate at all
//!
//! Solving fluid and structure separately means each sees a stale version
//! of the other. Exchanging once per time step — the *staggered* scheme —
//! is cheap and works when the structure is much heavier than the fluid it
//! displaces.
//!
//! It fails, badly, when it is not. The fluid's reaction to structural
//! acceleration behaves like an **added mass**: the structure must
//! accelerate the fluid around it as well as itself. Once that added mass
//! exceeds the structural mass, the interface fixed point becomes
//! repulsive and the staggered scheme diverges no matter how small the
//! time step. This is the added-mass effect, and it is the reason
//! partitioned coupling has a literature at all.
//!
//! Aeroelasticity in air often escapes it. A flexible structure in water,
//! or a very light structure in air, does not.
//!
//! # Aitken dynamic relaxation
//!
//! Under-relaxing the interface update with a fixed factor can stabilise
//! the iteration, but choosing that factor requires knowing the added-mass
//! ratio in advance, which is exactly what nobody knows.
//!
//! Aitken's delta-squared method infers it from the last two residuals:
//!
//! ```text
//! omega_k = -omega_{k-1} * (r_{k-1} . (r_k - r_{k-1})) / ||r_k - r_{k-1}||^2
//! ```
//!
//! For a linear fixed point this recovers the exact relaxation factor, so
//! convergence is immediate — which is both why it works and how the
//! implementation can be tested sharply rather than by "it got there
//! eventually".
//!
//! # IQN-ILS — the vector quasi-Newton coupler
//!
//! Aitken's factor is a **scalar**: one relaxation for every interface
//! degree of freedom. When the coupled map's gain differs across
//! interface modes — a flag whose tip and root see different added mass,
//! or a map contaminated by uncorrelated sampling noise — no single
//! scalar fits, and Aitken grinds or stalls.
//!
//! Interface Quasi-Newton with Inverse Least-Squares (IQN-ILS, Degroote,
//! Bathe & Vierendeels 2009) builds a low-rank secant model of the
//! interface Jacobian from the residual history instead. Writing one
//! pass as `x_tilde = pass(x)` with residual `r = x_tilde - x`, each
//! iteration contributes a column pair `(delta r, delta x_tilde)`; the
//! update solves the least-squares problem
//!
//! ```text
//! alpha = argmin || V alpha + r ||, x_next = x_tilde + W alpha
//! ```
//!
//! which is exactly the Newton step for `pass(x) - x = 0` in the
//! subspace the history spans (verified in the tests against linear maps
//! with anisotropic gains, where scalar Aitken cannot be exact). Two
//! properties matter for a noisy embedded-boundary interface:
//!
//! - the least-squares projection **filters components of the residual
//! that no history column explains** — uncorrelated per-pass sampling
//! noise does not steer the update the way it steers a scalar factor;
//! - columns from the **previous few time steps** can be reused
//! ([`IqnIls::with_reuse`]), so even a step that converges in one or
//! two passes benefits from a full secant model — the regime a tightly
//! time-coupled march actually runs in.
//!
//! Near-dependent columns are dropped by a modified GramSchmidt filter
//! whose threshold is **relative to each column's own norm** — an
//! absolute threshold here would be the same latent scale bug that has
//! now struck this workspace four times.
use std::collections::VecDeque;
use crate::error::FsiError;
/// Largest interface residual growth tolerated before declaring
/// divergence rather than iterating to the budget.
///
/// A residual an order of magnitude worse than where it started is not a
/// slow start, and reporting it as divergence names the actual failure —
/// almost always added mass — instead of an exhausted budget.
const DIVERGENCE_FACTOR: f64 = 10.0;
/// The interface state after a successful coupling step.
#[derive(Debug, Clone, PartialEq)]
pub struct Converged {
/// The agreed interface state.
pub state: Vec<f64>,
/// Final residual norm.
pub residual: f64,
/// Iterations performed.
pub iterations: usize,
}
/// How the interface update is relaxed between subiterations.
#[derive(Debug, Clone, Copy, PartialEq)]
enum Relaxation {
/// A constant factor, chosen by the caller.
Fixed(f64),
/// Aitken delta-squared, inferred from successive residuals.
Aitken,
}
/// Fixed-point driver for the fluidstructure interface.
#[derive(Debug, Clone, PartialEq)]
pub struct Subiterated {
relaxation: Relaxation,
max_iterations: usize,
tolerance: f64,
}
impl Subiterated {
/// Iterate with a constant relaxation factor.
///
/// A factor of 1.0 is the plain staggered scheme, which is the
/// configuration that exhibits the added-mass instability.
///
/// # Errors
/// [`FsiError::InvalidParameter`] for a non-positive or non-finite
/// factor, a zero iteration budget, or a non-positive tolerance.
pub fn relaxed(factor: f64, max_iterations: usize, tolerance: f64) -> Result<Self, FsiError> {
if !factor.is_finite() || factor <= 0.0 {
return Err(FsiError::InvalidParameter {
parameter: "relaxation factor",
value: factor,
});
}
Self::new(Relaxation::Fixed(factor), max_iterations, tolerance)
}
/// Iterate with Aitken dynamic relaxation.
///
/// # Errors
/// [`FsiError::InvalidParameter`] for a zero iteration budget or a
/// non-positive tolerance.
pub fn aitken(max_iterations: usize, tolerance: f64) -> Result<Self, FsiError> {
Self::new(Relaxation::Aitken, max_iterations, tolerance)
}
fn new(
relaxation: Relaxation,
max_iterations: usize,
tolerance: f64,
) -> Result<Self, FsiError> {
if max_iterations == 0 {
return Err(FsiError::InvalidParameter {
parameter: "max_iterations",
value: 0.0,
});
}
if !tolerance.is_finite() || tolerance <= 0.0 {
return Err(FsiError::InvalidParameter {
parameter: "tolerance",
value: tolerance,
});
}
Ok(Self {
relaxation,
max_iterations,
tolerance,
})
}
/// Drive `pass` — one fluid-then-structure exchange — to a fixed
/// point on the interface.
///
/// # Errors
/// - [`FsiError::EmptyInterface`] for an empty initial state.
/// - [`FsiError::CountMismatch`] if `pass` returns a different length.
/// - [`FsiError::NonFinite`] if `pass` returns a NaN or infinity.
/// - [`FsiError::CouplingDiverged`] if the residual runs away, which
/// names added mass rather than blaming the budget.
/// - [`FsiError::CouplingNotConverged`] if the budget is exhausted.
pub fn solve<F>(&mut self, initial: &[f64], pass: F) -> Result<Converged, FsiError>
where
F: Fn(&[f64]) -> Vec<f64>,
{
if initial.is_empty() {
return Err(FsiError::EmptyInterface { side: "interface" });
}
let mut state = initial.to_vec();
let mut omega = match self.relaxation {
Relaxation::Fixed(factor) => factor,
// Aitken needs a first step to have residuals to work from.
Relaxation::Aitken => 1.0,
};
let mut previous_residual: Option<Vec<f64>> = None;
let mut first_norm = None;
for iteration in 1..=self.max_iterations {
let candidate = pass(&state);
if candidate.len() != state.len() {
return Err(FsiError::CountMismatch {
field: "coupling pass",
got: candidate.len(),
expected: state.len(),
});
}
if let Some(index) = candidate.iter().position(|v| !v.is_finite()) {
return Err(FsiError::NonFinite {
field: "coupling pass",
index,
});
}
let residual: Vec<f64> = candidate
.iter()
.zip(&state)
.map(|(new, old)| new - old)
.collect();
let norm = norm_of(&residual);
let first = *first_norm.get_or_insert(norm);
if norm <= self.tolerance {
return Ok(Converged {
state,
residual: norm,
iterations: iteration,
});
}
if norm > first * DIVERGENCE_FACTOR && first > 0.0 {
return Err(FsiError::CouplingDiverged {
iterations: iteration,
residual: norm,
});
}
if let Relaxation::Aitken = self.relaxation {
if let Some(previous) = &previous_residual {
omega = aitken_factor(omega, previous, &residual);
}
}
for (value, delta) in state.iter_mut().zip(&residual) {
*value += omega * delta;
}
previous_residual = Some(residual);
}
Err(FsiError::CouplingNotConverged {
iterations: self.max_iterations,
residual: previous_residual.as_deref().map_or(f64::NAN, norm_of),
tolerance: self.tolerance,
})
}
}
/// How near-dependent a secant column may be to the span of the columns
/// already accepted before it is dropped, **relative to its own norm**.
const COLUMN_FILTER: f64 = 1e-8;
/// Trust region for the quasi-Newton step: the full update `r + W alpha`
/// is capped at this multiple of the current residual norm (direction
/// kept). Legitimate Newton steps exceed `||r||` only by the inverse
/// distance of the map's gain from one — large for near-marginal maps,
/// which this cap still admits — while a secant model extrapolating a
/// locally violent nonlinear map can propose steps thousands of times
/// the residual (measured on TurekHron FSI2 at subcycle 1: a candidate
/// interface displacement swept to the domain wall and killed the mask
/// build before any residual-based guard could fire). The cap is
/// relative to the residual's own scale, per this workspace's
/// absolute-threshold rule.
const STEP_CAP: f64 = 10.0;
/// Stagnation detection: if the best residual over the last
/// `STAGNATION_WINDOW` passes is still more than `STAGNATION_PROGRESS`
/// of the best over the `STAGNATION_WINDOW` passes before those, the
/// iteration is bouncing on a noise floor and is reported as
/// unconverged at that level instead of grinding to the budget. Two
/// windows, compared by their minima: a bistable bounce dips to the
/// same floor in both (caught), while a slow but steady contraction of
/// 0.9 per pass improves its window minimum by 0.9^6 = 0.53 (not
/// caught — a single-window "best vs oldest" test at 0.5 misjudged
/// exactly such a step as stalled).
const STAGNATION_WINDOW: usize = 6;
const STAGNATION_PROGRESS: f64 = 0.9;
/// One secant sample: `(delta residual, delta pass-output)` between two
/// successive iterations.
type SecantColumn = (Vec<f64>, Vec<f64>);
/// Interface quasi-Newton driver with inverse least-squares (IQN-ILS).
///
/// Keep one instance alive across a time march: with
/// [`Self::with_reuse`] the secant columns of the last few steps carry
/// over, and the first pass of a new step already runs against a full
/// Jacobian model. See the module docs for the method and the tests for
/// its sharp properties (exact on linear maps, anisotropic gains, scale
/// invariance, noise stalling at the noise scale instead of diverging).
#[derive(Debug, Clone, PartialEq)]
pub struct IqnIls {
max_iterations: usize,
tolerance: f64,
initial_relaxation: f64,
steps_retained: usize,
/// Newest step first; within a step, newest column first.
history: VecDeque<Vec<SecantColumn>>,
}
impl IqnIls {
/// A driver with the given per-step iteration budget and interface
/// tolerance. Defaults: first-iteration relaxation 0.5, secant reuse
/// over the 2 previous steps.
///
/// # Errors
/// [`FsiError::InvalidParameter`] for a zero iteration budget or a
/// non-positive tolerance.
pub fn new(max_iterations: usize, tolerance: f64) -> Result<Self, FsiError> {
if max_iterations == 0 {
return Err(FsiError::InvalidParameter {
parameter: "max_iterations",
value: 0.0,
});
}
if !tolerance.is_finite() || tolerance <= 0.0 {
return Err(FsiError::InvalidParameter {
parameter: "tolerance",
value: tolerance,
});
}
Ok(Self {
max_iterations,
tolerance,
initial_relaxation: 0.5,
steps_retained: 2,
history: VecDeque::new(),
})
}
/// Retain the secant columns of the last `steps` time steps across
/// [`Self::solve`] calls (0 = within-step only).
#[must_use]
pub fn with_reuse(mut self, steps: usize) -> Self {
self.steps_retained = steps;
self.history.truncate(steps);
self
}
/// The relaxation applied when no secant information exists yet
/// (the very first pass of the very first step).
///
/// # Errors
/// [`FsiError::InvalidParameter`] for a non-finite or non-positive
/// factor.
pub fn with_initial_relaxation(mut self, factor: f64) -> Result<Self, FsiError> {
if !factor.is_finite() || factor <= 0.0 {
return Err(FsiError::InvalidParameter {
parameter: "initial relaxation",
value: factor,
});
}
self.initial_relaxation = factor;
Ok(self)
}
/// Update the interface tolerance for the next [`Self::solve`] call
/// (a marching coupler re-budgets per step: the tolerance is
/// max(noise floor, a fraction of the step's own increment)).
///
/// # Errors
/// [`FsiError::InvalidParameter`] for a non-positive tolerance.
pub fn set_tolerance(&mut self, tolerance: f64) -> Result<(), FsiError> {
if !tolerance.is_finite() || tolerance <= 0.0 {
return Err(FsiError::InvalidParameter {
parameter: "tolerance",
value: tolerance,
});
}
self.tolerance = tolerance;
Ok(())
}
/// Drop the retained cross-step secant history.
///
/// The history assumes the interface Jacobian changes slowly between
/// steps. During a rapid transient — a resonantly growing amplitude,
/// a load regime change — stale columns can steer the least-squares
/// update into an overshoot that a divergence guard then reads as
/// added mass (measured on TurekHron FSI2: a residual driven from
/// 1e-4 to 1e-3 by the first quasi-Newton update at 2.7x the
/// previously seen amplitude). A marching coupler's recovery is:
/// reset the history, retry the step from its predictor.
pub fn reset_history(&mut self) {
self.history.clear();
}
/// Drive `pass` to an interface fixed point, as
/// [`Subiterated::solve`] does, reusing secant history across calls.
///
/// # Errors
/// The same contract as [`Subiterated::solve`], plus: a residual
/// that has stopped moving (see `STAGNATION_WINDOW`) returns
/// [`FsiError::CouplingNotConverged`] early at the level reached.
/// Only a CONVERGED step's secant columns are retained across calls
/// — a stalled or diverged step's columns sample a noise floor or a
/// runaway, not the map (measured: a bistable mask flip's columns
/// extrapolated a 30 mm interface jump on the following step).
pub fn solve<F>(&mut self, initial: &[f64], pass: F) -> Result<Converged, FsiError>
where
F: Fn(&[f64]) -> Vec<f64>,
{
if initial.is_empty() {
return Err(FsiError::EmptyInterface { side: "interface" });
}
let mut x = initial.to_vec();
let mut previous: Option<(Vec<f64>, Vec<f64>)> = None; // (r, x_tilde)
let mut step_columns: Vec<SecantColumn> = Vec::new();
let mut first_norm = None;
let mut last_norm = f64::NAN;
let mut recent: VecDeque<f64> = VecDeque::new();
for iteration in 1..=self.max_iterations {
let x_tilde = pass(&x);
if x_tilde.len() != x.len() {
return Err(FsiError::CountMismatch {
field: "coupling pass",
got: x_tilde.len(),
expected: x.len(),
});
}
if let Some(index) = x_tilde.iter().position(|v| !v.is_finite()) {
return Err(FsiError::NonFinite {
field: "coupling pass",
index,
});
}
let r: Vec<f64> = x_tilde.iter().zip(&x).map(|(new, old)| new - old).collect();
let norm = norm_of(&r);
let first = *first_norm.get_or_insert(norm);
last_norm = norm;
if let Some((r_prev, xt_prev)) = &previous {
step_columns.insert(
0,
(
r.iter().zip(r_prev).map(|(a, b)| a - b).collect(),
x_tilde.iter().zip(xt_prev).map(|(a, b)| a - b).collect(),
),
);
}
if norm <= self.tolerance {
self.commit(step_columns);
return Ok(Converged {
state: x,
residual: norm,
iterations: iteration,
});
}
// The divergence verdict waits for the secant model: the
// second pass follows a blind relaxed step, and on a
// high-gain (strongly repulsive) map it legitimately
// overshoots by |1 - omega (1 + g)| before the first secant
// column exists — FSI3 at density ratio 1 shows per-pass
// gains in the hundreds, where any fixed first relaxation
// overshoots and the quasi-Newton step from pass 3 is what
// converges. Judging that exploratory pass as added-mass
// runaway would forbid exactly the maps IQN exists for.
let secant_applied = iteration > 2 || !self.history.is_empty();
if secant_applied && norm > first * DIVERGENCE_FACTOR && first > 0.0 {
// A diverged step's columns are not retained: they
// sample the runaway, not the map.
return Err(FsiError::CouplingDiverged {
iterations: iteration,
residual: norm,
});
}
// Stagnation: a residual that has stopped moving is a noise
// floor, not a slow start. Bouncing on it to the budget only
// manufactures noise-dominated secant columns (a bistable
// mask flip held a TurekHron FSI3 step for 60 passes and
// its columns then extrapolated a 30 mm interface jump on
// the next step). Report it as unconverged at the level
// reached; the caller decides whether that level is
// acceptable.
recent.push_back(norm);
if recent.len() > 2 * STAGNATION_WINDOW {
recent.pop_front();
}
if recent.len() == 2 * STAGNATION_WINDOW {
let best_before = recent
.iter()
.take(STAGNATION_WINDOW)
.copied()
.fold(f64::MAX, f64::min);
let best_now = recent
.iter()
.skip(STAGNATION_WINDOW)
.copied()
.fold(f64::MAX, f64::min);
if best_now > STAGNATION_PROGRESS * best_before {
return Err(FsiError::CouplingNotConverged {
iterations: iteration,
residual: norm,
tolerance: self.tolerance,
});
}
}
let columns: Vec<&SecantColumn> = step_columns
.iter()
.chain(self.history.iter().flatten())
.collect();
// No noise-column filtering (threshold 0 = exact-zero
// columns only): a filter at the tolerance was measured to
// stall a slowly converging FSI3 step at 5.5e-4 (the
// fine-scale columns near the floor ARE the information
// needed there), while the bistable-bounce columns it was
// meant for had |delta r| above the tolerance anyway — those
// are handled by not retaining a stalled step's history.
match least_squares_update(&columns, &r, 0.0) {
Some(delta) => {
// The full step from x is r + delta; cap it at
// STEP_CAP x the residual (see the constant's docs).
let mut step: Vec<f64> = r.iter().zip(&delta).map(|(a, b)| a + b).collect();
let step_norm = norm_of(&step);
let cap = STEP_CAP * norm;
if step_norm > cap {
let scale = cap / step_norm;
for value in &mut step {
*value *= scale;
}
}
x = x.iter().zip(&step).map(|(a, b)| a + b).collect();
}
None => {
// No usable secant information yet: one relaxed
// fixed-point step to generate it.
for (value, residual) in x.iter_mut().zip(&r) {
*value += self.initial_relaxation * residual;
}
}
}
previous = Some((r, x_tilde));
}
// Budget exhausted without convergence: the columns are not
// retained (see `solve`'s docs).
Err(FsiError::CouplingNotConverged {
iterations: self.max_iterations,
residual: last_norm,
tolerance: self.tolerance,
})
}
/// Retire this step's secant columns into the cross-step history.
fn commit(&mut self, step_columns: Vec<SecantColumn>) {
if self.steps_retained == 0 || step_columns.is_empty() {
return;
}
self.history.push_front(step_columns);
self.history.truncate(self.steps_retained);
}
}
/// The IQN-ILS update `W alpha` with `alpha = argmin || V alpha + r ||`,
/// via modified GramSchmidt with dropping of near-dependent columns
/// (threshold relative to each column's own norm). `None` when no column
/// survives — the caller falls back to a relaxed fixed-point step.
fn least_squares_update(columns: &[&SecantColumn], r: &[f64], noise: f64) -> Option<Vec<f64>> {
if columns.is_empty() {
return None;
}
let n = r.len();
// Accepted orthonormal basis q_i, the R entries of each accepted
// column, and the index of the original column it came from.
let mut basis: Vec<Vec<f64>> = Vec::new();
let mut upper: Vec<Vec<f64>> = Vec::new(); // per accepted column: R entries over basis
let mut accepted: Vec<usize> = Vec::new();
for (index, (v, _)) in columns.iter().enumerate() {
debug_assert_eq!(v.len(), n);
let original_norm = norm_of(v);
// Columns whose residual change is at or below `noise` are
// dropped (the caller passes 0 — see `solve` for why a threshold
// at the tolerance was measured to hurt).
if original_norm <= noise {
continue;
}
let mut q = v.clone();
let mut coefficients = Vec::with_capacity(basis.len());
for b in &basis {
let dot: f64 = b.iter().zip(&q).map(|(a, c)| a * c).sum();
for (qi, bi) in q.iter_mut().zip(b) {
*qi -= dot * bi;
}
coefficients.push(dot);
}
let remaining = norm_of(&q);
if remaining <= COLUMN_FILTER * original_norm {
continue;
}
for value in &mut q {
*value /= remaining;
}
coefficients.push(remaining);
basis.push(q);
upper.push(coefficients);
accepted.push(index);
if basis.len() == n {
break; // the span is full
}
}
if accepted.is_empty() {
return None;
}
// alpha solves R alpha = Q^T (-r), by back substitution: `upper[j]`
// holds column j's entries over basis rows 0..=j.
let m = accepted.len();
let rhs: Vec<f64> = basis
.iter()
.map(|q| -q.iter().zip(r).map(|(a, b)| a * b).sum::<f64>())
.collect();
let mut alpha = vec![0.0; m];
for j in (0..m).rev() {
let mut sum = rhs[j];
for k in j + 1..m {
sum -= upper[k][j] * alpha[k];
}
alpha[j] = sum / upper[j][j];
}
// W alpha over the accepted columns.
let mut delta = vec![0.0; n];
for (a, &index) in alpha.iter().zip(&accepted) {
for (d, w) in delta.iter_mut().zip(&columns[index].1) {
*d += a * w;
}
}
Some(delta)
}
/// Aitken delta-squared relaxation factor from successive residuals.
///
/// Falls back to the previous factor when the residual barely moved
/// *relative to its own size*, since the update divides by that
/// difference. The guard must be relative: an earlier version compared
/// the squared difference against a bare `f64::EPSILON`, which silently
/// disabled Aitken for any residual below ~1e-8 in norm — exactly the
/// well-converged regime — and the piston FSI benchmark then watched the
/// "relaxed" iteration diverge at unit factor from a residual of 1e-9.
/// The model-map unit tests never saw it because their residuals start
/// at 1.
fn aitken_factor(previous_omega: f64, previous: &[f64], current: &[f64]) -> f64 {
let difference: Vec<f64> = current
.iter()
.zip(previous)
.map(|(now, before)| now - before)
.collect();
let denominator: f64 = difference.iter().map(|d| d * d).sum();
let scale: f64 = previous.iter().map(|r| r * r).sum();
if denominator <= f64::EPSILON * scale {
return previous_omega;
}
let numerator: f64 = previous
.iter()
.zip(&difference)
.map(|(before, delta)| before * delta)
.sum();
-previous_omega * numerator / denominator
}
fn norm_of(values: &[f64]) -> f64 {
values.iter().map(|v| v * v).sum::<f64>().sqrt()
}
#[cfg(test)]
mod tests {
use super::*;
/// One fluid-then-structure pass, as a map on the interface state.
///
/// A linear map with gain `-gain` stands in for the added-mass
/// coupling: the fluid's reaction to structural acceleration is
/// proportional to the fluid density, and it opposes the motion. The
/// gain is the added-mass ratio, and everything about partitioned
/// stability follows from whether it exceeds one.
fn added_mass(gain: f64) -> impl Fn(&[f64]) -> Vec<f64> {
move |state: &[f64]| state.iter().map(|x| -gain * x).collect()
}
// ---- the added-mass instability ----
#[test]
fn staggered_coupling_survives_a_light_fluid() {
// Added mass well below structural mass: the classic staggered
// scheme is fine, which is why it is used at all.
let mut scheme = Subiterated::relaxed(0.5, 200, 1e-10).expect("valid");
let converged = scheme.solve(&[1.0], added_mass(0.3)).expect("converges");
assert!(converged.residual < 1e-10);
}
#[test]
fn staggered_coupling_diverges_when_the_fluid_is_heavy() {
// THE classic partitioned-FSI failure. Once the added mass exceeds
// the structural mass the fixed point is repulsive, and no amount
// of iterating at unit relaxation recovers it. A coupling that
// does not reproduce this is not being tested hard enough.
let mut scheme = Subiterated::relaxed(1.0, 200, 1e-10).expect("valid");
assert!(matches!(
scheme.solve(&[1.0], added_mass(2.5)),
Err(FsiError::CouplingDiverged { .. })
));
}
#[test]
fn aitken_relaxation_recovers_the_heavy_fluid_case() {
// The whole point of dynamic relaxation. Same gain that destroyed
// the fixed-relaxation scheme, now converging.
let mut scheme = Subiterated::aitken(200, 1e-10).expect("valid");
let converged = scheme.solve(&[1.0], added_mass(2.5)).expect("converges");
assert!(
converged.residual < 1e-10,
"residual {}",
converged.residual
);
}
#[test]
fn aitken_is_exact_on_a_linear_map() {
// For a linear fixed point Aitken's delta-squared finds the exact
// relaxation, so it should land in very few iterations. Asserting
// the count pins that the update is the real Aitken formula and
// not an under-relaxation that happens to converge.
let mut scheme = Subiterated::aitken(200, 1e-12).expect("valid");
let converged = scheme.solve(&[1.0], added_mass(2.5)).expect("converges");
assert!(
converged.iterations <= 4,
"expected near-immediate convergence, took {}",
converged.iterations
);
}
#[test]
fn aitken_is_scale_invariant() {
// The relaxation factor is a ratio of residuals, so nothing about
// the iteration may depend on their absolute size. The absolute
// epsilon guard this test pins down used to disable Aitken below
// residual ~1e-8, leaving unit relaxation to diverge on the same
// repulsive map it converges from at scale 1.
let mut scheme = Subiterated::aitken(200, 1e-20).expect("valid");
let converged = scheme
.solve(&[1e-9], added_mass(2.5))
.expect("Aitken must converge regardless of residual scale");
assert!(
converged.iterations <= 4,
"expected the same near-immediate convergence as at scale 1, \
took {}",
converged.iterations
);
}
#[test]
fn aitken_beats_fixed_relaxation_where_both_converge() {
let gain = 0.8;
let mut fixed = Subiterated::relaxed(0.5, 500, 1e-10).expect("valid");
let mut aitken = Subiterated::aitken(500, 1e-10).expect("valid");
let slow = fixed.solve(&[1.0], added_mass(gain)).expect("converges");
let fast = aitken.solve(&[1.0], added_mass(gain)).expect("converges");
assert!(
fast.iterations < slow.iterations,
"aitken {} vs fixed {}",
fast.iterations,
slow.iterations
);
}
// ---- ordinary behaviour ----
#[test]
fn an_already_converged_interface_does_no_work() {
// Zero is the fixed point of the added-mass map. Starting there
// must terminate immediately rather than iterating pointlessly.
let mut scheme = Subiterated::aitken(100, 1e-10).expect("valid");
let converged = scheme
.solve(&[0.0, 0.0], added_mass(2.0))
.expect("converges");
assert_eq!(converged.iterations, 1);
}
#[test]
fn a_multi_component_interface_converges_together() {
let mut scheme = Subiterated::aitken(200, 1e-10).expect("valid");
let converged = scheme
.solve(&[1.0, -2.0, 0.5, 3.0], added_mass(1.8))
.expect("converges");
assert!(converged.state.iter().all(|x| x.abs() < 1e-8));
}
#[test]
fn exhausting_the_iteration_budget_is_reported_not_hidden() {
// A slowly converging problem cut short must say so. Returning the
// unconverged state as if it were converged is how a coupling
// silently produces plausible nonsense.
let mut scheme = Subiterated::relaxed(0.01, 3, 1e-12).expect("valid");
assert!(matches!(
scheme.solve(&[1.0], added_mass(0.9)),
Err(FsiError::CouplingNotConverged { .. })
));
}
// ---- refusals ----
#[test]
fn an_invalid_relaxation_factor_is_refused() {
assert!(Subiterated::relaxed(0.0, 10, 1e-8).is_err());
assert!(Subiterated::relaxed(-0.5, 10, 1e-8).is_err());
assert!(Subiterated::relaxed(f64::NAN, 10, 1e-8).is_err());
}
#[test]
fn a_zero_iteration_budget_is_refused() {
assert!(Subiterated::aitken(0, 1e-8).is_err());
}
#[test]
fn an_invalid_tolerance_is_refused() {
assert!(Subiterated::aitken(10, 0.0).is_err());
assert!(Subiterated::aitken(10, -1e-8).is_err());
}
#[test]
fn an_empty_interface_state_is_refused() {
let mut scheme = Subiterated::aitken(10, 1e-8).expect("valid");
assert!(scheme.solve(&[], added_mass(0.5)).is_err());
}
#[test]
fn a_map_returning_the_wrong_length_is_refused() {
let mut scheme = Subiterated::aitken(10, 1e-8).expect("valid");
assert!(matches!(
scheme.solve(&[1.0, 2.0], |_: &[f64]| vec![0.0]),
Err(FsiError::CountMismatch { .. })
));
}
#[test]
fn a_map_returning_non_finite_values_is_refused() {
let mut scheme = Subiterated::aitken(10, 1e-8).expect("valid");
assert!(matches!(
scheme.solve(&[1.0], |_: &[f64]| vec![f64::NAN]),
Err(FsiError::NonFinite { .. })
));
}
// ---- IQN-ILS ----
/// A linear coupled map with a different gain per interface mode —
/// the situation a scalar relaxation factor cannot be exact for.
fn anisotropic(gains: &'static [f64]) -> impl Fn(&[f64]) -> Vec<f64> {
move |state: &[f64]| state.iter().zip(gains).map(|(x, g)| -g * x + 1.0).collect()
}
#[test]
fn iqn_recovers_the_heavy_added_mass_case() {
let mut scheme = IqnIls::new(200, 1e-10).expect("valid");
let converged = scheme.solve(&[1.0], added_mass(2.5)).expect("converges");
assert!(converged.residual < 1e-10);
}
#[test]
fn iqn_is_exact_on_a_linear_map_within_dimension_plus_two() {
// The least-squares secant model spans the full Jacobian after
// `dim` independent columns, so a linear map must converge in at
// most dim + 2 passes. Asserting the count pins that the update
// is the real IQN-ILS step, not a relaxation that happens to
// converge.
let gains: &[f64] = &[2.5, -0.8, 3.0, 0.3];
let mut scheme = IqnIls::new(200, 1e-12).expect("valid");
let converged = scheme
.solve(&[1.0, 1.0, 1.0, 1.0], anisotropic(gains))
.expect("converges");
assert!(
converged.iterations <= 6,
"expected <= dim + 2 = 6 iterations, took {}",
converged.iterations
);
}
#[test]
fn iqn_beats_aitken_on_anisotropic_gains() {
// Mixed attracting/repelling modes: no scalar factor fits both,
// so Aitken must grind where the vector secant is exact. This is
// the property that makes IQN the standard strong coupler.
let gains: &[f64] = &[2.2, -0.9, 1.4, 0.1, 2.9];
let initial = [1.0, -1.0, 2.0, 0.5, -0.3];
let mut aitken = Subiterated::aitken(500, 1e-10).expect("valid");
let mut iqn = IqnIls::new(500, 1e-10).expect("valid");
let slow = aitken
.solve(&initial, anisotropic(gains))
.expect("converges");
let fast = iqn.solve(&initial, anisotropic(gains)).expect("converges");
assert!(
fast.iterations < slow.iterations,
"iqn {} vs aitken {}",
fast.iterations,
slow.iterations
);
}
#[test]
fn iqn_is_scale_invariant() {
// The column filter must be relative to each column's own norm —
// the absolute-epsilon species has struck this workspace four
// times, once in this very module.
let mut scheme = IqnIls::new(200, 1e-20).expect("valid");
let converged = scheme
.solve(&[1e-9], added_mass(2.5))
.expect("IQN must converge regardless of residual scale");
assert!(
converged.iterations <= 4,
"expected the same near-immediate convergence as at scale 1, \
took {}",
converged.iterations
);
}
#[test]
fn iqn_reuses_secant_history_across_steps() {
// A marching coupler solves the same (linearised) interface
// problem step after step. With reuse the second step starts
// with a full Jacobian model and must converge in fewer passes
// than the first; without reuse it must not.
let gains: &[f64] = &[2.5, -0.8, 3.0];
let initial = [1.0, 1.0, 1.0];
let mut with_reuse = IqnIls::new(200, 1e-10).expect("valid").with_reuse(2);
let first = with_reuse
.solve(&initial, anisotropic(gains))
.expect("converges");
let second = with_reuse
.solve(&initial, anisotropic(gains))
.expect("converges");
assert!(
second.iterations < first.iterations,
"reuse should shorten the next step: {} then {}",
first.iterations,
second.iterations
);
let mut without = IqnIls::new(200, 1e-10).expect("valid").with_reuse(0);
let cold_first = without
.solve(&initial, anisotropic(gains))
.expect("converges");
let cold_second = without
.solve(&initial, anisotropic(gains))
.expect("converges");
assert_eq!(
cold_first.iterations, cold_second.iterations,
"without reuse each step must start cold"
);
}
#[test]
fn iqn_survives_the_exploratory_overshoot_of_a_high_gain_map() {
// A strongly repulsive map (gain 40, the added-mass regime at
// unit density ratio is worse still): the blind first relaxation
// of 0.5 multiplies the residual by |1 - 0.5 x 41| = 19.5 — far
// past the 10x divergence factor — and only the secant from
// pass 3 can converge it. The verdict must wait for the secant;
// an earlier draft of the guard killed FSI3 at its first step.
let mut scheme = IqnIls::new(50, 1e-10).expect("valid");
let converged = scheme
.solve(&[1.0], added_mass(40.0))
.expect("the quasi-Newton step must get its turn");
assert!(
converged.iterations <= 5,
"a linear map converges within a few passes of the first secant, took {}",
converged.iterations
);
// Genuine runaway is still caught once the secant has spoken: a
// pass that ignores its input and grows every call.
let calls = std::cell::Cell::new(0u32);
let runaway = |state: &[f64]| -> Vec<f64> {
calls.set(calls.get() + 1);
let scale = 10f64.powi(calls.get() as i32);
state.iter().map(|x| x + scale).collect()
};
let mut scheme = IqnIls::new(50, 1e-10).expect("valid");
assert!(matches!(
scheme.solve(&[1.0], runaway),
Err(FsiError::CouplingDiverged { .. })
));
}
#[test]
fn a_stagnated_iteration_is_reported_early_and_leaves_no_history() {
// Per-pass noise below the tolerance can never converge; the
// iteration must report the plateau within a stagnation window
// instead of bouncing to the budget, and must retain none of the
// noise-dominated columns (the next step then starts cold).
let calls = std::cell::Cell::new(0u64);
let noisy = |state: &[f64]| -> Vec<f64> {
calls.set(calls.get() + 1);
let noise = (calls.get() as f64 * 2.399_963).sin() * 1e-3;
state.iter().map(|x| -0.5 * x + 1.0 + noise).collect()
};
let mut scheme = IqnIls::new(200, 1e-9).expect("valid").with_reuse(2);
match scheme.solve(&[1.0, -1.0], noisy) {
Err(FsiError::CouplingNotConverged {
iterations,
residual,
..
}) => {
assert!(
iterations < 40,
"stagnation should be reported well before the budget, took {iterations}"
);
assert!(
residual < 1e-2,
"plateau at the noise scale, got {residual}"
);
}
other => panic!("expected an early NotConverged, got {other:?}"),
}
assert!(
scheme.history.is_empty(),
"a stalled step must not seed the next step's secant model"
);
}
#[test]
fn the_quasi_newton_step_is_trust_region_capped() {
// Per-pass noise corrupts the secant columns, and the
// least-squares extrapolation can then propose steps orders of
// magnitude beyond the residual — the FSI2 subcycle-1 march had
// a candidate interface swept to the domain wall this way,
// crashing the mask build before any residual guard could fire.
// Every iterate's step must stay within STEP_CAP x its own
// residual.
let calls = std::cell::Cell::new(0u64);
let trace: std::cell::RefCell<Vec<(Vec<f64>, Vec<f64>)>> =
std::cell::RefCell::new(Vec::new());
let noisy = |state: &[f64]| -> Vec<f64> {
calls.set(calls.get() + 1);
let noise = (calls.get() as f64 * 2.399_963).sin() * 1e-3;
let out: Vec<f64> = state.iter().map(|x| -2.5 * x + 1.0 + noise).collect();
trace.borrow_mut().push((state.to_vec(), out.clone()));
out
};
let mut scheme = IqnIls::new(60, 1e-14).expect("valid");
let _ = scheme.solve(&[1.0, -1.0], noisy); // unreachable tolerance
let trace = trace.into_inner();
// Stagnation detection ends the noisy iteration early; a few
// quasi-Newton passes are all the cap check needs.
assert!(trace.len() >= 4, "expected several noisy passes");
for pair in trace.windows(2) {
let (input, output) = &pair[0];
let (next_input, _) = &pair[1];
let residual: f64 = output
.iter()
.zip(input)
.map(|(a, b)| (a - b) * (a - b))
.sum::<f64>()
.sqrt();
let step: f64 = next_input
.iter()
.zip(input)
.map(|(a, b)| (a - b) * (a - b))
.sum::<f64>()
.sqrt();
assert!(
step <= STEP_CAP * residual * (1.0 + 1e-9),
"step {step:.3e} exceeded the trust region at residual \
{residual:.3e}"
);
}
}
#[test]
fn resetting_the_history_restores_a_cold_start() {
// The recovery path a marching coupler uses when stale secant
// columns overshoot: after reset_history the next solve must
// behave exactly like a cold start, not like a warm one.
let gains: &'static [f64] = &[2.5, -0.8, 3.0];
let initial = [1.0, 1.0, 1.0];
let mut scheme = IqnIls::new(200, 1e-10).expect("valid").with_reuse(2);
let cold = scheme
.solve(&initial, anisotropic(gains))
.expect("converges");
scheme.reset_history();
let after_reset = scheme
.solve(&initial, anisotropic(gains))
.expect("converges");
assert_eq!(
cold.iterations, after_reset.iterations,
"a reset coupler must start cold: {} then {}",
cold.iterations, after_reset.iterations
);
}
#[test]
fn iqn_stalls_at_the_noise_scale_instead_of_diverging() {
// The embedded-boundary reality: the pass carries a deterministic
// but effectively uncorrelated noise component (mask flips) of a
// fixed scale. The coupler must converge to a tolerance ABOVE the
// noise scale, and must report (not blow through) one below it.
// The noise must vary per PASS, not per state: a continuous
// function of the state alone has a genuine fixed point and IQN
// legitimately converges onto it to machine precision (the first
// draft of this test learned that the hard way). A call counter
// models the real thing — successive samplings of the same
// geometry never repay the same load once the mask has moved.
let noise_scale = 1e-6;
let calls = std::cell::Cell::new(0u64);
let noisy = |state: &[f64]| -> Vec<f64> {
calls.set(calls.get() + 1);
let noise = (calls.get() as f64 * 2.399_963).sin() * noise_scale;
state.iter().map(|x| -2.5 * x + 1.0 + noise).collect()
};
let mut above = IqnIls::new(50, 20.0 * noise_scale).expect("valid");
let converged = above.solve(&[1.0], noisy).expect("converges above noise");
assert!(converged.residual <= 20.0 * noise_scale);
let mut below = IqnIls::new(50, 1e-12).expect("valid");
match below.solve(&[1.0], noisy) {
Err(
FsiError::CouplingNotConverged { residual, .. }
| FsiError::CouplingDiverged { residual, .. },
) => {
assert!(
residual < 100.0 * noise_scale,
"stall residual {residual} should sit at the noise scale"
);
}
Ok(converged) => panic!(
"cannot genuinely converge below the noise floor \
(residual {})",
converged.residual
),
Err(other) => panic!("unexpected error species: {other:?}"),
}
}
#[test]
fn iqn_refusals_match_the_subiterated_contract() {
assert!(IqnIls::new(0, 1e-8).is_err());
assert!(IqnIls::new(10, 0.0).is_err());
assert!(IqnIls::new(10, -1e-8).is_err());
assert!(
IqnIls::new(10, 1e-8)
.expect("valid")
.with_initial_relaxation(0.0)
.is_err()
);
let mut scheme = IqnIls::new(10, 1e-8).expect("valid");
assert!(scheme.solve(&[], added_mass(0.5)).is_err());
assert!(matches!(
scheme.solve(&[1.0, 2.0], |_: &[f64]| vec![0.0]),
Err(FsiError::CountMismatch { .. })
));
assert!(matches!(
scheme.solve(&[1.0], |_: &[f64]| vec![f64::NAN]),
Err(FsiError::NonFinite { .. })
));
assert!(scheme.set_tolerance(-1.0).is_err());
assert!(scheme.set_tolerance(1e-6).is_ok());
}
#[test]
fn iqn_budget_exhaustion_is_reported_not_hidden() {
// A pass that ignores its input never generates a secant column
// pointing at the fixed point of anything; the budget must be
// reported honestly.
let mut scheme = IqnIls::new(3, 1e-12)
.expect("valid")
.with_initial_relaxation(1e-6)
.expect("valid");
assert!(matches!(
scheme.solve(&[1.0], added_mass(0.999)),
Err(FsiError::CouplingNotConverged { .. } | FsiError::CouplingDiverged { .. })
));
}
}