Performance Benchmarks / Run Benchmarks (push) Canceled after 0s
CI / Format Check (push) Canceled after 0s
CI / Clippy Check (push) Canceled after 0s
CI / Build (macos-latest) (push) Canceled after 0s
CI / Build (ubuntu-latest) (push) Canceled after 0s
CI / Test (macos-latest) (push) Canceled after 0s
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Build CPU-Only (Explicit) (push) Canceled after 0s
CI / Python Bindings (maturin) (macos-latest) (push) Canceled after 0s
CI / Python Bindings (maturin) (ubuntu-latest) (push) Canceled after 0s
CI / WASM Build + Size Check (push) Canceled after 0s
CI / Distributed Training Tests (push) Canceled after 0s
CI / CI Success (push) Canceled after 0s
Documentation / Build API Documentation (push) Canceled after 0s
Documentation / Build User Guide (push) Canceled after 0s
The 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
310 lines
12 KiB
Rust
310 lines
12 KiB
Rust
//! 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
|
||
//! Turek–Hron 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());
|
||
}
|
||
}
|