rtx-cfd: overset A-P2 — the patch overlaps the background (OversetPisoSolver), gated S1–S5
CI / Test (macos-latest) (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 / Build (macos-latest) (push) Canceled after 0s
CI / Build (ubuntu-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
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
Documentation / Build API Documentation (push) Canceled after 0s

Background = the embedded solver with a mask from the overlap classification
(embedded/{mod,projection}.rs: module split, projection's solve/apply halves,
set_overlap, fringe p' Dirichlet by elimination into extra_diag/rhs, anchor
dropped, set_inner_stop_factor, phase API begin_step/solve_correction/
apply_correction/end_step; advance rebuilt on the phases — every suite digit-
identical, FSI2 default line-for-line). Patch = the curvilinear solver with an
acceptor ring (set_side_velocity; set_acceptor_ring/stamp_acceptors/
set_acceptor_correction; acceptor Dirichlet by elimination into
PressureSystem.links so the BiCGSTAB stop stays in flux units — identity rows
measured unconverged at 2431 iterations; same phase API). overset/overlap.rs:
OverlapMap — hole/fringe/active from the patch's own indices (hole = body or
k <= nn-1-overlap_rows, DEFAULT_OVERLAP_ROWS = 4 from the 2.9 h depth budget),
dual-quad inverse-bilinear donors patch→fringe, lattice donors →acceptors,
both invariants asserted, mass-defect measures. overset/mod.rs:
OversetPisoSolver — advance (exchange rebuilt BEFORE the predictors from the
previous corrected field), alternating Schwarz on the acceptor p' vector with
Anderson(3) (plain Schwarz measured 0.82/round: floating patch, Neumann wall)
and the previous step's vector as warm start (1 round/corrector at steady
state), stop relative to the STEP's p' scale (the MG absolute stop is
1e-9/dt² in pressure — the whole second correction), set_patch_mesh,
snapshot/restore carrying the warm-start vector.

