Files
rustytorch/crates/specialized/rtx-fsi/src/smoothing.rs
T
Omar SobhandClaude Fable 5 140310b223
Performance Benchmarks / Run Benchmarks (push) Canceled after 0s
CI / Format Check (push) Canceled after 0s
CI / Clippy Check (push) Canceled after 0s
CI / Build (macos-latest) (push) Canceled after 0s
CI / Build (ubuntu-latest) (push) Canceled after 0s
CI / Test (macos-latest) (push) Canceled after 0s
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Build CPU-Only (Explicit) (push) Canceled after 0s
CI / Python Bindings (maturin) (macos-latest) (push) Canceled after 0s
CI / Python Bindings (maturin) (ubuntu-latest) (push) Canceled after 0s
CI / WASM Build + Size Check (push) Canceled after 0s
CI / Distributed Training Tests (push) Canceled after 0s
CI / CI Success (push) Canceled after 0s
Documentation / Build API Documentation (push) Canceled after 0s
Documentation / Build User Guide (push) Canceled after 0s
rtx-fsi: the noise floor interrogated — smoothing refuted, IQN-ILS lands, tight coupling reopened
The tenth session ended on "lower the interface noise floor". This
builds the levers and measures them, and the measurements overturn the
diagnosis:

- smooth_tractions: arclength moving average over the wetted surface,
  area-weighted, smooth normal-similarity factor so corners do not mix
  and the smoothed load stays continuous in the geometry. Nine unit
  tests. MEASURED NEGATIVE RESULT: the flip-scan floor is unchanged to
  0.2% at radii 1-3h — the flip's load jump is coherent through the
  fluid field (mask rebuild shifts the pressure around the flipped
  cell), and a surface average preserves coherent shifts. Default off;
  the probe pins the attribution so nobody re-reaches for this lever.

- IqnIls: interface quasi-Newton with inverse least squares (Degroote
  2009) — filtered MGS least squares over secant columns (filter
  RELATIVE to column norm), cross-step history reuse, per-step
  set_tolerance. Model-map tests: exact on anisotropic linear maps
  within dim+2 passes (scalar Aitken provably cannot be), scale
  invariant, history reuse shortens the next step, stalls at the noise
  scale instead of diverging (fixture lesson: per-pass noise, not
  state-dependent noise — the latter has a genuine fixed point).

- tests/fsi2_harness/: the FSI2 machinery extracted shared; verified
  pure code motion (committed release response reproduced to every
  printed digit). March gains RTX_FSI2_SMOOTH / RTX_FSI2_COUPLER=iqn /
  RTX_FSI2_REUSE knobs; pinned bands guard the default configuration.

- tests/fsi2_interface_noise.rs: the probe. Flip-scan floor at
  subcycle 8: 3.05e-5 (pinned); smoothing attribution pinned; the
  cross-subcycle scan recorded but unpinned (the fixed geometry
  increment's wall-velocity trend, increment/dt_c, swamps the flip
  signal at small dt_c — a dt_c^2 scaling hypothesis died in that
  operationalization). THE OPERATIONAL FLOOR — the real release step
  subiterated at tolerance 1e-9 with residuals traced — converges DEEP
  at both subcycles: s8 aitken 3.4e-9 / iqn 1.6e-9, s2 both ~6.4e-10
  in 5-6 passes. The flip jumps are events at specific geometries, not
  a floor under every step: the tenth session's subcycle-2 blowup was
  tolerance mis-budgeting (2e-4 held fixed while dt_c shrank), not an
  impassable floor. Probe bug found and fixed on the way: stale shared
  geometry leaked a 4.5e-5 phantom first residual into the first stall
  run; every measurement now resets the geometry on entry.

All 924+17 tests green: lib 44 (was 27), piston 2, curved edge 1,
FSI1, the committed FSI2 march (release response identical), the probe.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Lnyrw33Lu6rUhW42E9KHwq
2026-08-21 06:42:05 -07:00

