rtx-fsi + rtx-cfd + rtx-fea: FSI1 — the coupled cylinder and flag

The summit rung C1: all the verified pieces joined into the first coupled
Turek–Hron computation (rtx-fsi/tests/turek_hron_fsi1.rs). The embedded
fluid computes tractions on the DEFORMED flag surface
(EmbeddedMask::traction_at, factored from surface_force); the flag's
wetted boundary is a polygon whose vertex list sits behind a lock, so the
moving-body mask rebuild picks up every shape update
(EmbeddedBody::polygon + pub polygon_signed_distance); WettedSurface —
rebuilt on the deformed interface every subiteration — carries the loads
to the flag's boundary nodes (NonlinearStaticAnalysis::set_nodal_forces);
Subiterated::aitken drives the exchange, each pass marching the fluid to
flag-load stagnation so the coupling map is a function of geometry, not
of the fluid's transient.

Result (ny = 62, 6 Aitken passes, 420 s): coupled drag 15.360 (+7.5%,
the rigid CFD1 band at this grid), lift 0.7977 (+4.4%), ux(A) 2.647e-5
vs 2.270e-5 (+16.6%; +6.1% at ny = 82), uy(A) 3.90e-4 vs 8.21e-4 at
h = 6.6 mm and 1.124e-3 (+37%) at h = 5 mm — the resolutions BRACKET the
reference through the flag's 3 -> 4-cell thickness transition, like the
rigid-flag lift; conservation 7.4e-12 every pass. Bands asserted are the
measured ones; RTX_FSI1_NY runs studies.

Two real rtx-fsi defects found by this rung (15th and 16th of the
campaign), both regression-tested (tests/transfer_curved_edge.rs):

1. solve_weights built its constraint Gram from RAW coordinates: the
   condition number grows as (position/spacing)^2 — ~1e4 for a flag edge
   at x ~ 0.26 with 5 mm spacing — and the 4x4 SVD pseudo-inverse lost
   enough accuracy that the (correctly strict) partition-of-unity /
   reproduction verification rejected healthy neighbourhoods: the
   operator's behaviour depended on WHERE the interface sat. Now centred
   on the face and scaled by the neighbourhood radius — identical
   constraints, O(1) conditioning, translation-invariant.

2. A NEARLY collinear neighbourhood (the nearest nodes of a face on a
   smoothly deformed edge: y is almost linear in x, off by the curvature
   sagitta) cannot satisfy exact centroid reproduction with bounded
   weights — the offending singular value is too large to truncate and
   too small to invert. The recruitment now widens (8 -> 16 -> 32 -> all)
   until the verified constraints hold; for a thin structure that pulls
   in the opposite face, exactly the transverse spread the system needs.

Findings measured before believed: the transfer is faithful (a strictly
local two-node split of the same tractions moved the tip by 2%); the
uy error is the sampled lift PROFILE on a 3-cell flag (a uniform
distribution of the same net lift bends 4x more), confirmed by the
resolution study; TVD limiter chatter (+-0.5% steady load — limited
schemes stall short of machine steady state) defeats steady fixed-point
coupling, so steady coupled cases run upwind while the time-marched
FSI2/FSI3 keep TVD; and the mask never chattered at FSI1's sub-cell
amplitude (fluid-cell count constant through every pass).

rtx-fsi 29 -> 31 green (lib 27, piston 2, curved-edge 1, FSI1 1).

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-20 17:22:27 -07:00
co-authored by Claude Fable 5
parent 38ca5ef080
commit b82f307cae
5 changed files with 720 additions and 11 deletions
@@ -0,0 +1,41 @@
use nalgebra::Vector3;
use rtx_fsi::{FluidFace, WettedSurface};
/// A face on a smoothly curved, nearly collinear edge (the deformed-flag
/// neighbourhood that broke the fixed-count recruitment): the transfer must
/// widen the neighbourhood until the opposite face's nodes give it a
/// two-dimensional spread, and the verified constraints must then hold.
#[test]
fn nearly_collinear_curved_edge_recruits_wider() {
let mut nodes = Vec::new();
let mut x = 0.255;
// Deformed edges: y = y0 + kappa * (x - 0.25)^2 with the FSI1 scale.
while x < 0.6 + 1e-9 {
let bend = 0.013 * (x - 0.25) * (x - 0.25) / 2.0;
nodes.push(Vector3::new(x, 0.19 + bend, 0.0));
nodes.push(Vector3::new(x, 0.21 + bend, 0.0));
x += 0.005;
}
let bend = |x: f64| 0.013 * (x - 0.25) * (x - 0.25) / 2.0;
let faces: Vec<FluidFace> = (0..60)
.map(|k| {
let x = 0.26 + 0.005 * k as f64 + 0.00125;
FluidFace {
centroid: Vector3::new(x, 0.19 + bend(x), 0.0),
normal: Vector3::new(0.0, -1.0, 0.0),
area: 0.0025,
}
})
.collect();
let surface = WettedSurface::build(&faces, &nodes).expect("adaptive recruitment must succeed");
for face in 0..faces.len() {
let weights = surface.weights_for(face);
let unity: f64 = weights.iter().map(|(_, w)| w).sum();
assert!((unity - 1.0).abs() < 1e-9, "face {face}: unity {unity}");
let max_weight = weights.iter().map(|(_, w)| w.abs()).fold(0.0, f64::max);
assert!(
max_weight < 100.0,
"face {face}: weight blow-up {max_weight}"
);
}
}