Gates: overlap linear-exact 1e-13, quadratic orders 1.96/1.99 (acceptors),
1.40/1.91 (fringe); half-couplings: patch with exact acceptors Stokes 2.07/1.98
+ 2.08/1.98, upwind 0.84/0.84, background with exact fringe 7.86e-3/2.90e-3/
1.09e-3 (1.44/1.41); two-mesh MMS n=32/64: background 8.717e-3/4.207e-3 (1.03x/
0.97x the embedded circle), patch 1.322e-2/6.904e-3 (1.5-1.6x), orders 1.05/
0.94, patch div <= 5e-13, overlap mass defect 3.6e-3 -> 8.2e-4 of the overlap
flux (under the registered 1e-3 from n=64; disclosed at 32); motion: stationary
patch through set_patch_mesh bit-identical, snapshot/restore with a pending mesh
bit-identical, translating phantom circle 1.22x/1.19x the static level over
4.5 cells. Inherited, disclosed: poisson_equivalence's no-body multigrid pin
fails by 3.9e-9 at d46fb0b (M1's commit; verified in a clean worktree).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01X2GmJXeQ2njUecEKiJZ1G2
This commit is contained in:
Omar Sobh
2026-09-04 19:24:13 -07:00
co-authored by Claude Fable 5.1
parent d46fb0b7a7
commit afd1bff6ee
14 changed files with 3523 additions and 482 deletions
@@ -0,0 +1,482 @@
//! The pressure step of the embedded-boundary PISO: the masked five-point
//! problem and the projection, split into its solve and apply halves so
//! the overset coupling (`overset/`) can iterate the solve across two
//! meshes before applying the correction once.
use super::EmbeddedPisoSolver;
use crate::CfdResult;
use crate::solvers::incompressible::ale::SideBoundary;
use crate::solvers::incompressible::poisson::{
MultigridParameters, PoissonProblem, PoissonSolverKind, solve_multigrid_pcg,
};
use crate::solvers::incompressible::{EmbeddedMask, FlowField};
impl EmbeddedPisoSolver {
/// The pressure-correction system of one projection as a
/// [`PoissonProblem`]: active = fluid cells, coefficient `dt A / delta`
/// across every fluid interior face and zero across every prescribed
/// one (domain Velocity / SlipWall sides, non-fluid interior faces),
/// the outlet's Dirichlet `p' = 0` half a cell away as a diagonal-only
/// `extra_diag`, right-hand side the mass imbalance `sp`. Arm for arm
/// the coefficients the SOR loop of [`Self::project`] forms in place.
pub(super) fn poisson_problem(&self, field: &FlowField, dt: f64) -> PoissonProblem {
let (nx, ny, dx, dy) = field.grid_info();
let b = self.parameters.boundaries;
let outlet = SideBoundary::PressureOutlet;
let ae_interior = dt * dy / dx;
let an_interior = dt * dx / dy;
let ae_outlet = dt * dy / (0.5 * dx);
let an_outlet = dt * dx / (0.5 * dy);
let mut problem = PoissonProblem::new(nx, ny);
for j in 0..ny {
for i in 0..nx {
let idx = j * nx + i;
if !self.cell_is_fluid(j, i) {
problem.active[idx] = false;
continue;
}
let mut extra = 0.0;
// An overset fringe neighbour is a Dirichlet cell: its
// coefficient moves to the diagonal and its known p' to
// the right-hand side (the outlet's construction).
let mut rhs_extra = 0.0;
if i + 1 == nx {
if b.right == outlet {
extra += ae_outlet;
}
} else if self.u_is_fluid(j, i + 1) {
if self.is_fringe(j, i + 1) {
extra += ae_interior;
rhs_extra += ae_interior * self.fringe_correction(j, i + 1);
} else {
problem.ae[idx] = ae_interior;
}
}
if i == 0 {
if b.left == outlet {
extra += ae_outlet;
}
} else if self.u_is_fluid(j, i) {
if self.is_fringe(j, i - 1) {
extra += ae_interior;
rhs_extra += ae_interior * self.fringe_correction(j, i - 1);
} else {
problem.aw[idx] = ae_interior;
}
}
if j + 1 == ny {
if b.top == outlet {
extra += an_outlet;
}
} else if self.v_is_fluid(j + 1, i) {
if self.is_fringe(j + 1, i) {
extra += an_interior;
rhs_extra += an_interior * self.fringe_correction(j + 1, i);
} else {
problem.an[idx] = an_interior;
}
}
if j == 0 {
if b.bottom == outlet {
extra += an_outlet;
}
} else if self.v_is_fluid(j, i) {
if self.is_fringe(j - 1, i) {
extra += an_interior;
rhs_extra += an_interior * self.fringe_correction(j - 1, i);
} else {
problem.as_[idx] = an_interior;
}
}
problem.extra_diag[idx] = extra;
problem.rhs[idx] = field.sp[(j, i)] + rhs_extra;
}
}
problem
}
/// One projection on the fluid cells: the fixed-grid PISO's, with a
/// zero coefficient across every prescribed face (domain Velocity /
/// SlipWall sides and every non-fluid interior face), a Dirichlet `p' =
/// 0` half a cell beyond an outlet face, and the anchor on the first
/// fluid cell when no outlet exists. Returns the normalised mass
/// imbalance of the corrected field over the fluid cells.
#[allow(clippy::too_many_lines)]
/// One projection on the fluid cells: [`Self::solve_correction`] then
/// [`Self::apply_correction`] — the two halves the overset coupling
/// calls separately (several solves, one application).
pub(super) fn project(
&self,
field: &mut FlowField,
dt: f64,
warm_start: bool,
) -> CfdResult<f64> {
self.solve_correction(field, dt, warm_start)?;
Ok(self.apply_correction(field, dt))
}
/// The solve half of a projection: the continuity source from `u*`
/// into `field.sp`, then `p'` into `field.p_prime` (multigrid PCG with
/// the SOR fallback). Nothing else in `field` is touched.
#[allow(clippy::too_many_lines)]
pub(crate) fn solve_correction(
&self,
field: &mut FlowField,
dt: f64,
warm_start: bool,
) -> CfdResult<()> {
let (nx, ny, dx, dy) = field.grid_info();
let rho = self.config.density;
let b = self.parameters.boundaries;
let outlet = SideBoundary::PressureOutlet;
let any_outlet = [b.left, b.right, b.bottom, b.top].contains(&outlet);
let anchor = self.mask.as_ref().map_or((1, 1), EmbeddedMask::anchor);
// With `warm_start` (the FIRST corrector only), the previous
// step's correction is the multigrid initial guess — the
// correction field is temporally correlated step to step
// (measured 2026-08-30 on the FSI2 rigid phase: 2.96 PCG
// iterations/solve from zero, 1.27 warm). Later correctors
// solve for a much SMALLER correction, and the first
// corrector's p' is a WORSE guess than zero there (measured:
// the all-correctors draft cost 3.9 iters/solve in the coupled
// phase). The SOR fallback below still starts from zero,
// exactly as before.
let mut source_scale = 0.0;
for j in 0..ny {
for i in 0..nx {
if !self.cell_is_fluid(j, i) {
field.sp[(j, i)] = 0.0;
continue;
}
let divergence_flux = rho
* ((field.u_star[(j, i + 1)] - field.u_star[(j, i)]) * dy
+ (field.v_star[(j + 1, i)] - field.v_star[(j, i)]) * dx);
field.sp[(j, i)] = -divergence_flux;
source_scale += divergence_flux.abs();
}
}
// Swept-volume source (knob; see `swept_volume`): the corrected
// field must satisfy Σ u·n A = dV_f/dt in every interface cell.
if let (true, Some(a_new), Some(a_old)) =
(self.swept_volume != 0.0, &self.alpha_new, &self.alpha_old)
{
for j in 0..ny {
for i in 0..nx {
if !self.cell_is_fluid(j, i) {
continue;
}
let k = j * nx + i;
let da = a_new[k] - a_old[k];
if da != 0.0 {
field.sp[(j, i)] -= self.swept_volume * rho * da * dx * dy / dt;
}
}
}
}
// Reporting-only divergence trace (RTX_EMBEDDED_TRACE_SP): where the
// projection's source sits relative to the step's fresh cells, in
// units of one whole cell volume per step (rho dx dy / dt).
if warm_start && !self.fresh_trace.is_empty() {
let unit = rho * dx * dy / dt;
let is_fresh =
|j: usize, i: usize| self.fresh_trace.iter().any(|&(a, b)| a == j && b == i);
let is_nbr = |j: usize, i: usize| {
self.fresh_trace.iter().any(|&(a, b)| {
(a == j && (b + 1 == i || i + 1 == b)) || (b == i && (a + 1 == j || j + 1 == a))
})
};
let (mut mf, mut mn, mut mo) = (0.0f64, 0.0f64, 0.0f64);
let (mut arg, mut argv) = ((0usize, 0usize), 0.0f64);
let mut sum_fresh = 0.0f64;
for j in 0..ny {
for i in 0..nx {
if !self.cell_is_fluid(j, i) {
continue;
}
let v = field.sp[(j, i)] / unit;
if is_fresh(j, i) {
mf = mf.max(v.abs());
sum_fresh += v;
} else if is_nbr(j, i) {
mn = mn.max(v.abs());
} else {
mo = mo.max(v.abs());
}
if v.abs() > argv.abs() {
argv = v;
arg = (j, i);
}
}
}
let class = if is_fresh(arg.0, arg.1) {
"FRESH"
} else if is_nbr(arg.0, arg.1) {
"NEIGHBOUR"
} else {
"other"
};
// The argmax cell's 3x3 neighbourhood: F = fluid, S = solid,
// * = fresh this step (row above first).
let mut hood = String::new();
for dj in [1i64, 0, -1] {
for di in [-1i64, 0, 1] {
let (jj, ii) = (arg.0 as i64 + dj, arg.1 as i64 + di);
let c = if jj < 0 || ii < 0 || jj >= ny as i64 || ii >= nx as i64 {
'#'
} else if is_fresh(jj as usize, ii as usize) {
'*'
} else if self.cell_is_fluid(jj as usize, ii as usize) {
'F'
} else {
'S'
};
hood.push(c);
}
hood.push('/');
}
let fj = self.fresh_trace.iter().map(|c| c.0);
let fi = self.fresh_trace.iter().map(|c| c.1);
println!(
" SP-TRACE fresh rows {:?}..{:?} cols {:?}..{:?}; argmax hood {hood}",
fj.clone().min(),
fj.max(),
fi.clone().min(),
fi.max()
);
println!(
" SP-TRACE t = {:.6}: {} fresh cells; max |sp| {:+.3} cell-volumes/step at ({}, {}) [{class}]; \
max over fresh {:.3}, neighbours {:.3}, others {:.3}; sum over fresh {:+.3}",
self.time + dt,
self.fresh_trace.len(),
argv,
arg.0,
arg.1,
mf,
mn,
mo,
sum_fresh
);
}
let ae_interior = dt * dy / dx;
let an_interior = dt * dx / dy;
// Outlet face: p' = 0 half a cell away.
let ae_outlet = dt * dy / (0.5 * dx);
let an_outlet = dt * dx / (0.5 * dy);
let reference_flux = rho * self.config.reference_velocity * self.config.reference_length;
let inner_stop = (self.inner_stop_factor * source_scale)
.max(0.1 * self.parameters.tolerance * reference_flux)
+ 1e-14;
let mut multigrid_converged = false;
if self.parameters.poisson_solver == PoissonSolverKind::Multigrid {
// The same system the SOR loop below sweeps, handed to the
// multigrid-preconditioned CG solver: anchored on the first
// fluid cell when there is no outlet (the SOR loop pins it to
// zero), level-free otherwise. Non-fluid cells and isolated
// fluid cells are never written and keep `p' = 0`.
let problem = self.poisson_problem(field, dt);
// Warm start from the previous correction on the CURRENT
// fluid cells; everything else stays zero, preserving the
// p' = 0 invariant on non-fluid cells through the copy-back.
let mut p_prime = vec![0.0; nx * ny];
if warm_start {
for j in 0..ny {
for i in 0..nx {
if self.cell_is_fluid(j, i) {
p_prime[j * nx + i] = field.p_prime[(j, i)];
}
}
}
}
let anchor_cell =
(!any_outlet && !self.has_fringe()).then_some(anchor.0 * nx + anchor.1);
let solution = solve_multigrid_pcg(
&problem,
&mut p_prime,
&MultigridParameters {
precision: self.parameters.poisson_precision,
..MultigridParameters::default()
},
inner_stop,
anchor_cell,
);
// Unconverged: fall back to the SOR sweeps for this projection
// rather than apply a correction that did not reach the stop.
multigrid_converged = solution.converged;
if multigrid_converged {
for j in 0..ny {
for i in 0..nx {
field.p_prime[(j, i)] = p_prime[j * nx + i];
}
}
self.stamp_fringe_correction(&mut field.p_prime);
}
}
if !multigrid_converged {
// The fallback is unchanged: SOR from zero, as it always ran.
field.p_prime.fill(0.0);
self.stamp_fringe_correction(&mut field.p_prime);
let omega = 2.0 / (1.0 + (std::f64::consts::PI / nx.max(ny) as f64).sin());
for _sweep in 0..2000 {
let mut residual = 0.0;
for j in 0..ny {
for i in 0..nx {
if !self.cell_is_fluid(j, i) {
continue;
}
if !any_outlet && !self.has_fringe() && (j, i) == anchor {
field.p_prime[(j, i)] = 0.0;
continue;
}
// A coefficient is zero exactly when the face is
// prescribed: a domain side with velocity data, or a
// non-fluid interior face.
let ae = if i + 1 == nx {
if b.right == outlet { ae_outlet } else { 0.0 }
} else if self.u_is_fluid(j, i + 1) {
ae_interior
} else {
0.0
};
let aw = if i == 0 {
if b.left == outlet { ae_outlet } else { 0.0 }
} else if self.u_is_fluid(j, i) {
ae_interior
} else {
0.0
};
let an = if j + 1 == ny {
if b.top == outlet { an_outlet } else { 0.0 }
} else if self.v_is_fluid(j + 1, i) {
an_interior
} else {
0.0
};
let as_ = if j == 0 {
if b.bottom == outlet { an_outlet } else { 0.0 }
} else if self.v_is_fluid(j, i) {
an_interior
} else {
0.0
};
let ap = ae + aw + an + as_;
if ap == 0.0 {
// An isolated fluid cell enclosed by prescribed
// faces has no equation; leave p' = 0 there.
continue;
}
let east = if i + 1 < nx {
ae * field.p_prime[(j, i + 1)]
} else {
0.0
};
let west = if i > 0 {
aw * field.p_prime[(j, i - 1)]
} else {
0.0
};
let north = if j + 1 < ny {
an * field.p_prime[(j + 1, i)]
} else {
0.0
};
let south = if j > 0 {
as_ * field.p_prime[(j - 1, i)]
} else {
0.0
};
let rhs = field.sp[(j, i)] + east + west + north + south;
let p_old = field.p_prime[(j, i)];
residual += (rhs - ap * p_old).abs();
field.p_prime[(j, i)] = (1.0 - omega) * p_old + omega * rhs / ap;
}
}
if residual < inner_stop {
break;
}
}
}
Ok(())
}
/// The apply half of a projection: correct the fluid faces from `u*`
/// with the `p'` in `field.p_prime` (outlet faces against `p' = 0`
/// outside), add `p'` to `p` on the fluid cells, and return the
/// normalised mass imbalance of the corrected field.
pub(crate) fn apply_correction(&self, field: &mut FlowField, dt: f64) -> f64 {
let (nx, ny, dx, dy) = field.grid_info();
let rho = self.config.density;
let b = self.parameters.boundaries;
let outlet = SideBoundary::PressureOutlet;
let reference_flux = rho * self.config.reference_velocity * self.config.reference_length;
// Correct exactly the faces the equations treated as correctable:
// fluid interior faces, and outlet faces against p' = 0 outside.
for j in 0..ny {
for i in 1..nx {
if self.u_is_fluid(j, i) {
let dp_dx = (field.p_prime[(j, i)] - field.p_prime[(j, i - 1)]) / dx;
field.u[(j, i)] = field.u_star[(j, i)] - (dt / rho) * dp_dx;
}
}
if b.left == outlet {
let dp_dx = (field.p_prime[(j, 0)] - 0.0) / (0.5 * dx);
field.u[(j, 0)] = field.u_star[(j, 0)] - (dt / rho) * dp_dx;
}
if b.right == outlet {
let dp_dx = (0.0 - field.p_prime[(j, nx - 1)]) / (0.5 * dx);
field.u[(j, nx)] = field.u_star[(j, nx)] - (dt / rho) * dp_dx;
}
}
for i in 0..nx {
for j in 1..ny {
if self.v_is_fluid(j, i) {
let dp_dy = (field.p_prime[(j, i)] - field.p_prime[(j - 1, i)]) / dy;
field.v[(j, i)] = field.v_star[(j, i)] - (dt / rho) * dp_dy;
}
}
if b.bottom == outlet {
let dp_dy = (field.p_prime[(0, i)] - 0.0) / (0.5 * dy);
field.v[(0, i)] = field.v_star[(0, i)] - (dt / rho) * dp_dy;
}
if b.top == outlet {
let dp_dy = (0.0 - field.p_prime[(ny - 1, i)]) / (0.5 * dy);
field.v[(ny, i)] = field.v_star[(ny, i)] - (dt / rho) * dp_dy;
}
}
for j in 0..ny {
for i in 0..nx {
if self.cell_is_fluid(j, i) {
field.p[(j, i)] += field.p_prime[(j, i)];
}
}
}
let mut mass_imbalance = 0.0;
for j in 0..ny {
for i in 0..nx {
if !self.cell_is_fluid(j, i) {
continue;
}
let divergence_flux = rho
* ((field.u[(j, i + 1)] - field.u[(j, i)]) * dy
+ (field.v[(j + 1, i)] - field.v[(j, i)]) * dx);
mass_imbalance += divergence_flux.abs();
}
}
if reference_flux > 0.0 {
mass_imbalance / reference_flux
} else {
mass_imbalance
}
}
}