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
@@ -197,6 +197,16 @@ pub struct CurvilinearSolverState {
type VelocityFn = Box<dyn Fn(f64, f64, f64) -> (f64, f64) + Send + Sync>;
/// The acceptor ring of an overset patch (A-P2): the outer row of cells
/// (`k = nn 1`) carries values stamped from the background — `u, v, p`
/// at the end of every step, the pressure correction `p'` during each
/// projection — and no momentum or continuity equation of its own.
#[derive(Debug, Clone)]
struct AcceptorRing {
/// Dirichlet `p'` per acceptor cell (column order), for the next solve.
correction: Vec<f64>,
}
/// PISO on a curvilinear patch.
pub struct CurvilinearPisoSolver {
config: CfdConfig,
@@ -206,9 +216,35 @@ pub struct CurvilinearPisoSolver {
pending: Option<PatchMesh>,
ops: Operators,
boundary_velocity: Option<VelocityFn>,
/// Per-side overrides of `boundary_velocity` (Inner, Outer, SStart, SEnd).
side_velocity: [Option<VelocityFn>; 4],
momentum_source: Option<VelocityFn>,
acceptors: Option<AcceptorRing>,
time: f64,
matrix: Option<(f64, CsrMatrix, Option<usize>)>,
matrix: Option<PressureSystem>,
}
/// The assembled pressure-correction system for one `dt` and geometry.
pub(crate) struct PressureSystem {
/// The `dt` it was assembled for.
pub(crate) dt: f64,
/// `−Σ_f sign (dt/ρ) L_f` with identity rows on acceptor cells.
pub(crate) matrix: CsrMatrix,
/// The anchored cell of a pure-Neumann patch.
pub(crate) anchor: Option<usize>,
/// `(row, acceptor cell, coefficient)`: the interior rows' couplings to
/// acceptor cells, eliminated to the right-hand side at solve time so
/// the residual stays in flux units.
pub(crate) links: Vec<(usize, usize, f64)>,
}
fn side_index(side: PatchSide) -> usize {
match side {
PatchSide::Inner => 0,
PatchSide::Outer => 1,
PatchSide::SStart => 2,
PatchSide::SEnd => 3,
}
}
impl CurvilinearPisoSolver {
@@ -226,7 +262,9 @@ impl CurvilinearPisoSolver {
pending: None,
ops,
boundary_velocity: None,
side_velocity: [None, None, None, None],
momentum_source: None,
acceptors: None,
time: 0.0,
matrix: None,
})
@@ -239,6 +277,118 @@ impl CurvilinearPisoSolver {
{
self.boundary_velocity = Some(Box::new(f));
}
/// Velocity on ONE `Velocity` side, overriding [`Self::set_boundary_velocity`]
/// there (the overset patch: the wall on `Inner`, the background on
/// `Outer`).
pub fn set_side_velocity<F>(&mut self, side: PatchSide, f: F)
where
F: Fn(f64, f64, f64) -> (f64, f64) + Send + Sync + 'static,
{
self.side_velocity[side_index(side)] = Some(Box::new(f));
}
/// Turn the outer row of cells into acceptors (see `AcceptorRing`) or
/// back into ordinary cells. The pressure matrix is rebuilt.
pub fn set_acceptor_ring(&mut self, on: bool) {
self.acceptors = on.then(|| AcceptorRing {
correction: vec![0.0; self.mesh.ns()],
});
self.matrix = None;
}
/// Whether the outer row is an acceptor ring.
pub fn has_acceptor_ring(&self) -> bool {
self.acceptors.is_some()
}
/// Is cell `c` an acceptor (no equation of its own)?
pub fn is_acceptor(&self, c: usize) -> bool {
self.acceptors.is_some() && self.mesh.cell_ki(c).0 == self.mesh.nn() - 1
}
/// Stamp `(u, v, p)` onto the acceptor cells, column order `i = 0..ns`.
pub fn stamp_acceptors(&self, field: &mut PatchField, values: &[(f64, f64, f64)]) {
let nn = self.mesh.nn();
for (i, &(u, v, p)) in values.iter().enumerate() {
let c = self.mesh.cell(nn - 1, i);
field.u[c] = u;
field.v[c] = v;
field.p[c] = p;
}
}
/// The Dirichlet `p'` of the acceptor cells for the next
/// [`Self::solve_correction`] (column order).
pub fn set_acceptor_correction(&mut self, values: &[f64]) {
if let Some(ring) = &mut self.acceptors {
ring.correction.clear();
ring.correction.extend_from_slice(values);
}
}
/// One pressure-correction SOLVE (no application): `p'` on every cell
/// (acceptor rows hold their Dirichlet values), with the BiCGSTAB
/// report. `None` when the incoming divergence is already at the
/// rounding floor. For the overset's Schwarz rounds.
pub(crate) fn solve_correction(
&self,
field: &PatchField,
dt: f64,
) -> Option<(
Vec<f64>,
crate::solvers::incompressible::sparse_bicgstab::BicgstabResult,
)> {
let system = self
.matrix
.as_ref()
.expect("begin_step assembled the matrix");
debug_assert_eq!(system.dt, dt);
let flux_scale: f64 = field.flux.iter().map(|f| f.abs()).sum::<f64>().max(1e-300);
let floor = 1e-15 * flux_scale;
let (rhs, incoming) = self.pressure_rhs(system, &field.flux);
if incoming <= floor {
return None;
}
let tolerance = self.params.tolerance * incoming + floor;
Some(self.solve_pressure_correction(system, rhs, tolerance))
}
/// Apply a correction `pc` (from [`Self::solve_correction`]).
pub(crate) fn apply_correction_pub(&self, field: &mut PatchField, pc: &[f64], dt: f64) {
self.apply_correction(field, pc, dt);
}
/// Largest cell mass imbalance over the equation-carrying cells.
pub(crate) fn max_divergence_pub(&self, flux: &[f64]) -> f64 {
self.max_divergence(flux)
}
/// Overlap mass defect on the patch side: `Σ_acceptors |Σ_f sign F_f|`
/// (the acceptors carry no continuity) with the OUTER face flux taken
/// from `outer_velocity` (the background's velocity at that face
/// centre, column order), and the flux scale `Σ |F_f|` over the faces
/// between the acceptor ring and the interior.
pub fn acceptor_mass_defect(
&self,
field: &PatchField,
outer_velocity: &[(f64, f64)],
) -> (f64, f64) {
let mesh = &self.mesh;
let nn = mesh.nn();
if self.acceptors.is_none() || nn < 2 {
return (0.0, 0.0);
}
let mut defect = 0.0;
let mut scale = 0.0;
for i in 0..mesh.ns() {
let c = mesh.cell(nn - 1, i);
let outer = mesh.nface(nn, i);
let mut div = 0.0;
for (f, sign) in mesh.cell_faces(c) {
if f == outer {
let s = mesh.faces()[f].s;
let (uo, vo) = outer_velocity.get(i).copied().unwrap_or((0.0, 0.0));
div += sign * (uo * s[0] + vo * s[1]);
} else {
div += sign * field.flux[f];
}
}
defect += div.abs();
scale += field.flux[mesh.nface(nn - 1, i)].abs();
}
(defect, scale)
}
/// Body force per unit volume, `(x, y, t) -> (fx, fy)`.
pub fn set_momentum_source<F>(&mut self, f: F)
where
@@ -314,11 +464,18 @@ impl CurvilinearPisoSolver {
self.matrix = None;
}
pub(crate) fn boundary_velocity(&self, x: f64, y: f64, t: f64) -> (f64, f64) {
self.boundary_velocity
pub(crate) fn boundary_velocity(&self, side: PatchSide, x: f64, y: f64, t: f64) -> (f64, f64) {
self.side_velocity[side_index(side)]
.as_ref()
.or(self.boundary_velocity.as_ref())
.map_or((0.0, 0.0), |f| f(x, y, t))
}
/// Dirichlet `p'` of acceptor cell `c`, if it is one.
pub(crate) fn acceptor_correction(&self, c: usize) -> Option<f64> {
let ring = self.acceptors.as_ref()?;
let (k, i) = self.mesh.cell_ki(c);
(k == self.mesh.nn() - 1).then(|| ring.correction.get(i).copied().unwrap_or(0.0))
}
pub(crate) fn source_at(&self, xy: [f64; 2], t: f64) -> (f64, f64) {
self.momentum_source
.as_ref()
@@ -350,12 +507,37 @@ impl CurvilinearPisoSolver {
);
}
/// Advance one step of `dt`.
/// Advance one step of `dt`: [`Self::begin_step`], the correctors,
/// [`Self::end_step`].
pub async fn advance(
&mut self,
field: &mut PatchField,
dt: f64,
) -> CfdResult<CurvilinearResult> {
let start = self.begin_step(field, dt)?;
let mut iterations = 0;
let mut converged = true;
let mut performed = 0;
let mut max_div = self.max_divergence(&field.flux);
for _ in 0..self.params.corrector_steps {
let Some((pc, out)) = self.solve_correction(field, dt) else {
break;
};
iterations += out.iterations;
converged &= out.converged;
self.apply_correction(field, &pc, dt);
performed += 1;
max_div = self.max_divergence(&field.flux);
}
Ok(self.end_step(&start, performed, max_div, iterations, converged))
}
/// Everything before the correctors: the mesh swap (if `set_mesh` named
/// one), the step geometry, the predictor, the predicted fluxes with
/// the closed-patch adjustment, `u*`, and the pressure matrix on the
/// end-of-step geometry. The overset coupling runs this on the patch,
/// then drives the correctors itself.
pub(crate) fn begin_step(&mut self, field: &mut PatchField, dt: f64) -> CfdResult<StepStart> {
let t_old = self.time;
let t_new = t_old + dt;
let rho = self.config.density;
@@ -383,44 +565,47 @@ impl CurvilinearPisoSolver {
let mut flux = self.predicted_fluxes(&uh, &vh, &field.p, dt, t_new, &geo);
let adjustment = self.adjust_boundary_flux(&mut flux);
for c in 0..mesh.cell_count() {
if self.is_acceptor(c) {
continue;
}
let g = self.pressure_gradient(&field.p, c);
field.u[c] = uh[c] - dt / rho * g[0];
field.v[c] = vh[c] - dt / rho * g[1];
}
field.flux = flux;
if self.matrix.as_ref().is_none_or(|(d, _, _)| *d != dt) {
let (m, anchor) = self.assemble_pressure_matrix(dt);
self.matrix = Some((dt, m, anchor));
if self.matrix.as_ref().is_none_or(|s| s.dt != dt) {
self.matrix = Some(self.assemble_pressure_matrix(dt));
}
let (_, matrix, anchor) = self.matrix.as_ref().expect("assembled");
let flux_scale: f64 = field.flux.iter().map(|f| f.abs()).sum::<f64>().max(1e-300);
let floor = 1e-15 * flux_scale;
Ok(StepStart { t_new, adjustment })
}
let mut iterations = 0;
let mut converged = true;
let mut performed = 0;
let mut max_div = self.max_divergence(&field.flux);
for _ in 0..self.params.corrector_steps {
let incoming = self.divergence_l1(&field.flux);
if incoming <= floor {
break;
}
let tolerance = self.params.tolerance * incoming + floor;
let (pc, out) = self.solve_pressure_correction(matrix, *anchor, &field.flux, tolerance);
iterations += out.iterations;
converged &= out.converged;
self.apply_correction(field, &pc, dt);
performed += 1;
max_div = self.max_divergence(&field.flux);
}
self.time = t_new;
Ok(CurvilinearResult {
/// Everything after the correctors: the clock and the result.
pub(crate) fn end_step(
&mut self,
start: &StepStart,
performed: usize,
max_div: f64,
iterations: usize,
converged: bool,
) -> CurvilinearResult {
self.time = start.t_new;
CurvilinearResult {
corrector_steps_performed: performed,
max_divergence: max_div,
poisson_iterations: iterations,
poisson_converged: converged,
boundary_flux_adjustment: adjustment,
})
boundary_flux_adjustment: start.adjustment,
}
}
}
/// What [`CurvilinearPisoSolver::begin_step`] hands to
/// [`CurvilinearPisoSolver::end_step`].
#[derive(Debug, Clone, Copy)]
pub(crate) struct StepStart {
/// End-of-step time.
pub(crate) t_new: f64,
/// Boundary-flux defect removed on a closed patch.
pub(crate) adjustment: f64,
}