//! 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, /// 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 { 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::new(Relaxation::Aitken, max_iterations, tolerance) } fn new( relaxation: Relaxation, max_iterations: usize, tolerance: f64, ) -> Result { 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(&mut self, initial: &[f64], pass: F) -> Result where F: Fn(&[f64]) -> Vec, { 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> = 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 = 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, Vec); /// 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>, } 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 { 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 { 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(&mut self, initial: &[f64], pass: F) -> Result where F: Fn(&[f64]) -> Vec, { if initial.is_empty() { return Err(FsiError::EmptyInterface { side: "interface" }); } let mut x = initial.to_vec(); let mut previous: Option<(Vec, Vec)> = None; // (r, x_tilde) let mut step_columns: Vec = Vec::new(); let mut first_norm = None; let mut last_norm = f64::NAN; let mut recent: VecDeque = 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 = 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 = 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) { 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> { 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::new(); let mut upper: Vec> = Vec::new(); // per accepted column: R entries over basis let mut accepted: Vec = 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 = basis .iter() .map(|q| -q.iter().zip(r).map(|(a, b)| a * b).sum::()) .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 = 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::().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 { 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 { 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 { 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 { 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)>> = std::cell::RefCell::new(Vec::new()); let noisy = |state: &[f64]| -> Vec { calls.set(calls.get() + 1); let noise = (calls.get() as f64 * 2.399_963).sin() * 1e-3; let out: Vec = 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::() .sqrt(); let step: f64 = next_input .iter() .zip(input) .map(|(a, b)| (a - b) * (a - b)) .sum::() .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 { 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 { .. }) )); } }