310 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Smoothing sampled tractions along the wetted surface.
//!
//! # Why the load needs smoothing at all
//!
//! An embedded-boundary fluid samples tractions by reconstructing the
//! near-wall field from whichever cells are currently fluid. When the
//! interface moves — even by a vanishing amount — a cell can flip between
//! fluid and solid and every sample whose stencil contains it jumps by a
//! finite amount. Mapped through the structure's per-step compliance this
//! becomes the **interface noise floor**: the smallest displacement
//! tolerance a partitioned coupling can actually reach (measured on
//! TurekHron FSI2: ~1.3e-4 m per pass at full inflow, and it rides with
//! the loads). Tighter time coupling than the floor allows is blocked,
//! because wall-velocity noise is displacement-tolerance / dt.
//!
//! The flips are a cell-resolution artefact: the discretisation cannot
//! represent traction variation below the cell scale in the first place,
//! so averaging the sampled tractions over a stencil about that scale
//! removes noise the samples were never entitled to carry.
//!
//! # The kernel, and why every factor is continuous
//!
//! Each smoothed traction is a normalised weighted average over the
//! samples within `radius` of it along the surface:
//!
//! ```text
//! t'_i = sum_j k(|s_i - s_j|) c_ij A_j t_j / sum_j k(|s_i - s_j|) c_ij A_j
//! ```
//!
//! - `k` is a triangular kernel in **arclength** `s` (cumulative centroid
//! distance): samples separated by a gap — e.g. the part of a flag
//! buried in its mounting cylinder — sit far apart in arclength and
//! never mix.
//! - `c_ij = max(0, n_i . n_j)^2` keeps averaging from mixing tractions
//! across corners: `sigma . n` on the two sides of a corner are loads
//! in different directions, and averaging the vectors would manufacture
//! a spurious tangential load. The factor is **smooth** in the normals,
//! deliberately: a hard angular cutoff would make the smoothed load a
//! discontinuous function of the interface geometry, and a coupling
//! subiteration bounces on exactly such discontinuities (the
//! clamp-don't-drop finding from the spike guard).
//! - `A_j` weights by face area, so the average is the area-consistent
//! one and a constant traction field is reproduced exactly.
//!
//! On a uniformly sampled straight stretch the kernel matrix is
//! symmetric with unit column sums, so the total force over the interior
//! is conserved exactly; end effects and corners redistribute load only
//! within a kernel radius, at the scale the sampling could not resolve
//! anyway.
use nalgebra::Vector3;
use crate::error::FsiError;
use crate::transfer::FluidFace;
/// Smooth sampled tractions with a triangular moving average of
/// half-width `radius` in surface arclength, weighted by face area and by
/// normal similarity (see the module docs for the kernel and its
/// continuity rationale).
///
/// `faces` must be ordered along the surface — arclength is accumulated
/// from consecutive centroid distances. A `radius` of zero returns the
/// tractions unchanged.
///
/// # Errors
/// - [`FsiError::CountMismatch`] if `tractions` and `faces` differ in
/// length.
/// - [`FsiError::InvalidParameter`] for a negative or non-finite radius.
/// - [`FsiError::NonFinite`] for a non-finite traction sample.
pub fn smooth_tractions(
faces: &[FluidFace],
tractions: &[Vector3<f64>],
radius: f64,
) -> Result<Vec<Vector3<f64>>, FsiError> {
if tractions.len() != faces.len() {
return Err(FsiError::CountMismatch {
field: "tractions",
got: tractions.len(),
expected: faces.len(),
});
}
if !radius.is_finite() || radius < 0.0 {
return Err(FsiError::InvalidParameter {
parameter: "smoothing radius",
value: radius,
});
}
if let Some(index) = tractions
.iter()
.position(|t| !t.iter().all(|v| v.is_finite()))
{
return Err(FsiError::NonFinite {
field: "tractions",
index,
});
}
if radius == 0.0 || faces.is_empty() {
return Ok(tractions.to_vec());
}
// Cumulative arclength along the ordered samples.
let mut s = Vec::with_capacity(faces.len());
let mut acc = 0.0;
s.push(0.0);
for pair in faces.windows(2) {
acc += (pair[1].centroid - pair[0].centroid).norm();
s.push(acc);
}
let mut smoothed = Vec::with_capacity(faces.len());
for i in 0..faces.len() {
// The window is a contiguous index range because arclength is
// monotone in the ordering.
let lo = (0..i)
.rev()
.take_while(|&j| s[i] - s[j] < radius)
.last()
.unwrap_or(i);
let hi = (i + 1..faces.len())
.take_while(|&j| s[j] - s[i] < radius)
.last()
.unwrap_or(i);
let mut sum = Vector3::zeros();
let mut weight_sum = 0.0;
for j in lo..=hi {
let kernel = 1.0 - (s[i] - s[j]).abs() / radius;
let alignment = faces[i].normal.dot(&faces[j].normal).max(0.0).powi(2);
let w = kernel * alignment * faces[j].area;
sum += w * tractions[j];
weight_sum += w;
}
// The self term always contributes (kernel 1, alignment 1), so
// the denominator cannot vanish for a face with positive area.
smoothed.push(sum / weight_sum);
}
Ok(smoothed)
}
#[cfg(test)]
mod tests {
use super::*;
/// A straight horizontal stretch of `n` uniformly spaced samples with
/// upward normals — the interior of a wall, as the sampler sees it.
fn straight_faces(n: usize, spacing: f64) -> Vec<FluidFace> {
(0..n)
.map(|i| FluidFace {
centroid: Vector3::new(i as f64 * spacing, 0.0, 0.0),
normal: Vector3::new(0.0, 1.0, 0.0),
area: spacing,
})
.collect()
}
#[test]
fn a_constant_field_is_reproduced_exactly() {
// The weights are normalised, so any constant must pass through
// untouched — including at the ends, where the window truncates.
let faces = straight_faces(20, 0.1);
let tractions = vec![Vector3::new(3.0, -2.0, 0.0); 20];
let smoothed = smooth_tractions(&faces, &tractions, 0.25).unwrap();
for t in &smoothed {
assert!((t - Vector3::new(3.0, -2.0, 0.0)).norm() < 1e-14);
}
}
#[test]
fn zero_radius_is_the_identity() {
let faces = straight_faces(5, 0.1);
let tractions: Vec<_> = (0..5)
.map(|i| Vector3::new(i as f64, -(i as f64), 0.0))
.collect();
let smoothed = smooth_tractions(&faces, &tractions, 0.0).unwrap();
assert_eq!(smoothed, tractions);
}
#[test]
fn a_single_sample_spike_is_reduced_and_its_force_conserved() {
// The mask-flip signature: one sample jumps by a finite amount.
// Smoothing must spread it (peak reduced) without losing the
// impulse (interior column sums are one on a uniform stretch).
let n = 21;
let faces = straight_faces(n, 0.1);
let mut tractions = vec![Vector3::zeros(); n];
tractions[10] = Vector3::new(0.0, 5.0, 0.0);
let smoothed = smooth_tractions(&faces, &tractions, 0.25).unwrap();
let peak = smoothed.iter().map(|t| t.norm()).fold(0.0, f64::max);
assert!(
peak < 0.6 * 5.0,
"spike should spread over the window, peak still {peak}"
);
let total_before: Vector3<f64> =
faces.iter().zip(&tractions).map(|(f, t)| t * f.area).sum();
let total_after: Vector3<f64> = faces.iter().zip(&smoothed).map(|(f, t)| t * f.area).sum();
assert!(
(total_after - total_before).norm() < 1e-12 * total_before.norm(),
"interior spike force changed: {} vs {}",
total_after.y,
total_before.y
);
}
#[test]
fn tractions_do_not_bleed_across_a_right_angle_corner() {
// Two perpendicular stretches meeting at a corner (a flag tip).
// sigma.n on the two sides are loads in different directions;
// max(0, n_i.n_j)^2 = 0 across the corner, so each side smooths
// only among its own.
let spacing = 0.1;
let mut faces = Vec::new();
for i in 0..5 {
faces.push(FluidFace {
centroid: Vector3::new(i as f64 * spacing, 0.0, 0.0),
normal: Vector3::new(0.0, -1.0, 0.0),
area: spacing,
});
}
for j in 0..5 {
faces.push(FluidFace {
centroid: Vector3::new(4.0 * spacing, (j + 1) as f64 * spacing, 0.0),
normal: Vector3::new(1.0, 0.0, 0.0),
area: spacing,
});
}
let mut tractions = vec![Vector3::new(0.0, -1.0, 0.0); 5];
tractions.extend(vec![Vector3::new(2.0, 0.0, 0.0); 5]);
let smoothed = smooth_tractions(&faces, &tractions, 0.35).unwrap();
for (k, t) in smoothed.iter().enumerate() {
if k < 5 {
assert!(
(t - Vector3::new(0.0, -1.0, 0.0)).norm() < 1e-14,
"bottom sample {k} contaminated across the corner: {t:?}"
);
} else {
assert!(
(t - Vector3::new(2.0, 0.0, 0.0)).norm() < 1e-14,
"side sample {k} contaminated across the corner: {t:?}"
);
}
}
}
#[test]
fn samples_across_an_arclength_gap_do_not_mix() {
// A buried stretch (samples skipped) leaves consecutive kept
// samples far apart in arclength; the kernel must not reach over.
let spacing = 0.1;
let mut faces = straight_faces(3, spacing);
for i in 0..3 {
faces.push(FluidFace {
centroid: Vector3::new(10.0 + i as f64 * spacing, 0.0, 0.0),
normal: Vector3::new(0.0, 1.0, 0.0),
area: spacing,
});
}
let mut tractions = vec![Vector3::new(0.0, 1.0, 0.0); 3];
tractions.extend(vec![Vector3::new(0.0, -1.0, 0.0); 3]);
let smoothed = smooth_tractions(&faces, &tractions, 0.25).unwrap();
for (k, t) in smoothed.iter().enumerate() {
let expected = if k < 3 { 1.0 } else { -1.0 };
assert!(
(t.y - expected).abs() < 1e-14,
"sample {k} mixed across the gap: {t:?}"
);
}
}
#[test]
fn mismatched_lengths_are_refused() {
let faces = straight_faces(4, 0.1);
let tractions = vec![Vector3::zeros(); 3];
assert!(matches!(
smooth_tractions(&faces, &tractions, 0.1),
Err(FsiError::CountMismatch { .. })
));
}
#[test]
fn an_invalid_radius_is_refused() {
let faces = straight_faces(4, 0.1);
let tractions = vec![Vector3::zeros(); 4];
assert!(matches!(
smooth_tractions(&faces, &tractions, -0.1),
Err(FsiError::InvalidParameter { .. })
));
assert!(matches!(
smooth_tractions(&faces, &tractions, f64::NAN),
Err(FsiError::InvalidParameter { .. })
));
}
#[test]
fn a_non_finite_traction_is_refused() {
let faces = straight_faces(4, 0.1);
let mut tractions = vec![Vector3::zeros(); 4];
tractions[2].x = f64::NAN;
assert!(matches!(
smooth_tractions(&faces, &tractions, 0.1),
Err(FsiError::NonFinite { .. })
));
}
#[test]
fn an_empty_surface_smooths_to_empty() {
let smoothed = smooth_tractions(&[], &[], 0.1).unwrap();
assert!(smoothed.is_empty());
}
}