rtx-fsi: partitioned fluid-structure coupling
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
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
Performance Benchmarks / Run Benchmarks (push) Canceled after 0s

rtx-cfd (18,715 lines) and rtx-fea (36,576 lines) both exist and nothing
connects them -- rtx-fea is commented out of rtx-cfd's dependencies. This
is the coupling layer, and it is the piece Prof. Charbel Farhat's 2026
Guggenheim Medal citation is actually about.

It depends on NEITHER solver. The properties that make a partitioned
coupling correct -- conservation of force, moment and interface work --
are statements about the transfer operators alone, so they can be
validated now, on solvers whose canonical-benchmark validation is still
outstanding. Adapters to the concrete solvers belong above this.

TRANSFER (transfer.rs). Weights satisfy two constraints:
  sum(w_i) = 1            partition of unity  -> force conserved
  sum(w_i x_i) = x_face   linear reproduction -> MOMENT conserved

The second is the one that gets skipped. Inverse-distance weighting
satisfies the first and generally violates the second, conserving force
while corrupting moment -- which shows up as slow spurious rotation rather
than as an obvious error. Underdetermined for >4 nodes, so it takes the
minimum-norm solution w = A^T (A A^T)^+ b.

That is a PSEUDO-inverse, and not for defensiveness. A wetted surface is a
surface, so its nodes are usually planar, and for a planar patch the z
constraint row is an affine multiple of the ones row -- A A^T is genuinely
rank-deficient. The constraint is redundant there, not unsatisfiable. An
ordinary inverse rejects the most ordinary interface there is; I found
this because my first test fixture was collinear and the code correctly
refused it. Constraints are then verified against the weights actually
obtained, since a pseudo-inverse returns a least-squares answer whether or
not the system was consistent.

Motion transfer uses the TRANSPOSE of the load operator, which makes
interface work conserved identically: (Hf).v = f.(H^T v). Any other
pairing leaks energy every step, and the leak looks like physics until it
destabilises.

COUPLING (coupling.rs). Staggered and Aitken-relaxed subiteration. The
decisive tests reproduce the added-mass effect: at a gain of 2.5 the
fixed-relaxation scheme DIVERGES and is reported as CouplingDiverged
rather than as an exhausted budget, and Aitken recovers the same case. A
partitioned coupling that cannot reproduce its own classic failure mode is
not being tested hard enough. Aitken is exact for a linear fixed point, so
convergence is asserted at <=4 iterations -- pinning that this is the real
delta-squared formula and not an under-relaxation that happens to work.

SCOPE, stated up front in the crate docs: small-displacement transpiration
coupling on a fixed mesh. Deliberately not ALE and not embedded-boundary,
so the Discrete Geometric Conservation Law does not yet apply -- the mesh
does not move. Large motion needs an embedded boundary treatment; that is
the next phase, not an oversight.

External comparator named at entry: Turek-Hron FSI2/FSI3, not yet reached.

