Merge r8a-span-uniform-fsi (R8-a: the first coupled 3D FSI, span-uniform; default-off). Merge fixes: the load-route observers of R8-a (to_load_sink) and R8-c (sink(LoadKind, …)) both kept on the same summands (both inert by default); the harness's DeviceSdf gains plate: None (R8-c's new field). Merged-tree gate: slab ny 62 default / BAND_CHECK / all-host / uniform plate byte-identical to main's base; device suites 2/2, 3/3; the coupled harness reproduces R8-a's recorded slab march row for row (478 rows)
CI / Test (macos-latest) (push) Blocked by required conditions
CI / Test (ubuntu-latest) (push) Blocked by required conditions
CI / Build (macos-latest) (push) Waiting to run
CI / Python Bindings (maturin) (macos-latest) (push) Blocked by required conditions
CI / WASM Build + Size Check (push) Blocked by required conditions
CI / Distributed Training Tests (push) Blocked by required conditions
CI / CI Success (push) Blocked by required conditions
CI / Python Bindings (maturin) (ubuntu-latest) (push) Blocked by required conditions
CI / Clippy Check (push) Failing after 5s
CI / Build CPU-Only (Explicit) (push) Failing after 4s
CI / Format Check (push) Failing after 5s
CI / Build (ubuntu-latest) (push) Failing after 5s
Documentation / Build User Guide (push) Successful in 6s
Performance Benchmarks / Run Benchmarks (push) Successful in 15s
Documentation / Build API Documentation (push) Failing after 17s

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-09-25 21:21:40 -05:00
co-authored by Claude Opus 5.5
8 changed files with 1376 additions and 0 deletions
@@ -942,6 +942,16 @@ impl Mask {
for c in 0..3 {
v[c] = f.p[idx] * w[c];
pressure[c] += v[c];
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[c],
);
}
let x = [
(i as f64 + 0.5) * g.dx,
@@ -1000,6 +1010,7 @@ impl Mask {
let mut fv = [0.0; 3];
fv[c] = v;
sink(LoadKind::Shear, x, fv);
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
@@ -223,10 +253,12 @@ impl Mask {
let v = -rho * m_plus * (u_face - u0);
convective[c] -= v;
sink(LoadKind::ExchangeConvective, xf, comp(c, -v));
to_load_sink(xf, c, 3, -v);
}
let v = mu * cv.ap[d][1] * a_d * (un - u0) / solid_spacing(1.0);
force[c] -= v;
sink(LoadKind::ExchangeDiffusive, xf, comp(c, -v));
to_load_sink(xf, c, 2, -v);
}
}
// Minus side.
@@ -245,11 +277,13 @@ impl Mask {
let v = rho * m_minus * (u_face - u0);
convective[c] -= v;
sink(LoadKind::ExchangeConvective, xf, comp(c, -v));
to_load_sink(xf, c, 3, -v);
}
let v =
mu * cv.ap[d][0] * a_d * (ud - u0) / solid_spacing(-1.0);
force[c] -= v;
sink(LoadKind::ExchangeDiffusive, xf, comp(c, -v));
to_load_sink(xf, 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);
}
}
}