//! 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], radius: f64, ) -> Result>, 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 { (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 = faces.iter().zip(&tractions).map(|(f, t)| t * f.area).sum(); let total_after: Vector3 = 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()); } }