R8-a: span-uniform coupled FSI2 on embedded3 — DeviceStep snapshot/restore, operator-route load sink, harness fsi2_embedded3.rs

- embedded3 DeviceStep::snapshot/restore (step/device/snapshot.rs): the start-of-step
  device fields + the moving body's host records; a restore rebuilds the R6 persistent
  geometry/classification, the predictor tables, the hierarchy and the guess basis.
  Only called by the new harness (existing paths untouched).
- exchange::set_load_sink: every contribution of the operator load route (p W, wall
  shear, diffusive/convective exchange) with position/component/part; None by default,
  the routes' sums unchanged (slab ny 62 CSV byte-identical to r6_1/base_slab62.csv).
- rtx-fsi feature cuda (= rtx-cfd/cuda) and tests/fsi2_embedded3.rs: the 2D rtx-fea flag
  (35x2 Quad8, TL, Newmark 0.7) drives the capsule's centreline per coupled step; the
  route's contributions are distributed onto the wetted nodes per unit span; Aitken
  subiterations each re-run the fluid step from the snapshot.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-09-25 18:11:27 -05:00
co-authored by Claude Opus 5.5
parent d63806c0e6
commit 12a27c6fca
7 changed files with 1101 additions and 8 deletions
@@ -915,11 +915,22 @@ impl Mask {
let mut pressure = [0.0; 3];
let mut force = [0.0; 3];
for (idx, w) in cut.wall.iter().enumerate() {
let (k, _, i) = g.kji(idx);
let (k, j, i) = g.kji(idx);
if self.cell_fluid[idx] && k >= k0 && k < k1 && in_load_window((i as f64 + 0.5) * g.dx)
{
for c in 0..3 {
pressure[c] += f.p[idx] * w[c];
let v = f.p[idx] * w[c];
pressure[c] += v;
super::exchange::to_load_sink(
[
(i as f64 + 0.5) * g.dx,
(j as f64 + 0.5) * g.dy,
(k as f64 + 0.5) * g.dz,
],
c,
0,
v,
);
}
}
}
@@ -967,7 +978,9 @@ impl Mask {
};
let (c1, c2, nb) = self.wall_gradient(c, p, &cv, xi);
let un = nb.map_or(ub, |f| values[c][f]);
force[c] += mu * a_w * (c1 * (values[c][idx] - ub) + c2 * (un - ub));
let v = mu * a_w * (c1 * (values[c][idx] - ub) + c2 * (un - ub));
force[c] += v;
super::exchange::to_load_sink(x, c, 1, v);
}
}
}
@@ -30,6 +30,36 @@ pub(super) fn in_load_window(x: f64) -> bool {
.is_none_or(|(x0, x1)| x >= x0 && x < x1)
}
/// R8-a: a sink for the operator load route's CONTRIBUTIONS — each term
/// the route sums (part 0 the cell's `p W`, 1 the face's wall shear, 2 and
/// 3 the face's diffusive and convective exchange) is handed to it as
/// (position, component, part, force on the body) while the totals are
/// summed as before.
/// A coupled harness distributes them onto its structure. Process-wide;
/// `None` (the default) hands nothing and the routes' sums are unchanged.
/// The gradient-weight term (the host prototype `pressure_centroid`) is
/// not handed.
pub type LoadSink = Box<dyn FnMut([f64; 3], usize, usize, f64) + Send>;
static LOAD_SINK: std::sync::Mutex<Option<LoadSink>> = std::sync::Mutex::new(None);
static LOAD_SINK_ON: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Install (or clear, with `None`) the load sink; returns the previous one.
pub fn set_load_sink(sink: Option<LoadSink>) -> Option<LoadSink> {
let mut guard = LOAD_SINK.lock().expect("load sink");
LOAD_SINK_ON.store(sink.is_some(), std::sync::atomic::Ordering::SeqCst);
std::mem::replace(&mut *guard, sink)
}
#[inline]
pub(super) fn to_load_sink(pos: [f64; 3], c: usize, part: usize, value: f64) {
if LOAD_SINK_ON.load(std::sync::atomic::Ordering::Relaxed) {
if let Some(f) = LOAD_SINK.lock().expect("load sink").as_mut() {
f(pos, c, part, value);
}
}
}
impl Mask {
/// The cut-cell load route: the force on the body from the operators
/// themselves — `Σ_c p_c W_c` over the cells plus the implicit wall
@@ -192,10 +222,13 @@ impl Mask {
};
let u_face = upwind(m_plus, u0, un) + delta;
if !self.exchange_convection_off {
convective[c] -= -rho * m_plus * (u_face - u0);
let v = -rho * m_plus * (u_face - u0);
convective[c] -= v;
to_load_sink(lat.face_position(c, p), c, 3, -v);
}
force[c] -=
mu * cv.ap[d][1] * a_d * (un - u0) / solid_spacing(1.0);
let v = mu * cv.ap[d][1] * a_d * (un - u0) / solid_spacing(1.0);
force[c] -= v;
to_load_sink(lat.face_position(c, p), c, 2, -v);
}
}
// Minus side.
@@ -211,10 +244,14 @@ impl Mask {
};
let u_face = upwind(m_minus, ud, u0) + delta;
if !self.exchange_convection_off {
convective[c] -= rho * m_minus * (u_face - u0);
let v = rho * m_minus * (u_face - u0);
convective[c] -= v;
to_load_sink(lat.face_position(c, p), c, 3, -v);
}
force[c] -=
let v =
mu * cv.ap[d][0] * a_d * (ud - u0) / solid_spacing(-1.0);
force[c] -= v;
to_load_sink(lat.face_position(c, p), c, 2, -v);
}
}
}
@@ -9,6 +9,9 @@ mod cut;
mod geom;
mod mask;
mod poisson_setup;
mod snapshot;
pub use snapshot::DeviceSnapshot;
use super::{Side, Solver, StepResult};
use crate::solvers::incompressible::embedded3::field::Field;
@@ -0,0 +1,124 @@
//! R8-a: the step's snapshot and restore for a partitioned FSI loop — the
//! coupled harness re-runs the same fluid step once per subiteration with
//! a new candidate body position, so the device fields and the moving
//! body's host state at the start of the step must come back exactly.
//!
//! Nothing here runs unless a caller asks for it: every existing path is
//! untouched (byte-identical by construction).
//!
//! What a restore rebuilds instead of copying (the persistent R6 state
//! whose band history would otherwise describe the rejected pass): the
//! device geometry and classification (`geom`, `dmask`; the next step
//! re-syncs them from the restored mask, as the first moving step of a run
//! does), the predictor tables (`DeviceCut::build` at the restored time,
//! the identity reference of `predictor_from`), the multigrid hierarchy
//! and the projected-guess basis. The restored step is the same step up to
//! the CG's initial guess (the Poisson solves' own tolerance).
use super::DeviceStep;
use super::cut::{DeviceCut, Phase};
use crate::solvers::incompressible::embedded3::poisson::device::runtime;
use crate::solvers::incompressible::embedded3::wall::Mask;
use cudarc::driver::CudaSlice;
/// The start-of-step state of a [`DeviceStep`] (device copies of the
/// fields plus the moving body's host records).
pub struct DeviceSnapshot {
time: f64,
fields: Vec<CudaSlice<f64>>,
mask: Option<Mask>,
vol_old: Vec<f64>,
apertures_old: Option<[Vec<f64>; 3]>,
wall_fluxes: Vec<f64>,
last_ghost_correction: f64,
mask_gen: u64,
}
impl DeviceSnapshot {
/// The solver time the snapshot was taken at.
#[must_use]
pub fn time(&self) -> f64 {
self.time
}
}
impl DeviceStep {
fn field_slots(&mut self) -> [&mut CudaSlice<f64>; 8] {
[
&mut self.u,
&mut self.v,
&mut self.w,
&mut self.p,
&mut self.p_prime,
&mut self.u_old,
&mut self.v_old,
&mut self.w_old,
]
}
/// R8-a: the start-of-step state (device fields copied on the device,
/// the host mask cloned). Take it between steps.
pub fn snapshot(&mut self) -> DeviceSnapshot {
let rt = runtime();
let mut fields = Vec::with_capacity(8);
for src in self.field_slots() {
let mut dst = rt.stream.alloc_zeros::<f64>(src.len()).expect("alloc");
rt.stream.memcpy_dtod(&*src, &mut dst).expect("snapshot");
fields.push(dst);
}
rt.stream.synchronize().expect("sync");
let s = &self.solver;
let mask = s.mask.clone().map(|mut m| {
// A clone is a host generation: its arrays must not return to
// the device geometry's recycling pool under a live generation.
if let Some(c) = m.cut.as_mut() {
c.generation = 0;
}
m
});
DeviceSnapshot {
time: s.time,
fields,
mask,
vol_old: s.vol_old.clone(),
apertures_old: s.apertures_old.clone(),
wall_fluxes: s.wall_fluxes.clone(),
last_ghost_correction: s.last_ghost_correction,
mask_gen: s.mask_gen,
}
}
/// R8-a: back to `snap` (taken on this stepper). The body's functions
/// must answer for the snapshot's time as they did when it was taken
/// (the predictor tables are rebuilt from them).
pub fn restore(&mut self, snap: &DeviceSnapshot) {
let rt = runtime();
for (dst, src) in self.field_slots().into_iter().zip(&snap.fields) {
rt.stream.memcpy_dtod(src, dst).expect("restore");
}
rt.stream.synchronize().expect("sync");
{
let s = &mut self.solver;
s.time = snap.time;
s.mask = snap.mask.clone();
s.vol_old = snap.vol_old.clone();
s.apertures_old = snap.apertures_old.clone();
s.apertures_old_gen = 0;
s.wall_fluxes = snap.wall_fluxes.clone();
s.last_ghost_correction = snap.last_ghost_correction;
s.mask_gen = snap.mask_gen;
s.pending_cut = None;
s.pending_mask = None;
s.mask_pool = None;
s.geom_pool = Default::default();
}
self.geom = None;
self.dmask = None;
self.cg = None;
self.steps_since_hierarchy = 0;
self.guess.clear();
if self.cut.is_some() {
self.cut = DeviceCut::build(&self.solver, self.grid, Phase::Predictor, snap.time);
}
}
}