P5-1: FSI2 with the fluid on the overset — fsi2_harness/overset.rs (the background without a body, the cylinder–flag patch regenerated around the deformed flag every pass via set_patch_mesh, the wall velocity from the interface velocities along the wetted polygon, the load from the patch's wall faces into WettedSurface::transfer_load — no probes, no clamp, no smoothing), fsi2_harness/overset_march.rs (the harness's rigid phase / release / subiterated coupling with its acceptance rule, no rescue machinery, death returned not panicked), tests/turek_hron_fsi2_overset.rs (RTX_FSI2O_* knobs); rtx-cfd: CurvilinearPisoSolver::wall_tractions (per-face pressure + full-stress traction, surface_force sums the same terms bit-identically); Interface::edges (bottom/tip/top for the generator); cylinder_flag_mms RTX_CF_BEND (P5-0 gate iv: Stokes orders 2.14 / 2.09 on the flag bent to 80 mm)
Documentation / Build API Documentation (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
Documentation / Build User Guide (push) Canceled after 0s
Performance Benchmarks / Run Benchmarks (push) Canceled after 0s
CI / Format Check (push) Canceled after 0s
CI / Clippy Check (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

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_0116sg1Qz1gMv9hdcKP1XUam
This commit is contained in:
Omar Sobh
2026-09-07 07:16:53 -07:00
co-authored by Claude Fable 5.1
parent c2451fbacf
commit f0b2563bf8
6 changed files with 1076 additions and 41 deletions
@@ -0,0 +1,407 @@
//! P5 (`docs/overset_metal_campaign.md` §5.12): the FSI2 harness's FLUID
//! SIDE on the overset — the background without a body, the cylinderflag
//! O-grid regenerated around the deformed flag every time the interface
//! moves (`set_patch_mesh`: the overlap rebuilt, fresh cells refilled, the
//! fringe re-stamped, the flux balance on), the patch's wall velocity from
//! the interface velocities (nearest wetted segment, linear along it), and
//! the load from the patch's wall faces (pressure + full-stress traction
//! per face into `WettedSurface::transfer_load`) — no probes, no spike
//! clamp, no smoothing. The structure side is the harness's, unchanged.
use std::cell::Cell;
use std::sync::{Arc, RwLock};
use nalgebra::Vector3;
use rtx_cfd::mesh::patch_gen::cylinder_flag_patch_deformed;
use rtx_cfd::mesh::PatchSide;
use rtx_cfd::solvers::incompressible::{
AleBoundaries, ConvectionScheme, CurvilinearParameters, CurvilinearPisoSolver,
EmbeddedParameters, EmbeddedPisoSolver, FlowField, MgPrecision, NormalDiffusion, OversetField,
OversetParameters, OversetPisoSolver, OversetResult, OversetSolverState, PatchConvection,
PatchField, PoissonSolverKind, SideBoundary,
};
use rtx_cfd::{CfdConfig, CfdResult};
use rtx_fea::mesh::{Mesh, NodeId};
use rtx_fsi::{FluidFace, WettedSurface};
use super::{flag_mesh, inflow_for, BenchmarkCase, Interface, H, L, NU_F, RHO_F};
const CYL_CENTRE: [f64; 2] = [0.2, 0.2];
const CYL_R: f64 = 0.05;
const FLAG_T: f64 = 0.01;
/// The junction fillet: a fixed 5 mm at every resolution (§5.11).
const FILLET: f64 = 0.5 * 0.41 / 41.0;
const PATCH_ROWS: usize = 12;
const PATCH_STRETCH: f64 = 4.0;
/// The deforming wall as the patch sees it: the wetted polygon (the
/// `Interface` walk, anchors included) and the velocity at each vertex.
#[derive(Debug, Clone, Default)]
pub struct WallMotion {
pub polygon: Vec<[f64; 2]>,
pub velocity: Vec<[f64; 2]>,
}
impl WallMotion {
/// Velocity at `(x, y)`: linear along the nearest polygon segment.
pub fn velocity_at(&self, x: f64, y: f64) -> (f64, f64) {
let n = self.polygon.len();
if n < 2 {
return (0.0, 0.0);
}
let (mut best_d, mut best) = (f64::INFINITY, (0.0, 0.0));
for i in 0..n - 1 {
let (a, b) = (self.polygon[i], self.polygon[i + 1]);
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let l2 = dx * dx + dy * dy;
let t = if l2 > 0.0 {
(((x - a[0]) * dx + (y - a[1]) * dy) / l2).clamp(0.0, 1.0)
} else {
0.0
};
let (px, py) = (a[0] + t * dx, a[1] + t * dy);
let d = (px - x).powi(2) + (py - y).powi(2);
if d < best_d {
best_d = d;
let (va, vb) = (self.velocity[i], self.velocity[i + 1]);
best = (va[0] + t * (vb[0] - va[0]), va[1] + t * (vb[1] - va[1]));
}
}
best
}
}
/// The overset fluid of the coupled march.
pub struct OversetFluid {
pub case: BenchmarkCase,
pub mesh: Mesh,
pub interface: Interface,
pub a_node: NodeId,
pub ny: usize,
pub nx: usize,
pub h: f64,
pub mu: f64,
pub dt_fluid: f64,
pub solver: OversetPisoSolver,
pub field: OversetField,
pub shared: Arc<RwLock<WallMotion>>,
pub sweeps: usize,
/// Patch regenerations and their wall time.
pub regen_count: Cell<usize>,
pub regen_seconds: Cell<f64>,
/// Background cells reclassified, summed over every fluid step.
pub reclassified_total: Cell<usize>,
pub fresh_total: Cell<usize>,
pub rounds_total: Cell<usize>,
pub correctors_total: Cell<usize>,
}
impl OversetFluid {
/// Build the composite at rest around the undeformed flag.
pub fn build_case(
case: BenchmarkCase,
ny: usize,
flag_nx: usize,
sweeps: usize,
max_rounds: usize,
) -> CfdResult<Self> {
let h = H / ny as f64;
let nx = (L / h).round() as usize;
let mu = RHO_F * NU_F;
let u_mean = case.u_mean;
let u_peak = 1.5 * 1.5 * u_mean;
let mesh = flag_mesh(flag_nx, 2);
let interface = Interface::build(&mesh);
let a_node = mesh
.nodes
.iter()
.find(|(_, n)| {
(n.position().x - 0.6).abs() < 1e-9 && (n.position().y - 0.2).abs() < 1e-9
})
.map(|(&id, _)| id)
.expect("point A");
let zero_d = vec![0.0; 2 * interface.wetted.len()];
let config = CfdConfig::new()
.with_density(RHO_F)
.with_viscosity(mu)
.with_reference_velocity(u_mean)
.with_reference_length(0.1);
let mut background = EmbeddedPisoSolver::new(
config.clone(),
EmbeddedParameters {
corrector_steps: 2,
tolerance: 1e-7,
boundaries: AleBoundaries {
left: SideBoundary::Velocity,
right: SideBoundary::PressureOutlet,
bottom: SideBoundary::Velocity,
top: SideBoundary::Velocity,
},
poisson_solver: PoissonSolverKind::Multigrid,
poisson_precision: MgPrecision::F64,
convection_scheme: ConvectionScheme::TvdVanAlbada,
},
)?;
background.set_boundary_velocity(move |x, y, t| {
if x <= 0.0 {
(inflow_for(u_mean, y, t), 0.0)
} else {
(0.0, 0.0)
}
});
let (patch_mesh, _) = cylinder_flag_patch_deformed(
CYL_CENTRE,
CYL_R,
FLAG_T,
&interface.edges(&zero_d),
h,
FILLET,
6.0 * h,
PATCH_ROWS,
PATCH_STRETCH,
sweeps,
)?;
// The patch's along-body explicit limit (its wall row is
// line-implicit) beside the background's combined criterion.
let mut hs = f64::INFINITY;
for c in 0..patch_mesh.cell_count() {
for (f, _) in patch_mesh.cell_faces(c) {
if patch_mesh.is_sface(f) {
let d = patch_mesh.faces()[f].d;
hs = hs.min((d[0] * d[0] + d[1] * d[1]).sqrt());
}
}
}
let dt_bg = 0.25 / (2.0 * u_peak / h + 4.0 * NU_F / (h * h));
let dt_patch = 0.4 * (hs * hs / (4.0 * NU_F)).min(hs / u_peak);
let dt_fluid = dt_bg.min(dt_patch);
let shared = Arc::new(RwLock::new(WallMotion {
polygon: interface
.polygon(&zero_d)
.iter()
.map(|&(x, y)| [x, y])
.collect(),
velocity: vec![[0.0, 0.0]; interface.walk.len()],
}));
let wall = shared.clone();
let mut patch = CurvilinearPisoSolver::new(
config,
CurvilinearParameters {
tolerance: 1e-5,
convection: PatchConvection::TvdVanAlbada,
normal_diffusion: NormalDiffusion::LineImplicit,
..CurvilinearParameters::default()
},
patch_mesh,
)?;
patch.set_side_velocity(PatchSide::Inner, move |x, y, _| {
wall.read().unwrap().velocity_at(x, y)
});
let mut patch_field = PatchField::new(patch.mesh());
patch.initialize(&mut patch_field, |_, _| (0.0, 0.0));
let params = OversetParameters {
stall_rounds: 2,
max_rounds,
..OversetParameters::default()
};
let mut solver = OversetPisoSolver::new(background, patch, (nx, ny, h, h), params)?;
let mut field = OversetField {
background: FlowField::new(nx, ny, h, h)?,
patch: patch_field,
};
solver.initialize(&mut field)?;
Ok(Self {
case,
mesh,
interface,
a_node,
ny,
nx,
h,
mu,
dt_fluid,
solver,
field,
shared,
sweeps,
regen_count: Cell::new(0),
regen_seconds: Cell::new(0.0),
reclassified_total: Cell::new(0),
fresh_total: Cell::new(0),
rounds_total: Cell::new(0),
correctors_total: Cell::new(0),
})
}
/// The patch around the interface `d`.
pub fn patch_for(&self, d: &[f64]) -> CfdResult<rtx_cfd::mesh::PatchMesh> {
let start = std::time::Instant::now();
let (mesh, _) = cylinder_flag_patch_deformed(
CYL_CENTRE,
CYL_R,
FLAG_T,
&self.interface.edges(d),
self.h,
FILLET,
6.0 * self.h,
PATCH_ROWS,
PATCH_STRETCH,
self.sweeps,
)?;
self.regen_count.set(self.regen_count.get() + 1);
self.regen_seconds
.set(self.regen_seconds.get() + start.elapsed().as_secs_f64());
Ok(mesh)
}
/// The wall for the next fluid step: geometry `d`, velocity `ddot`.
pub fn set_geometry(&mut self, d: &[f64], ddot: &[f64]) -> CfdResult<()> {
{
let mut w = self.shared.write().unwrap();
w.polygon = self
.interface
.polygon(d)
.iter()
.map(|&(x, y)| [x, y])
.collect();
w.velocity = self
.interface
.walk_velocities(ddot)
.iter()
.map(|&(u, v)| [u, v])
.collect();
}
let mesh = self.patch_for(d)?;
self.solver.set_patch_mesh(mesh)
}
/// One fluid step at the current wall.
pub fn step(&mut self) -> CfdResult<OversetResult> {
let r = futures::executor::block_on(self.solver.advance(&mut self.field, self.dt_fluid))?;
self.reclassified_total
.set(self.reclassified_total.get() + r.reclassified_cells);
self.fresh_total.set(self.fresh_total.get() + r.fresh_cells);
self.rounds_total
.set(self.rounds_total.get() + r.rounds.iter().sum::<usize>());
self.correctors_total
.set(self.correctors_total.get() + r.rounds.len());
Ok(r)
}
/// `subcycle` fluid substeps from the current state with the interface
/// interpolated from `d_n` to `d_candidate` (the harness's
/// `advance_subcycled`, digit for digit in the kinematics).
pub fn advance_subcycled(
&mut self,
d_n: &[f64],
d_candidate: &[f64],
subcycle: usize,
v_n: Option<&[f64]>,
) -> CfdResult<()> {
let dt = self.dt_fluid * subcycle as f64;
let mean_velocity: Vec<f64> = d_candidate
.iter()
.zip(d_n)
.map(|(new, old)| (new - old) / dt)
.collect();
for m in 1..=subcycle {
let fraction = m as f64 / subcycle as f64;
let (d_sub, ddot_sub): (Vec<f64>, Vec<f64>) = match v_n {
None => (
d_n.iter()
.zip(d_candidate)
.map(|(old, new)| old + fraction * (new - old))
.collect(),
mean_velocity.clone(),
),
Some(v_start) => {
let mut d_sub = Vec::with_capacity(d_n.len());
let mut ddot_sub = Vec::with_capacity(d_n.len());
for k in 0..d_n.len() {
let v_end = 2.0 * mean_velocity[k] - v_start[k];
let accel = (v_end - v_start[k]) / dt;
let tau = fraction * dt;
d_sub.push(d_n[k] + v_start[k] * tau + 0.5 * accel * tau * tau);
ddot_sub.push(v_start[k] + accel * tau);
}
(d_sub, ddot_sub)
}
};
self.set_geometry(&d_sub, &ddot_sub)?;
self.step()?;
}
Ok(())
}
/// Drag and lift on cylinder + flag from the patch's wall stress.
pub fn measure_force(&self) -> (f64, f64) {
let f = self
.solver
.patch()
.surface_force(&self.field.patch, PatchSide::Inner, self.solver.time())
.total();
(f[0], f[1])
}
/// The wall faces' tractions transferred to the flag's wetted nodes
/// at geometry `d`: `(nodal forces, conservation defect, faces used)`.
/// Faces on the cylinder proper are skipped; the fillets' load goes
/// to the nearest (clamped) root nodes.
pub fn sample_load(&self, d: &[f64]) -> (Vec<(NodeId, Vector3<f64>)>, f64, usize) {
let mut faces = Vec::new();
let mut tractions: Vec<Vector3<f64>> = Vec::new();
for (centre, normal, len, traction) in self.solver.patch().wall_tractions(
&self.field.patch,
PatchSide::Inner,
self.solver.time(),
) {
let on_cylinder =
((centre[0] - CYL_CENTRE[0]).powi(2) + (centre[1] - CYL_CENTRE[1]).powi(2)).sqrt()
< CYL_R + 1e-9;
if on_cylinder {
continue;
}
faces.push(FluidFace {
centroid: Vector3::new(centre[0], centre[1], 0.0),
normal: Vector3::new(normal[0], normal[1], 0.0),
area: len,
});
tractions.push(Vector3::new(traction[0], traction[1], 0.0));
}
let nodes_now = self.interface.deformed_nodes(d);
let surface = WettedSurface::build(&faces, &nodes_now).expect("transfer build");
let nodal = surface.transfer_load(&faces, &tractions).unwrap();
let total_sampled: Vector3<f64> =
faces.iter().zip(&tractions).map(|(f, t)| t * f.area).sum();
let total_nodal: Vector3<f64> = nodal.iter().sum();
let conservation = (total_nodal - total_sampled).norm() / total_sampled.norm().max(1e-30);
(
self.interface
.wetted
.iter()
.zip(nodal)
.map(|(&id, f)| (id, f))
.collect(),
conservation,
faces.len(),
)
}
pub fn snapshot(&self) -> (OversetSolverState, OversetField) {
(self.solver.snapshot(), self.field.clone())
}
pub fn restore(&mut self, saved: &(OversetSolverState, OversetField)) {
self.solver.restore(&saved.0);
self.field = saved.1.clone();
}
pub fn time(&self) -> f64 {
self.solver.time()
}
}