26 tests written red-first; cargo test/fmt/clippy -D warnings clean.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-19 06:49:36 -07:00
co-authored by Claude Opus 5
parent 5155c081ca
commit 9be5f4a68f
6 changed files with 1097 additions and 0 deletions
+2
View File
@@ -100,6 +100,7 @@ members = [
"crates/specialized/rtx-nmf", "crates/specialized/rtx-nmf",
"crates/specialized/rtx-fea", "crates/specialized/rtx-fea",
"crates/specialized/rtx-cfd", "crates/specialized/rtx-cfd",
"crates/specialized/rtx-fsi",
# Medical imaging crates (MRI2FE parity) # Medical imaging crates (MRI2FE parity)
"crates/specialized/rtx-medical-core", # Super-crate consolidating medical imaging I/O "crates/specialized/rtx-medical-core", # Super-crate consolidating medical imaging I/O
@@ -392,6 +393,7 @@ rtx-science = { path = "crates/specialized/rtx-science", version = "1.0.0" }
rtx-nmf = { path = "crates/specialized/rtx-nmf", version = "1.0.0" } rtx-nmf = { path = "crates/specialized/rtx-nmf", version = "1.0.0" }
rtx-fea = { path = "crates/specialized/rtx-fea", version = "1.0.0" } rtx-fea = { path = "crates/specialized/rtx-fea", version = "1.0.0" }
rtx-cfd = { path = "crates/specialized/rtx-cfd", version = "1.0.0" } rtx-cfd = { path = "crates/specialized/rtx-cfd", version = "1.0.0" }
rtx-fsi = { path = "crates/specialized/rtx-fsi", version = "1.0.0" }
rtx-medical-core = { path = "crates/specialized/rtx-medical-core", version = "1.0.0" } rtx-medical-core = { path = "crates/specialized/rtx-medical-core", version = "1.0.0" }
rtx-medical-io = { path = "crates/specialized/rtx-medical-io", version = "1.0.0" } rtx-medical-io = { path = "crates/specialized/rtx-medical-io", version = "1.0.0" }
rtx-materials = { path = "crates/specialized/rtx-materials", version = "1.0.0" } rtx-materials = { path = "crates/specialized/rtx-materials", version = "1.0.0" }
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "rtx-fsi"
version = "1.0.0"
edition.workspace = true
rust-version = "1.92"
authors = ["RustyTorch Team"]
license = "MIT OR Apache-2.0"
description = "Partitioned fluid-structure interaction coupling: conservative load and motion transfer across non-matching interfaces"
keywords = ["fsi", "fluid-structure", "coupling", "aeroelasticity", "simulation"]
categories = ["science", "simulation"]
repository = "https://github.com/rustytorch/rustytorch"
[dependencies]
nalgebra = { workspace = true }
thiserror = { workspace = true }
[lints]
workspace = true
+399
View File
@@ -0,0 +1,399 @@
//! 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".
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,
})
}
}
/// Aitken delta-squared relaxation factor from successive residuals.
///
/// Falls back to the previous factor when the residual barely moved,
/// since the update divides by that difference.
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();
if denominator <= f64::EPSILON {
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_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 { .. })
));
}
}
+93
View File
@@ -0,0 +1,93 @@
//! Errors raised when an interface or a transfer cannot be formed.
use thiserror::Error;
/// Why a coupling operation was refused.
#[derive(Debug, Clone, PartialEq, Error)]
pub enum FsiError {
/// One side of the interface was empty.
#[error("empty interface: {side} has no entries")]
EmptyInterface {
/// Which side was empty.
side: &'static str,
},
/// Too few structure nodes to reproduce a face centroid exactly.
///
/// Linear reproduction in three dimensions imposes four constraints,
/// so at least four independent nodes are needed. Falling back to a
/// weaker interpolation would conserve force and silently corrupt
/// moment, which is worse than refusing.
#[error("interface needs at least {needed} structure nodes for linear reproduction, got {got}")]
InsufficientNodes {
/// Nodes supplied.
got: usize,
/// Nodes required.
needed: usize,
},
/// A field had a different length from the mesh it belongs to.
#[error("{field}: expected {expected} values, got {got}")]
CountMismatch {
/// Which field.
field: &'static str,
/// Length supplied.
got: usize,
/// Length required.
expected: usize,
},
/// A field value was NaN or infinite.
#[error("non-finite value in {field} at index {index}")]
NonFinite {
/// Which field.
field: &'static str,
/// Where.
index: usize,
},
/// A parameter given to a coupling scheme was outside its valid
/// range.
#[error("invalid coupling parameter {parameter}: {value}")]
InvalidParameter {
/// Which parameter.
parameter: &'static str,
/// The value supplied.
value: f64,
},
/// The interface iteration ran away.
///
/// In partitioned coupling this is almost always the added-mass
/// effect: once the fluid's added mass exceeds the structural mass the
/// fixed point becomes repulsive, and a fixed relaxation factor cannot
/// recover it. Dynamic relaxation can.
#[error("coupling diverged after {iterations} iterations (residual {residual})")]
CouplingDiverged {
/// Iterations completed before divergence was detected.
iterations: usize,
/// Residual at that point.
residual: f64,
},
/// The iteration budget ran out before the interface agreed.
#[error(
"coupling did not converge in {iterations} iterations (residual {residual}, tolerance {tolerance})"
)]
CouplingNotConverged {
/// Iterations performed.
iterations: usize,
/// Residual reached.
residual: f64,
/// Tolerance required.
tolerance: f64,
},
/// The interpolation system was singular — the selected nodes are
/// degenerate, for example all collinear.
#[error("degenerate node neighbourhood for face {face}: cannot reproduce its centroid")]
DegenerateNeighbourhood {
/// Which face.
face: usize,
},
}
+41
View File
@@ -0,0 +1,41 @@
//! # `rtx-fsi` — partitioned fluidstructure interaction coupling
//!
//! Fluid and structure solvers exist separately in this workspace
//! (`rtx-cfd`, `rtx-fea`) and nothing connects them. This crate is the
//! coupling layer: it moves loads from the fluid onto the structure and
//! motion from the structure back onto the fluid, across an interface
//! where the two meshes do **not** match — which is the normal case and
//! the source of most fluidstructure interaction errors.
//!
//! # Why the coupling is worth building separately
//!
//! The properties that make a partitioned coupling correct are testable
//! **independently of whether the solvers it couples are accurate**.
//! Conservation of force, moment and interface work are statements about
//! the transfer operators alone. So this crate can be validated now, on
//! solvers whose canonical-benchmark validation is still outstanding.
//!
//! It therefore depends on neither `rtx-cfd` nor `rtx-fea`. It takes
//! geometry and field values, and returns field values. Adapters to the
//! concrete solvers belong above it.
//!
//! # Scope, stated up front
//!
//! This is a **small-displacement** coupling. Interface velocity is meant
//! to be applied to the fluid through a boundary condition on a fixed
//! mesh — a transpiration formulation, valid while displacements are
//! small relative to a cell. It is deliberately not arbitrary
//! LagrangianEulerian and not an embedded boundary method, so the
//! **Discrete Geometric Conservation Law does not yet apply**: the mesh
//! does not move. Large motion needs an embedded boundary treatment, and
//! that is the next phase rather than an oversight.
#![forbid(unsafe_code)]
mod coupling;
mod error;
mod transfer;
pub use coupling::{Converged, Subiterated};
pub use error::FsiError;
pub use transfer::{FluidFace, WettedSurface};
+544
View File
@@ -0,0 +1,544 @@
//! Conservative load and motion transfer across a non-matching interface.
//!
//! # The two constraints that make it conservative
//!
//! Each fluid face's load is distributed onto nearby structure nodes with
//! weights `w_i`. Two conditions decide whether the transfer conserves
//! anything:
//!
//! - `sum(w_i) = 1` — **partition of unity**. Total force is preserved.
//! - `sum(w_i * x_i) = x_face` — **linear reproduction**. The load arrives
//! where it left, in the weighted-average sense, so total **moment** is
//! preserved too, about any point.
//!
//! The second is the one that gets skipped. Inverse-distance weighting
//! satisfies partition of unity and generally violates linear
//! reproduction, which conserves force while quietly corrupting moment —
//! a defect that shows up as a slow spurious rotation rather than as an
//! obvious error.
//!
//! Both constraints leave the weights underdetermined for more than four
//! nodes, so this takes the **minimum-norm** solution: `w = Aᵀ(AAᵀ)⁻¹ b`,
//! where `A` stacks the ones row and the coordinate rows. Minimum norm
//! keeps the load spread rather than concentrated on whichever node the
//! solver happened to favour.
//!
//! # Why motion transfer is the transpose
//!
//! Given the load operator `H` mapping face loads to nodal forces, using
//! `Hᵀ` to map nodal velocities to face velocities makes interface work
//! conserved *identically*:
//!
//! ```text
//! (H f)·v = f·(Hᵀ v)
//! ```
//!
//! which is just the definition of the transpose. Any other pairing leaks
//! energy across the interface every step, and the leak looks like physics
//! until it destabilises.
use nalgebra::{Matrix4, Vector3, Vector4};
use crate::error::FsiError;
/// Structure nodes recruited per fluid face.
///
/// Four is the minimum for linear reproduction in three dimensions. More
/// spreads the load and conditions the system better; too many turn a
/// local transfer into a global smear.
const NEIGHBOURS: usize = 8;
/// Singular values below this share of the largest are treated as zero
/// when forming the pseudo-inverse.
const SINGULAR_TOLERANCE: f64 = 1e-12;
/// How exactly the partition-of-unity and linear-reproduction constraints
/// must hold before a face is accepted. Tight, because these are the
/// properties the conservation guarantees rest on.
const CONSTRAINT_TOLERANCE: f64 = 1e-9;
/// One fluid-side boundary face on the wetted surface.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FluidFace {
/// Face centroid.
pub centroid: Vector3<f64>,
/// Outward unit normal, pointing into the fluid.
pub normal: Vector3<f64>,
/// Face area, used to turn a traction into a force.
pub area: f64,
}
/// The interface between the two meshes, with the transfer operator
/// already formed.
#[derive(Debug, Clone, PartialEq)]
pub struct WettedSurface {
/// Per face: the recruited nodes and their weights.
rows: Vec<Vec<(usize, f64)>>,
node_count: usize,
}
impl WettedSurface {
/// Form the transfer operator between a fluid boundary and a
/// structural interface mesh.
///
/// # Errors
/// - [`FsiError::EmptyInterface`] if either side is empty.
/// - [`FsiError::InsufficientNodes`] with fewer than four nodes.
/// - [`FsiError::DegenerateNeighbourhood`] if a face's nearest nodes
/// are collinear or coplanar in a way that makes the constraint
/// system singular.
pub fn build(
fluid_faces: &[FluidFace],
structure_nodes: &[Vector3<f64>],
) -> Result<Self, FsiError> {
if fluid_faces.is_empty() {
return Err(FsiError::EmptyInterface { side: "fluid" });
}
if structure_nodes.is_empty() {
return Err(FsiError::EmptyInterface { side: "structure" });
}
if structure_nodes.len() < 4 {
return Err(FsiError::InsufficientNodes {
got: structure_nodes.len(),
needed: 4,
});
}
let mut rows = Vec::with_capacity(fluid_faces.len());
for (face_index, face) in fluid_faces.iter().enumerate() {
let recruited = nearest(structure_nodes, face.centroid, NEIGHBOURS);
let weights = solve_weights(structure_nodes, &recruited, face.centroid)
.ok_or(FsiError::DegenerateNeighbourhood { face: face_index })?;
rows.push(recruited.into_iter().zip(weights).collect());
}
Ok(Self {
rows,
node_count: structure_nodes.len(),
})
}
/// Number of fluid faces on the interface.
#[must_use]
pub fn face_count(&self) -> usize {
self.rows.len()
}
/// Number of structure nodes on the interface.
#[must_use]
pub const fn node_count(&self) -> usize {
self.node_count
}
/// The `(node, weight)` pairs a face distributes onto.
#[must_use]
pub fn weights_for(&self, face: usize) -> &[(usize, f64)] {
self.rows.get(face).map_or(&[], Vec::as_slice)
}
/// Fluid tractions to structural nodal forces.
///
/// Conserves total force and total moment exactly.
///
/// # Errors
/// [`FsiError::CountMismatch`] if the field lengths disagree with the
/// interface, or [`FsiError::NonFinite`] for a NaN or infinite value.
pub fn transfer_load(
&self,
fluid_faces: &[FluidFace],
tractions: &[Vector3<f64>],
) -> Result<Vec<Vector3<f64>>, FsiError> {
if fluid_faces.len() != self.rows.len() {
return Err(FsiError::CountMismatch {
field: "fluid_faces",
got: fluid_faces.len(),
expected: self.rows.len(),
});
}
if tractions.len() != self.rows.len() {
return Err(FsiError::CountMismatch {
field: "tractions",
got: tractions.len(),
expected: self.rows.len(),
});
}
check_finite("tractions", tractions)?;
let mut nodal = vec![Vector3::zeros(); self.node_count];
for ((face, traction), row) in fluid_faces.iter().zip(tractions).zip(&self.rows) {
let force = traction * face.area;
for (node, weight) in row {
nodal[*node] += force * *weight;
}
}
Ok(nodal)
}
/// Structural nodal velocities to fluid face velocities.
///
/// Uses the transpose of the load operator, which is what makes
/// interface work conserved identically.
///
/// # Errors
/// [`FsiError::CountMismatch`] or [`FsiError::NonFinite`].
pub fn transfer_motion(
&self,
node_velocities: &[Vector3<f64>],
) -> Result<Vec<Vector3<f64>>, FsiError> {
if node_velocities.len() != self.node_count {
return Err(FsiError::CountMismatch {
field: "node_velocities",
got: node_velocities.len(),
expected: self.node_count,
});
}
check_finite("node_velocities", node_velocities)?;
Ok(self
.rows
.iter()
.map(|row| {
row.iter()
.map(|(node, weight)| node_velocities[*node] * *weight)
.sum()
})
.collect())
}
}
/// Indices of the `count` nodes nearest a point.
fn nearest(nodes: &[Vector3<f64>], point: Vector3<f64>, count: usize) -> Vec<usize> {
let mut ordered: Vec<(f64, usize)> = nodes
.iter()
.enumerate()
.map(|(index, node)| ((node - point).norm_squared(), index))
.collect();
ordered.sort_by(|a, b| a.0.total_cmp(&b.0));
ordered
.into_iter()
.take(count.min(nodes.len()))
.map(|(_, index)| index)
.collect()
}
/// Minimum-norm weights satisfying partition of unity and linear
/// reproduction of `centroid`.
///
/// Solves `w = Aᵀ(AAᵀ)⁺ b` with `A` the 4×k constraint matrix, using the
/// **pseudo-inverse** rather than an inverse.
///
/// That is not defensive programming, it is the common case. A wetted
/// surface is a surface, so its nodes are usually planar — and for a
/// planar patch the `z` constraint row is an affine multiple of the ones
/// row, leaving `AAᵀ` genuinely rank-deficient. The constraint is not
/// unsatisfiable there, it is *redundant*: every partition of unity
/// reproduces a coordinate that is the same at every node. An ordinary
/// inverse would reject the most ordinary interface there is.
///
/// The constraints are then checked against the weights actually
/// obtained, because a pseudo-inverse returns a least-squares answer
/// whether or not the system was consistent. If the target genuinely
/// cannot be reproduced — a face outside the span of its recruited
/// nodes — the residual reveals it and the face is refused.
fn solve_weights(
nodes: &[Vector3<f64>],
recruited: &[usize],
centroid: Vector3<f64>,
) -> Option<Vec<f64>> {
let mut gram = Matrix4::zeros();
for index in recruited {
let node = nodes[*index];
let row = Vector4::new(1.0, node.x, node.y, node.z);
gram += row * row.transpose();
}
let target = Vector4::new(1.0, centroid.x, centroid.y, centroid.z);
let lambda = gram.pseudo_inverse(SINGULAR_TOLERANCE).ok()? * target;
let weights: Vec<f64> = recruited
.iter()
.map(|index| {
let node = nodes[*index];
Vector4::new(1.0, node.x, node.y, node.z).dot(&lambda)
})
.collect();
// Verify what was asked for, rather than trusting the solve.
let unity: f64 = weights.iter().sum();
let reproduced: Vector3<f64> = recruited
.iter()
.zip(&weights)
.map(|(index, weight)| nodes[*index] * *weight)
.sum();
if (unity - 1.0).abs() > CONSTRAINT_TOLERANCE
|| (reproduced - centroid).norm() > CONSTRAINT_TOLERANCE
{
return None;
}
Some(weights)
}
fn check_finite(field: &'static str, values: &[Vector3<f64>]) -> Result<(), FsiError> {
for (index, value) in values.iter().enumerate() {
if !value.iter().all(|component| component.is_finite()) {
return Err(FsiError::NonFinite { field, index });
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use nalgebra::Vector3;
/// A structure-side patch that deliberately does not match the fluid
/// side: 4x4 nodes at 0.25 spacing on the z = 0 plane. Matching meshes
/// hide every interesting failure, and a planar patch is the ordinary
/// case a wetted surface presents.
fn structure_nodes() -> Vec<Vector3<f64>> {
let mut nodes = Vec::new();
for i in 0..4 {
for j in 0..4 {
nodes.push(Vector3::new(f64::from(i) * 0.25, f64::from(j) * 0.25, 0.0));
}
}
nodes
}
/// Fluid faces at 0.35 spacing, offset so no face sits on a node.
fn fluid_faces() -> Vec<FluidFace> {
let mut faces = Vec::new();
for i in 0..3 {
for j in 0..3 {
faces.push(FluidFace {
centroid: Vector3::new(0.1 + f64::from(i) * 0.3, 0.1 + f64::from(j) * 0.3, 0.0),
normal: Vector3::new(0.0, 0.0, 1.0),
area: 0.09,
});
}
}
faces
}
// ---- the conservation properties ----
#[test]
fn the_weights_form_a_partition_of_unity() {
// Sum to one is what conserves total force. Without it the
// coupling quietly gains or loses load every step.
let surface = WettedSurface::build(&fluid_faces(), &structure_nodes()).expect("buildable");
for face in 0..surface.face_count() {
let total: f64 = surface.weights_for(face).iter().map(|(_, w)| w).sum();
assert!(
(total - 1.0).abs() < 1e-12,
"face {face} weights sum to {total}"
);
}
}
#[test]
fn the_weights_reproduce_the_face_centroid() {
// Linear reproduction. This is what conserves *moment*: the load
// must arrive at the same place it left, in the weighted-average
// sense. Partition of unity alone conserves force and silently
// corrupts the moment.
let nodes = structure_nodes();
let surface = WettedSurface::build(&fluid_faces(), &nodes).expect("buildable");
for (face, expected) in fluid_faces().iter().enumerate() {
let reproduced: Vector3<f64> = surface
.weights_for(face)
.iter()
.map(|(node, w)| nodes[*node] * *w)
.sum();
let error = (reproduced - expected.centroid).norm();
assert!(error < 1e-10, "face {face} centroid off by {error}");
}
}
#[test]
fn total_force_is_preserved_across_the_interface() {
let nodes = structure_nodes();
let faces = fluid_faces();
let surface = WettedSurface::build(&faces, &nodes).expect("buildable");
// A non-uniform traction, so a bug cannot hide behind symmetry.
let tractions: Vec<Vector3<f64>> = (0..faces.len())
.map(|i| Vector3::new(1.0 + f64::from(i as i32), -2.0, 0.5))
.collect();
let fluid_total: Vector3<f64> = faces
.iter()
.zip(&tractions)
.map(|(face, traction)| traction * face.area)
.sum();
let nodal = surface
.transfer_load(&faces, &tractions)
.expect("transferable");
let structure_total: Vector3<f64> = nodal.iter().sum();
let error = (structure_total - fluid_total).norm();
assert!(error < 1e-10, "force lost across interface: {error}");
}
#[test]
fn total_moment_is_preserved_across_the_interface() {
let nodes = structure_nodes();
let faces = fluid_faces();
let surface = WettedSurface::build(&faces, &nodes).expect("buildable");
let tractions: Vec<Vector3<f64>> = (0..faces.len())
.map(|i| Vector3::new(1.0 + f64::from(i as i32), -2.0, 0.5))
.collect();
// About an arbitrary point, deliberately not the origin: a scheme
// that only conserves moment about one special point is not
// conserving moment.
let about = Vector3::new(-0.7, 1.3, 2.1);
let fluid_moment: Vector3<f64> = faces
.iter()
.zip(&tractions)
.map(|(face, traction)| (face.centroid - about).cross(&(traction * face.area)))
.sum();
let nodal = surface
.transfer_load(&faces, &tractions)
.expect("transferable");
let structure_moment: Vector3<f64> = nodes
.iter()
.zip(&nodal)
.map(|(position, force)| (position - about).cross(force))
.sum();
let error = (structure_moment - fluid_moment).norm();
assert!(error < 1e-10, "moment lost across interface: {error}");
}
#[test]
fn interface_work_is_conserved() {
// The decisive one. Motion transfer uses the transpose of the load
// transfer, so the work the fluid does equals the work the
// structure receives, exactly. A coupling that fails this injects
// or drains energy every step and will eventually destabilise for
// reasons that look like physics.
let nodes = structure_nodes();
let faces = fluid_faces();
let surface = WettedSurface::build(&faces, &nodes).expect("buildable");
let tractions: Vec<Vector3<f64>> = (0..faces.len())
.map(|i| Vector3::new(0.3 * f64::from(i as i32), 1.7, -0.4))
.collect();
let node_velocities: Vec<Vector3<f64>> = (0..nodes.len())
.map(|i| Vector3::new(0.1, 0.2 * f64::from(i as i32), -0.05))
.collect();
let nodal_forces = surface
.transfer_load(&faces, &tractions)
.expect("transferable");
let face_velocities = surface
.transfer_motion(&node_velocities)
.expect("transferable");
let fluid_work: f64 = faces
.iter()
.zip(&tractions)
.zip(&face_velocities)
.map(|((face, traction), velocity)| (traction * face.area).dot(velocity))
.sum();
let structure_work: f64 = nodal_forces
.iter()
.zip(&node_velocities)
.map(|(force, velocity)| force.dot(velocity))
.sum();
assert!(
(fluid_work - structure_work).abs() < 1e-10,
"interface work not conserved: fluid {fluid_work}, structure {structure_work}"
);
}
// ---- limits ----
#[test]
fn zero_traction_moves_nothing() {
let nodes = structure_nodes();
let faces = fluid_faces();
let surface = WettedSurface::build(&faces, &nodes).expect("buildable");
let tractions = vec![Vector3::zeros(); faces.len()];
let nodal = surface
.transfer_load(&faces, &tractions)
.expect("transferable");
assert!(nodal.iter().all(|force| force.norm() < 1e-15));
}
#[test]
fn a_motionless_structure_leaves_the_fluid_boundary_still() {
let nodes = structure_nodes();
let surface = WettedSurface::build(&fluid_faces(), &nodes).expect("buildable");
let velocities = vec![Vector3::zeros(); nodes.len()];
let face_velocities = surface.transfer_motion(&velocities).expect("transferable");
assert!(face_velocities.iter().all(|v| v.norm() < 1e-15));
}
#[test]
fn a_rigid_translation_transfers_unchanged() {
// Every structure node moving together must give every fluid face
// the same velocity. This follows from partition of unity, and it
// is the check that catches a normalisation slip.
let nodes = structure_nodes();
let surface = WettedSurface::build(&fluid_faces(), &nodes).expect("buildable");
let rigid = Vector3::new(0.4, -1.1, 0.9);
let velocities = vec![rigid; nodes.len()];
for velocity in surface.transfer_motion(&velocities).expect("transferable") {
assert!(
(velocity - rigid).norm() < 1e-12,
"rigid translation distorted: {velocity:?}"
);
}
}
// ---- refusals ----
#[test]
fn an_interface_with_too_few_nodes_is_refused() {
// Linear reproduction in three dimensions needs four independent
// nodes. Fewer cannot satisfy the constraint, and pretending
// otherwise would silently break moment conservation.
let nodes: Vec<Vector3<f64>> = (0..3)
.map(|i| Vector3::new(f64::from(i), f64::from(i) * 0.5, 0.0))
.collect();
assert!(matches!(
WettedSurface::build(&fluid_faces(), &nodes),
Err(FsiError::InsufficientNodes { .. })
));
}
#[test]
fn an_empty_interface_is_refused() {
assert!(WettedSurface::build(&[], &structure_nodes()).is_err());
assert!(WettedSurface::build(&fluid_faces(), &[]).is_err());
}
#[test]
fn a_traction_count_mismatch_is_refused() {
let surface = WettedSurface::build(&fluid_faces(), &structure_nodes()).expect("buildable");
let wrong = vec![Vector3::new(1.0, 0.0, 0.0); 2];
assert!(matches!(
surface.transfer_load(&fluid_faces(), &wrong),
Err(FsiError::CountMismatch { .. })
));
}
#[test]
fn a_non_finite_traction_is_refused() {
let faces = fluid_faces();
let surface = WettedSurface::build(&faces, &structure_nodes()).expect("buildable");
let mut tractions = vec![Vector3::new(1.0, 0.0, 0.0); faces.len()];
tractions[1].y = f64::NAN;
assert!(matches!(
surface.transfer_load(&faces, &tractions),
Err(FsiError::NonFinite { .. })
));
}
}