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
1182 lines
47 KiB
Rust
1182 lines
47 KiB
Rust
//! 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 Gram–Schmidt 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 fluid–structure 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 Turek–Hron 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 Turek–Hron 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 Turek–Hron 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 Gram–Schmidt 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 { .. })
|
||
));
|
||
}
|
||
}
|