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,539 @@
//! The overset (chimera) hybrid: a curvilinear patch around the body over
//! the fixed background grid (`docs/overset_metal_campaign.md` §2, A-P2).
//!
//! [`OversetPisoSolver`] drives an [`EmbeddedPisoSolver`] (the background,
//! its mask from the overlap classification: holes, fringe) and a
//! [`CurvilinearPisoSolver`] (the patch, its outer row an acceptor ring)
//! through one PISO step together:
//!
//! 1. patch predictor (`begin_step`: mesh swap if the patch moves, fluxes,
//! `u*`, matrix); if the mesh moved, the overlap is rebuilt and the
//! fringe re-stamped from the patch's previous corrected field (values
//! identical when the mesh did not move — the moving path with a
//! stationary patch is the static path to the bit);
//! 2. background predictor (`begin_step`);
//! 3. per corrector, alternating Schwarz on `p'` with Dirichlet transmission
//! both ways: fringe `p'` ← patch `p'` (zero in the first round),
//! background solve; acceptor `p'` ← background `p'`, patch solve; stop
//! when the exchanged values change by less than `schwarz_tolerance ×
//! max |p'|`; then ONE application of the correction on each mesh;
//! 4. the exchange for the next step, from the corrected fields (the
//! boundary-history principle): prescribed background faces and fringe
//! `p` from the patch, acceptor `u, v, p` from the background.
//!
//! The overlap mass defect — continuity is enforced on neither the fringe
//! nor the acceptor cells — is measured every step on both sides.
pub mod overlap;
pub use overlap::{Acceptor, CellClass, DualDonor, FringeEntry, LatticeDonor, OverlapMap};
use crate::error::{CfdError, CfdResult};
use crate::mesh::PatchMesh;
use crate::solvers::incompressible::curvilinear::{
CurvilinearPisoSolver, CurvilinearSolverState, PatchField,
};
use crate::solvers::incompressible::embedded::{EmbeddedPisoSolver, EmbeddedSolverState};
use crate::solvers::incompressible::flow_field::FlowField;
/// Parameters of the composite step.
#[derive(Debug, Clone)]
pub struct OversetParameters {
/// Projections per step (both meshes).
pub corrector_steps: usize,
/// Schwarz stop: the largest change of an exchanged `p'` value between
/// rounds, relative to the largest exchanged `|p'|`.
pub schwarz_tolerance: f64,
/// Round cap per corrector.
pub max_rounds: usize,
/// Anderson-acceleration depth on the acceptor `p'` vector (0 = plain
/// alternating Schwarz). Plain Schwarz converges at ≈ 0.82 per round
/// here — the patch's wall is Neumann for `p'`, so its pressure level
/// is pinned only through the fringe and decays weakly across the
/// overlap (measured: 4.5e-3 relative after 20 rounds).
pub anderson_depth: usize,
/// Patch rows below the acceptor row kept non-hole
/// (`overlap::DEFAULT_OVERLAP_ROWS`).
pub overlap_rows: usize,
}
impl Default for OversetParameters {
fn default() -> Self {
Self {
corrector_steps: 2,
schwarz_tolerance: 1e-3,
max_rounds: 20,
anderson_depth: 3,
overlap_rows: overlap::DEFAULT_OVERLAP_ROWS,
}
}
}
/// The two fields.
#[derive(Debug, Clone)]
pub struct OversetField {
/// Background staggered field.
pub background: FlowField,
/// Patch collocated field.
pub patch: PatchField,
}
/// What one composite step reports.
#[derive(Debug, Clone)]
pub struct OversetResult {
/// Schwarz rounds per corrector.
pub rounds: Vec<usize>,
/// Whether every corrector's Schwarz iteration met its stop.
pub schwarz_converged: bool,
/// Background mass residual after the last corrector (its own measure).
pub background_residual: f64,
/// Largest patch cell imbalance after the last corrector.
pub patch_max_divergence: f64,
/// Patch BiCGSTAB iterations, summed.
pub patch_poisson_iterations: usize,
/// Whether every patch pressure solve converged.
pub patch_converged: bool,
/// `Σ_fringe |div|` on the background (volume flux).
pub background_mass_defect: f64,
/// `Σ_acceptors |div|` on the patch (volume flux).
pub patch_mass_defect: f64,
/// `Σ |F|` over the acceptorinterior faces: the flux scale through the overlap.
pub overlap_flux_scale: f64,
/// Background cells that changed class in this step's overlap rebuild.
pub reclassified_cells: usize,
/// Background cells that jumped hole → active (patch moved > 1 cell).
pub fresh_cells: usize,
}
/// Restorable state of the composite (the coupling re-runs a step).
#[derive(Clone)]
pub struct OversetSolverState {
background: EmbeddedSolverState,
patch: CurvilinearSolverState,
overlap: OverlapMap,
pending: Option<PatchMesh>,
acceptor_warm: Vec<f64>,
}
/// The composite solver.
pub struct OversetPisoSolver {
background: EmbeddedPisoSolver,
patch: CurvilinearPisoSolver,
overlap: OverlapMap,
params: OversetParameters,
grid: (usize, usize, f64, f64),
pending: Option<PatchMesh>,
/// The first corrector's converged acceptor `p'` of the previous step:
/// the temporal warm start (the correction is correlated step to step).
acceptor_warm: Vec<f64>,
}
/// Anderson acceleration of a fixed-point iteration `x ← G(x)` with residual
/// `r = G(x) x`, depth `m` (least squares by normal equations, tiny).
struct Anderson {
m: usize,
xs: Vec<Vec<f64>>,
rs: Vec<Vec<f64>>,
}
impl Anderson {
fn new(m: usize) -> Self {
Self {
m,
xs: Vec::new(),
rs: Vec::new(),
}
}
/// Next iterate from the current `x` and its residual `r`.
fn next(&mut self, x: &[f64], r: &[f64]) -> Vec<f64> {
self.xs.push(x.to_vec());
self.rs.push(r.to_vec());
while self.xs.len() > self.m + 1 {
self.xs.remove(0);
self.rs.remove(0);
}
let k = self.xs.len() - 1;
if self.m == 0 || k == 0 {
return x.iter().zip(r).map(|(a, b)| a + b).collect();
}
// Differences ΔR_i = r_{i+1} r_i, ΔX_i = x_{i+1} x_i, i = 0..k.
let n = x.len();
let dr: Vec<Vec<f64>> = (0..k)
.map(|i| (0..n).map(|j| self.rs[i + 1][j] - self.rs[i][j]).collect())
.collect();
let dx: Vec<Vec<f64>> = (0..k)
.map(|i| (0..n).map(|j| self.xs[i + 1][j] - self.xs[i][j]).collect())
.collect();
// Normal equations (ΔRᵀΔR) γ = ΔRᵀ r, Tikhonov-regularised.
let mut a = vec![vec![0.0; k]; k];
let mut b = vec![0.0; k];
let mut trace = 0.0;
for i in 0..k {
for l in 0..k {
a[i][l] = dr[i].iter().zip(&dr[l]).map(|(p, q)| p * q).sum();
}
trace += a[i][i];
b[i] = dr[i].iter().zip(r).map(|(p, q)| p * q).sum();
}
for i in 0..k {
a[i][i] += 1e-10 * trace.max(1e-300);
}
let gamma = solve_small(a, b);
(0..n)
.map(|j| {
let mut v = x[j] + r[j];
for i in 0..k {
v -= gamma[i] * (dx[i][j] + dr[i][j]);
}
v
})
.collect()
}
}
/// Gaussian elimination with partial pivoting on a tiny dense system.
fn solve_small(mut a: Vec<Vec<f64>>, mut b: Vec<f64>) -> Vec<f64> {
let k = b.len();
for col in 0..k {
let piv = (col..k)
.max_by(|&i, &j| a[i][col].abs().partial_cmp(&a[j][col].abs()).unwrap())
.unwrap();
a.swap(col, piv);
b.swap(col, piv);
let d = a[col][col];
if d.abs() <= 1e-300 {
continue;
}
for row in col + 1..k {
let f = a[row][col] / d;
for c in col..k {
a[row][c] -= f * a[col][c];
}
b[row] -= f * b[col];
}
}
let mut x = vec![0.0; k];
for row in (0..k).rev() {
let mut v = b[row];
for c in row + 1..k {
v -= a[row][c] * x[c];
}
x[row] = if a[row][row].abs() > 1e-300 {
v / a[row][row]
} else {
0.0
};
}
x
}
impl OversetPisoSolver {
/// Compose a configured background (sides, sources, boundary data) and
/// a configured patch (its `Inner` side velocity via
/// `set_side_velocity`) on a background of `nx × ny` cells, spacing
/// `dx, dy`. Builds the overlap, installs the hole/fringe mask on the
/// background and the acceptor ring on the patch.
pub fn new(
mut background: EmbeddedPisoSolver,
mut patch: CurvilinearPisoSolver,
grid: (usize, usize, f64, f64),
params: OversetParameters,
) -> CfdResult<Self> {
let (nx, ny, dx, dy) = grid;
let overlap = OverlapMap::build(patch.mesh(), nx, ny, dx, dy, params.overlap_rows)?;
background.set_overlap(overlap.background_mask(), overlap.fringe_flags());
// Each background solve must be accurate below the Schwarz stop, or
// the exchanged values never settle (measured at the default 1e-2).
background.set_inner_stop_factor(0.1 * params.schwarz_tolerance);
patch.set_acceptor_ring(true);
Ok(Self {
background,
patch,
overlap,
params,
grid,
pending: None,
acceptor_warm: Vec::new(),
})
}
/// The background solver.
pub fn background(&self) -> &EmbeddedPisoSolver {
&self.background
}
/// The patch solver.
pub fn patch(&self) -> &CurvilinearPisoSolver {
&self.patch
}
/// The current overlap map.
pub fn overlap(&self) -> &OverlapMap {
&self.overlap
}
/// Parameters.
pub fn parameters(&self) -> &OversetParameters {
&self.params
}
/// The patch's time (the background's agrees).
pub fn time(&self) -> f64 {
self.patch.time()
}
/// Name the patch geometry the next step ends on (see
/// `CurvilinearPisoSolver::set_mesh`; same topology; a fraction of a
/// background cell per step).
pub fn set_patch_mesh(&mut self, next: PatchMesh) -> CfdResult<()> {
self.patch.set_mesh(next.clone())?;
self.pending = Some(next);
Ok(())
}
/// Stamp both exchanges from the current fields and initialise the
/// background (boundary data at the current time). Call once after the
/// fields are set.
pub fn initialize(&mut self, field: &mut OversetField) -> CfdResult<()> {
self.exchange(field);
self.background.initialize(&mut field.background)
}
/// The exchange for the next step: prescribed background faces and
/// fringe `p` from the patch's cell field; acceptor `u, v, p` from the
/// background.
fn exchange(&self, field: &mut OversetField) {
self.overlap
.stamp_fringe_faces(&mut field.background, &field.patch.u, &field.patch.v);
let p = self.overlap.fringe_cell_values(&field.patch.p);
self.overlap.stamp_fringe_cells(&mut field.background.p, &p);
let acc = self
.overlap
.acceptor_values(&field.background, &field.background.p);
self.patch.stamp_acceptors(&mut field.patch, &acc);
}
/// Capture the state.
pub fn snapshot(&self) -> OversetSolverState {
OversetSolverState {
background: self.background.snapshot(),
patch: self.patch.snapshot(),
overlap: self.overlap.clone(),
pending: self.pending.clone(),
acceptor_warm: self.acceptor_warm.clone(),
}
}
/// Restore a captured state.
pub fn restore(&mut self, state: &OversetSolverState) {
self.background.restore(&state.background);
self.patch.restore(&state.patch);
self.overlap = state.overlap.clone();
self.pending = state.pending.clone();
self.acceptor_warm = state.acceptor_warm.clone();
// The background's mask and fringe flags come back with its own
// state; its fringe Dirichlet data are transient (set every round).
}
/// Advance both meshes one step of `dt`.
pub async fn advance(&mut self, field: &mut OversetField, dt: f64) -> CfdResult<OversetResult> {
let (nx, ny, dx, dy) = self.grid;
// 1. If the patch moves, the overlap follows the NEXT mesh before any
// predictor runs: the background is reclassified (fresh cells
// filled from neighbours), and the fringe is re-stamped from the
// patch's previous CORRECTED cell field through the new donors —
// the same numbers as the static path's exchange when the mesh did
// not move, so a stationary patch through this path is the static
// path to the bit.
let mut reclassified = 0usize;
let mut fresh = 0usize;
if self.pending.take().is_some() {
let next = self
.patch
.next_mesh()
.expect("set_mesh named the next mesh");
let new = OverlapMap::build(next, nx, ny, dx, dy, self.params.overlap_rows)?;
for j in 0..ny {
for i in 0..nx {
let (was, now) = (self.overlap.class(j, i), new.class(j, i));
if was != now {
reclassified += 1;
if was == CellClass::Hole && now == CellClass::Active {
fresh += 1;
// Fill from the neighbours that already carry a
// pressure (the embedded solver's fresh-cell rule).
let (mut sum, mut count) = (0.0, 0usize);
for (jj, ii) in [
(j, i + 1),
(j, i.wrapping_sub(1)),
(j + 1, i),
(j.wrapping_sub(1), i),
] {
if jj < ny
&& ii < nx
&& self.overlap.class(jj, ii) != CellClass::Hole
{
sum += field.background.p[(jj, ii)];
count += 1;
}
}
if count > 0 {
field.background.p[(j, i)] = sum / count as f64;
}
}
}
}
}
self.overlap = new;
self.background
.set_overlap(self.overlap.background_mask(), self.overlap.fringe_flags());
self.overlap
.stamp_fringe_faces(&mut field.background, &field.patch.u, &field.patch.v);
let p = self.overlap.fringe_cell_values(&field.patch.p);
self.overlap.stamp_fringe_cells(&mut field.background.p, &p);
}
// 1b. Patch predictor (swaps in the pending mesh).
let patch_start = self.patch.begin_step(&mut field.patch, dt)?;
// 2. Background predictor.
let bg_start = self.background.begin_step(&mut field.background, dt)?;
// 3. Correctors: alternating Schwarz on the acceptor p' vector `a`
// (patch solve with Dirichlet a → fringe p' → background solve →
// G(a)), Anderson-accelerated, warm-started in the first
// corrector from the previous step's converged `a`.
let fringe_cells: Vec<(usize, usize)> = self
.overlap
.fringe_cells
.iter()
.map(|e| (e.j, e.i))
.collect();
let n_acc = self.overlap.acceptors.len();
let mut rounds = Vec::with_capacity(self.params.corrector_steps);
let mut schwarz_converged = true;
let mut residual_history = Vec::new();
let mut final_residual = f64::INFINITY;
let mut patch_iterations = 0usize;
let mut patch_converged = true;
let trace_rounds = std::env::var("RTX_OVERSET_ROUNDS").is_ok();
// The stop is relative to the STEP's pressure-correction scale (the
// first corrector's): a later corrector's own p' is a mop-up of the
// size of the inner solver's absolute-stop noise (≈ 1e-9 in flux
// units is ≈ 1e-9/dt² in pressure — 0.02 here, the whole second
// correction), so a stop relative to its own magnitude cannot be
// met and would only feed noise to the acceleration.
let mut step_scale = 0.0_f64;
for corrector in 0..self.params.corrector_steps.max(1) {
let mut a: Vec<f64> = if corrector == 0 && self.acceptor_warm.len() == n_acc {
self.acceptor_warm.clone()
} else {
vec![0.0; n_acc]
};
let mut anderson = Anderson::new(self.params.anderson_depth);
let mut patch_pc = vec![0.0; self.patch.mesh().cell_count()];
let mut done = false;
let mut used = 0usize;
for round in 0..self.params.max_rounds.max(1) {
used = round + 1;
// Patch with Dirichlet a.
self.patch.set_acceptor_correction(&a);
match self.patch.solve_correction(&field.patch, dt) {
Some((pc, out)) => {
patch_iterations += out.iterations;
patch_converged &= out.converged;
patch_pc = pc;
}
None => patch_pc.iter_mut().for_each(|v| *v = 0.0),
}
// Background with the fringe p' from the patch.
let fringe_vals = self.overlap.fringe_cell_values(&patch_pc);
self.background
.set_fringe_correction(&fringe_cells, &fringe_vals);
self.background.solve_correction(
&mut field.background,
dt,
corrector == 0 || round > 0,
)?;
let g = self.overlap.acceptor_scalar(&field.background.p_prime);
let r: Vec<f64> = g.iter().zip(&a).map(|(x, y)| x - y).collect();
let g_max = g.iter().fold(0.0_f64, |m, v| m.max(v.abs()));
step_scale = step_scale.max(g_max);
let scale = step_scale.max(1e-300);
let change = r.iter().fold(0.0_f64, |m, v| m.max(v.abs()));
if trace_rounds {
println!(
" corrector {corrector} round {round}: |G(a) a| {change:.3e} / step scale {scale:.3e} = {:.3e} (max|G(a)| {g_max:.3e})",
change / scale
);
}
if change <= self.params.schwarz_tolerance * scale {
done = true;
break;
}
let next = anderson.next(&a, &r);
// Guard: an extrapolation far beyond the data is noise-driven;
// take the plain Schwarz step instead.
let next_max = next.iter().fold(0.0_f64, |m, v| m.max(v.abs()));
a = if next_max > 4.0 * g_max.max(scale) {
g.clone()
} else {
next
};
}
schwarz_converged &= done;
rounds.push(used);
if corrector == 0 {
self.acceptor_warm = a.clone();
}
// One application on each mesh (the patch with the a it solved).
let residual = self.background.apply_correction(&mut field.background, dt);
residual_history.push(residual);
final_residual = residual;
self.patch
.apply_correction_pub(&mut field.patch, &patch_pc, dt);
if corrector + 1 < self.params.corrector_steps.max(1) {
field.background.copy_to_starred();
}
}
let patch_max_div = self.patch.max_divergence_pub(&field.patch.flux);
// 4. The exchange for the next step, then the clocks.
self.exchange(field);
let outer = self.overlap.acceptor_outer_velocity(&field.background);
let (patch_defect, scale) = self.patch.acceptor_mass_defect(&field.patch, &outer);
let bg_defect = self.overlap.background_mass_defect(&field.background);
let bg = self.background.end_step(
&mut field.background,
&bg_start,
residual_history,
rounds.len(),
final_residual,
);
self.patch.end_step(
&patch_start,
rounds.len(),
patch_max_div,
patch_iterations,
patch_converged,
);
if (self.patch.time() - self.background.time()).abs()
> 1e-12 * self.patch.time().abs().max(1.0)
{
return Err(CfdError::invalid_parameter(
"overset: background and patch clocks disagree".to_string(),
));
}
Ok(OversetResult {
rounds,
schwarz_converged,
background_residual: bg.solver_result.final_residual,
patch_max_divergence: patch_max_div,
patch_poisson_iterations: patch_iterations,
patch_converged,
background_mass_defect: bg_defect,
patch_mass_defect: patch_defect,
overlap_flux_scale: scale,
reclassified_cells: reclassified,
fresh_cells: fresh,
})
}
}
@@ -0,0 +1,701 @@
//! The overlap between the fixed background grid and the curvilinear patch
//! (overset A-P2, `docs/overset_metal_campaign.md` §2.1, §5.9).
//!
//! Background cells are classified from the patch's own indices — no
//! signed-distance field: a cell whose centre lies inside the body (the
//! patch's inner ring, when periodic) or inside a patch cell with
//! `k ≤ nn 1 overlap_rows` is a HOLE; a non-hole cell 4-adjacent to a
//! hole is FRINGE (no continuity equation; `p` and `p'` Dirichlet from the
//! patch; its faces that are not shared with an active cell prescribed
//! from the patch); everything else is ACTIVE. The patch's outer row of
//! cells (`k = nn 1`) are ACCEPTORS: `u, v, p` bilinear from the
//! background's staggered lattices, no momentum or continuity equation.
//!
//! Patch → background interpolation is bilinear in the DUAL quad — the four
//! cell centres `(k,i) (k,i+1) (k+1,i+1) (k+1,i)` — by inverse bilinear
//! mapping (Newton); background → patch is bilinear on each staggered
//! lattice. Both second order (`tests/overset_interp.rs`).
//!
//! Two invariants are asserted at build, so a thin patch fails loudly
//! instead of coupling acceptors to acceptors: every fringe donor quad
//! uses patch cells `k ≤ nn 2` (never an acceptor), and every acceptor
//! donor lattice node is an active cell / a fluid face. The depth budget
//! behind `overlap_rows`: from the patch's outer boundary inward, the
//! acceptor centre sits ½ outer cell in, its bilinear stencil reaches one
//! background cell further, the fringe ring is one background cell thick,
//! and the outer curve's wobble adds its amplitude — about 2.9 h with the
//! outer spacing ≈ h. Three overlap rows (≈ 2.5 h with a 3× stretch) were
//! measured to fail exactly there (acceptor 32's p donor landed on a fringe
//! cell); four rows (≈ 3.2 h) is the default.
use crate::error::{CfdError, CfdResult};
use crate::mesh::PatchMesh;
use crate::solvers::incompressible::embedded_body::{EmbeddedMask, FaceKind};
use crate::solvers::incompressible::flow_field::FlowField;
/// Background cell class.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CellClass {
/// Carries continuity; its pressure is an unknown.
Active,
/// Ring around the hole: Dirichlet `p`, prescribed outer faces.
Fringe,
/// Under the patch (or in the body): never read.
Hole,
}
/// Bilinear weights on four patch cells (a dual quad).
#[derive(Debug, Clone, Copy)]
pub struct DualDonor {
/// The four cells, in the dual quad's order.
pub cells: [usize; 4],
/// Their weights (sum to one).
pub w: [f64; 4],
}
/// Bilinear weights on four lattice nodes of a staggered field.
#[derive(Debug, Clone, Copy)]
pub struct LatticeDonor {
/// Row and column of the lower-left node.
pub j0: usize,
/// See `j0`.
pub i0: usize,
/// Weights for `(j0,i0) (j0,i0+1) (j0+1,i0) (j0+1,i0+1)`.
pub w: [f64; 4],
}
impl LatticeDonor {
#[inline]
fn value(&self, m: &nalgebra::DMatrix<f64>) -> f64 {
let (j, i) = (self.j0, self.i0);
self.w[0] * m[(j, i)]
+ self.w[1] * m[(j, i + 1)]
+ self.w[2] * m[(j + 1, i)]
+ self.w[3] * m[(j + 1, i + 1)]
}
}
/// A background fringe cell or face with its patch donor.
#[derive(Debug, Clone, Copy)]
pub struct FringeEntry {
/// Row.
pub j: usize,
/// Column.
pub i: usize,
/// Donor.
pub donor: DualDonor,
}
/// A patch acceptor cell with its background donors.
#[derive(Debug, Clone, Copy)]
pub struct Acceptor {
/// Patch cell index.
pub cell: usize,
/// Donor on the u lattice `(i dx, (j + ½) dy)`.
pub u: LatticeDonor,
/// Donor on the v lattice `((i + ½) dx, j dy)`.
pub v: LatticeDonor,
/// Donor on the cell-centre lattice.
pub p: LatticeDonor,
/// Donors of the background velocity at the acceptor's OUTER face
/// centre (u and v lattices), for the mass-defect measure.
pub outer_u: LatticeDonor,
/// See `outer_u`.
pub outer_v: LatticeDonor,
}
/// The classification and the donors of one patch position.
#[derive(Debug, Clone)]
pub struct OverlapMap {
nx: usize,
ny: usize,
dx: f64,
dy: f64,
class: Vec<CellClass>,
/// Fringe cells (Dirichlet `p`, `p'`).
pub fringe_cells: Vec<FringeEntry>,
/// Prescribed u faces with donors.
pub fringe_u: Vec<FringeEntry>,
/// Prescribed v faces with donors.
pub fringe_v: Vec<FringeEntry>,
/// Acceptor cells on the patch's outer row.
pub acceptors: Vec<Acceptor>,
/// Patch rows searched for fringe donors (`nn 1 overlap_rows 1 ..= nn 2`).
pub donor_rows: (usize, usize),
hole_cells: usize,
}
/// Number of patch rows below the acceptor row that stay non-hole.
pub const DEFAULT_OVERLAP_ROWS: usize = 4;
impl OverlapMap {
/// Classify the `nx × ny` background of spacing `dx, dy` against
/// `patch`, with `overlap_rows` patch rows (below the acceptor row)
/// kept non-hole. Errors when a fringe cell has no interior donor or an
/// acceptor's donors are not all active (the patch is too thin or too
/// close to the domain boundary).
pub fn build(
patch: &PatchMesh,
nx: usize,
ny: usize,
dx: f64,
dy: f64,
overlap_rows: usize,
) -> CfdResult<Self> {
let (ns, nn) = (patch.ns(), patch.nn());
if nn < overlap_rows + 3 {
return Err(CfdError::mesh(format!(
"overset: patch needs nn >= overlap_rows + 3 = {}, got {nn}",
overlap_rows + 3
)));
}
let hole_row_max = nn - 1 - overlap_rows; // k <= this is hole
let primal = QuadIndex::primal(patch);
let body = body_polygon(patch);
// 1. Cells.
let mut class = vec![CellClass::Active; nx * ny];
let mut hole_cells = 0;
for j in 0..ny {
for i in 0..nx {
let x = (i as f64 + 0.5) * dx;
let y = (j as f64 + 0.5) * dy;
let in_hole = match primal.locate(patch, x, y) {
Some(c) => patch.cell_ki(c).0 <= hole_row_max,
None => body
.as_ref()
.is_some_and(|poly| point_in_polygon(poly, x, y)),
};
if in_hole {
class[j * nx + i] = CellClass::Hole;
hole_cells += 1;
}
}
}
for j in 0..ny {
for i in 0..nx {
if class[j * nx + i] != CellClass::Hole {
let hole = |jj: usize, ii: usize| class[jj * nx + ii] == CellClass::Hole;
if (i > 0 && hole(j, i - 1))
|| (i + 1 < nx && hole(j, i + 1))
|| (j > 0 && hole(j - 1, i))
|| (j + 1 < ny && hole(j + 1, i))
{
class[j * nx + i] = CellClass::Fringe;
}
}
}
}
for j in 0..ny {
for i in 0..nx {
let c = class[j * nx + i];
if c != CellClass::Active && (i == 0 || j == 0 || i + 1 == nx || j + 1 == ny) {
return Err(CfdError::mesh(format!(
"overset: {c:?} cell ({j}, {i}) touches the domain boundary"
)));
}
}
}
// 2. Fringe donors in the dual quads of rows k ∈ [k_lo, nn 2].
let k_hi = nn - 2;
let k_lo = hole_row_max.saturating_sub(1);
let dual = QuadIndex::dual(patch, k_lo, k_hi);
let is_active = |jj: usize, ii: usize| class[jj * nx + ii] == CellClass::Active;
let mut fringe_cells = Vec::new();
for j in 0..ny {
for i in 0..nx {
if class[j * nx + i] == CellClass::Fringe {
let x = (i as f64 + 0.5) * dx;
let y = (j as f64 + 0.5) * dy;
let donor = dual.dual_donor(patch, x, y).ok_or_else(|| {
CfdError::mesh(format!(
"overset: fringe cell ({j}, {i}) at ({x:.4}, {y:.4}) has no interior \
patch donor in rows {k_lo}..={k_hi} — patch too thin"
))
})?;
fringe_cells.push(FringeEntry { j, i, donor });
}
}
}
// Prescribed faces: interior faces with no active neighbour, that
// have a donor in the band (deeper ones are never read).
let mut fringe_u = Vec::new();
for j in 0..ny {
for i in 1..nx {
if !is_active(j, i - 1) && !is_active(j, i) {
let touches_fringe = class[j * nx + i - 1] == CellClass::Fringe
|| class[j * nx + i] == CellClass::Fringe;
let (x, y) = (i as f64 * dx, (j as f64 + 0.5) * dy);
match dual.dual_donor(patch, x, y) {
Some(donor) => fringe_u.push(FringeEntry { j, i, donor }),
None if touches_fringe => {
return Err(CfdError::mesh(format!(
"overset: fringe u face ({j}, {i}) has no interior patch donor"
)));
}
None => {}
}
}
}
}
let mut fringe_v = Vec::new();
for j in 1..ny {
for i in 0..nx {
if !is_active(j - 1, i) && !is_active(j, i) {
let touches_fringe = class[(j - 1) * nx + i] == CellClass::Fringe
|| class[j * nx + i] == CellClass::Fringe;
let (x, y) = ((i as f64 + 0.5) * dx, j as f64 * dy);
match dual.dual_donor(patch, x, y) {
Some(donor) => fringe_v.push(FringeEntry { j, i, donor }),
None if touches_fringe => {
return Err(CfdError::mesh(format!(
"overset: fringe v face ({j}, {i}) has no interior patch donor"
)));
}
None => {}
}
}
}
}
// 3. Acceptors: patch row nn 1, lattice donors on the background.
let u_fluid = |jj: usize, ii: usize| {
// A u face is fluid unless both adjacent cells are non-active.
ii == 0 || ii == nx || is_active(jj, ii - 1) || is_active(jj, ii)
};
let v_fluid = |jj: usize, ii: usize| {
jj == 0 || jj == ny || is_active(jj - 1, ii) || is_active(jj, ii)
};
let mut acceptors = Vec::with_capacity(ns);
for i in 0..ns {
let cell = patch.cell(nn - 1, i);
let xy = patch.centre(cell);
let u = lattice_donor(xy, 0.0, 0.5, dx, dy, nx + 1, ny)?;
let v = lattice_donor(xy, 0.5, 0.0, dx, dy, nx, ny + 1)?;
let p = lattice_donor(xy, 0.5, 0.5, dx, dy, nx, ny)?;
let oc = patch.faces()[patch.nface(nn, i)].centre;
let outer_u = lattice_donor(oc, 0.0, 0.5, dx, dy, nx + 1, ny)?;
let outer_v = lattice_donor(oc, 0.5, 0.0, dx, dy, nx, ny + 1)?;
for (dj, di) in [(0, 0), (0, 1), (1, 0), (1, 1)] {
if !u_fluid(u.j0 + dj, u.i0 + di) {
return Err(CfdError::mesh(format!(
"overset: acceptor {i} u donor ({}, {}) is a prescribed face",
u.j0 + dj,
u.i0 + di
)));
}
if !v_fluid(v.j0 + dj, v.i0 + di) {
return Err(CfdError::mesh(format!(
"overset: acceptor {i} v donor ({}, {}) is a prescribed face",
v.j0 + dj,
v.i0 + di
)));
}
if !is_active(p.j0 + dj, p.i0 + di) {
return Err(CfdError::mesh(format!(
"overset: acceptor {i} p donor cell ({}, {}) is not active",
p.j0 + dj,
p.i0 + di
)));
}
}
acceptors.push(Acceptor {
cell,
u,
v,
p,
outer_u,
outer_v,
});
}
Ok(Self {
nx,
ny,
dx,
dy,
class,
fringe_cells,
fringe_u,
fringe_v,
acceptors,
donor_rows: (k_lo, k_hi),
hole_cells,
})
}
/// Class of background cell `(j, i)`.
pub fn class(&self, j: usize, i: usize) -> CellClass {
self.class[j * self.nx + i]
}
/// Number of hole cells.
pub fn hole_cells(&self) -> usize {
self.hole_cells
}
/// Number of fringe cells.
pub fn fringe_count(&self) -> usize {
self.fringe_cells.len()
}
/// Grid dimensions `(nx, ny, dx, dy)`.
pub fn grid(&self) -> (usize, usize, f64, f64) {
(self.nx, self.ny, self.dx, self.dy)
}
/// The background mask for the embedded solver: active cells fluid;
/// a face is `Fluid` unless both adjacent cells are non-active, in
/// which case it is prescribed (`Ghost`, with no reconstruction data —
/// its value is stamped from the patch).
pub fn background_mask(&self) -> EmbeddedMask {
let (nx, ny) = (self.nx, self.ny);
let cell_fluid: Vec<bool> = self.class.iter().map(|&c| c == CellClass::Active).collect();
let active = |j: usize, i: usize| cell_fluid[j * nx + i];
let mut u_kind = vec![FaceKind::Fluid; ny * (nx + 1)];
for j in 0..ny {
for i in 1..nx {
if !active(j, i - 1) && !active(j, i) {
u_kind[j * (nx + 1) + i] = FaceKind::Ghost;
}
}
}
let mut v_kind = vec![FaceKind::Fluid; (ny + 1) * nx];
for j in 1..ny {
for i in 0..nx {
if !active(j - 1, i) && !active(j, i) {
v_kind[j * nx + i] = FaceKind::Ghost;
}
}
}
EmbeddedMask::from_classification(nx, ny, self.dx, self.dy, cell_fluid, u_kind, v_kind)
}
/// Per-cell Dirichlet flags for the background projection: `true` on
/// fringe cells.
pub fn fringe_flags(&self) -> Vec<bool> {
self.class.iter().map(|&c| c == CellClass::Fringe).collect()
}
/// Interpolate a patch cell field to the fringe cells (order of
/// `fringe_cells`).
pub fn fringe_cell_values(&self, patch_vals: &[f64]) -> Vec<f64> {
self.fringe_cells
.iter()
.map(|e| dual_value(&e.donor, patch_vals))
.collect()
}
/// Stamp `values` (from [`Self::fringe_cell_values`]) onto a
/// background cell field.
pub fn stamp_fringe_cells(&self, target: &mut nalgebra::DMatrix<f64>, values: &[f64]) {
for (e, &v) in self.fringe_cells.iter().zip(values) {
target[(e.j, e.i)] = v;
}
}
/// Stamp the prescribed u and v faces of the background from the patch
/// cell velocities.
pub fn stamp_fringe_faces(&self, field: &mut FlowField, patch_u: &[f64], patch_v: &[f64]) {
for e in &self.fringe_u {
field.u[(e.j, e.i)] = dual_value(&e.donor, patch_u);
}
for e in &self.fringe_v {
field.v[(e.j, e.i)] = dual_value(&e.donor, patch_v);
}
}
/// `(u, v, p)` at every acceptor from the background field (order of
/// `acceptors`); `p_source` selects which cell field supplies the
/// pressure-like value.
pub fn acceptor_values(
&self,
field: &FlowField,
p_source: &nalgebra::DMatrix<f64>,
) -> Vec<(f64, f64, f64)> {
self.acceptors
.iter()
.map(|a| {
(
a.u.value(&field.u),
a.v.value(&field.v),
a.p.value(p_source),
)
})
.collect()
}
/// The background velocity at every acceptor's outer face centre.
pub fn acceptor_outer_velocity(&self, field: &FlowField) -> Vec<(f64, f64)> {
self.acceptors
.iter()
.map(|a| (a.outer_u.value(&field.u), a.outer_v.value(&field.v)))
.collect()
}
/// A cell-centred background scalar (e.g. `p'`) at every acceptor.
pub fn acceptor_scalar(&self, m: &nalgebra::DMatrix<f64>) -> Vec<f64> {
self.acceptors.iter().map(|a| a.p.value(m)).collect()
}
/// Background-side overlap mass defect: `Σ_fringe |Σ_f sign F_f|`
/// (volume flux), the continuity the fringe cells do not enforce.
pub fn background_mass_defect(&self, field: &FlowField) -> f64 {
let (dx, dy) = (self.dx, self.dy);
self.fringe_cells
.iter()
.map(|e| {
let (j, i) = (e.j, e.i);
((field.u[(j, i + 1)] - field.u[(j, i)]) * dy
+ (field.v[(j + 1, i)] - field.v[(j, i)]) * dx)
.abs()
})
.sum()
}
}
#[inline]
fn dual_value(d: &DualDonor, vals: &[f64]) -> f64 {
d.w[0] * vals[d.cells[0]]
+ d.w[1] * vals[d.cells[1]]
+ d.w[2] * vals[d.cells[2]]
+ d.w[3] * vals[d.cells[3]]
}
/// Bilinear donor of point `xy` on a lattice whose node `(j, i)` sits at
/// `((i + ox) dx, (j + oy) dy)`, with `cols × rows` nodes.
fn lattice_donor(
xy: [f64; 2],
ox: f64,
oy: f64,
dx: f64,
dy: f64,
cols: usize,
rows: usize,
) -> CfdResult<LatticeDonor> {
let fx = xy[0] / dx - ox;
let fy = xy[1] / dy - oy;
if fx < 0.0 || fy < 0.0 || fx >= (cols - 1) as f64 || fy >= (rows - 1) as f64 {
return Err(CfdError::mesh(format!(
"overset: acceptor at ({:.4}, {:.4}) lies outside the background lattice",
xy[0], xy[1]
)));
}
let i0 = fx.floor() as usize;
let j0 = fy.floor() as usize;
let (a, b) = (fx - i0 as f64, fy - j0 as f64);
Ok(LatticeDonor {
j0,
i0,
w: [(1.0 - a) * (1.0 - b), a * (1.0 - b), (1.0 - a) * b, a * b],
})
}
/// The patch's inner ring as a closed polygon (periodic patches only).
fn body_polygon(patch: &PatchMesh) -> Option<Vec<[f64; 2]>> {
patch.periodic()?;
Some(
(0..patch.ns())
.map(|i| patch.node_xy(patch.node(0, i)))
.collect(),
)
}
/// Evenodd point-in-polygon.
fn point_in_polygon(poly: &[[f64; 2]], x: f64, y: f64) -> bool {
let mut inside = false;
let n = poly.len();
for a in 0..n {
let (p, q) = (poly[a], poly[(a + 1) % n]);
if (p[1] > y) != (q[1] > y) {
let xi = p[0] + (y - p[1]) / (q[1] - p[1]) * (q[0] - p[0]);
if x < xi {
inside = !inside;
}
}
}
inside
}
/// Is `xy` inside the convex quad `q` (counter-clockwise)?
fn point_in_quad(q: &[[f64; 2]; 4], x: f64, y: f64, tol: f64) -> bool {
(0..4).all(|a| {
let (p, r) = (q[a], q[(a + 1) % 4]);
(r[0] - p[0]) * (y - p[1]) - (r[1] - p[1]) * (x - p[0]) >= -tol
})
}
/// Bilinear weights of `xy` in the quad `q` (corners in the order
/// `(0,0) (1,0) (1,1) (0,1)`), by Newton on the inverse map; `None` if
/// Newton does not converge in 12 steps.
pub fn inverse_bilinear(q: &[[f64; 2]; 4], x: f64, y: f64) -> Option<[f64; 4]> {
let (mut s, mut t) = (0.5, 0.5);
let scale = (0..4)
.map(|a| (q[a][0] - q[0][0]).abs().max((q[a][1] - q[0][1]).abs()))
.fold(0.0, f64::max)
.max(1e-300);
for _ in 0..12 {
let n = [(1.0 - s) * (1.0 - t), s * (1.0 - t), s * t, (1.0 - s) * t];
let px = (0..4).map(|a| n[a] * q[a][0]).sum::<f64>() - x;
let py = (0..4).map(|a| n[a] * q[a][1]).sum::<f64>() - y;
if px.abs().max(py.abs()) <= 1e-14 * scale {
return Some(n);
}
// Jacobian d(px,py)/d(s,t).
let dxs = -(1.0 - t) * q[0][0] + (1.0 - t) * q[1][0] + t * q[2][0] - t * q[3][0];
let dys = -(1.0 - t) * q[0][1] + (1.0 - t) * q[1][1] + t * q[2][1] - t * q[3][1];
let dxt = -(1.0 - s) * q[0][0] - s * q[1][0] + s * q[2][0] + (1.0 - s) * q[3][0];
let dyt = -(1.0 - s) * q[0][1] - s * q[1][1] + s * q[2][1] + (1.0 - s) * q[3][1];
let det = dxs * dyt - dxt * dys;
if det.abs() <= 1e-300 {
return None;
}
s -= (px * dyt - dxt * py) / det;
t -= (dxs * py - px * dys) / det;
}
let n = [(1.0 - s) * (1.0 - t), s * (1.0 - t), s * t, (1.0 - s) * t];
let px = (0..4).map(|a| n[a] * q[a][0]).sum::<f64>() - x;
let py = (0..4).map(|a| n[a] * q[a][1]).sum::<f64>() - y;
(px.abs().max(py.abs()) <= 1e-12 * scale).then_some(n)
}
/// Uniform bins over a set of quads for point location.
struct QuadIndex {
quads: Vec<([[f64; 2]; 4], [usize; 4])>,
x0: f64,
y0: f64,
bw: f64,
bh: f64,
nbx: usize,
nby: usize,
bins: Vec<Vec<usize>>,
tol: f64,
}
impl QuadIndex {
/// The primal cells: corners are nodes, payload the cell index (×4).
fn primal(patch: &PatchMesh) -> Self {
let (ns, nn) = (patch.ns(), patch.nn());
let mut quads = Vec::with_capacity(ns * nn);
for k in 0..nn {
for i in 0..ns {
let n = [
patch.node(k, i),
patch.node(k, i + 1),
patch.node(k + 1, i + 1),
patch.node(k + 1, i),
];
let c = patch.cell(k, i);
quads.push((n.map(|nd| patch.node_xy(nd)), [c; 4]));
}
}
Self::new(quads)
}
/// The dual quads of rows `k_lo..=k_hi` (corners are cell centres,
/// payload the four cells), periodic wrap in `i`.
fn dual(patch: &PatchMesh, k_lo: usize, k_hi: usize) -> Self {
let (ns, nn) = (patch.ns(), patch.nn());
let shift = patch.periodic();
let cols = if shift.is_some() { ns } else { ns - 1 };
let mut quads = Vec::new();
for k in k_lo..=k_hi.min(nn - 2) {
for i in 0..cols {
let i1 = (i + 1) % ns;
let wrap = shift.filter(|_| i1 == 0).unwrap_or([0.0; 2]);
let cells = [
patch.cell(k, i),
patch.cell(k, i1),
patch.cell(k + 1, i1),
patch.cell(k + 1, i),
];
let mut pts = cells.map(|c| patch.centre(c));
pts[1] = [pts[1][0] + wrap[0], pts[1][1] + wrap[1]];
pts[2] = [pts[2][0] + wrap[0], pts[2][1] + wrap[1]];
quads.push((pts, cells));
}
}
Self::new(quads)
}
fn new(quads: Vec<([[f64; 2]; 4], [usize; 4])>) -> Self {
let (mut x0, mut y0, mut x1, mut y1) = (
f64::INFINITY,
f64::INFINITY,
f64::NEG_INFINITY,
f64::NEG_INFINITY,
);
let mut hmax = 0.0_f64;
for (q, _) in &quads {
for p in q {
x0 = x0.min(p[0]);
y0 = y0.min(p[1]);
x1 = x1.max(p[0]);
y1 = y1.max(p[1]);
}
for a in 0..4 {
let (p, r) = (q[a], q[(a + 1) % 4]);
hmax = hmax.max(((r[0] - p[0]).powi(2) + (r[1] - p[1]).powi(2)).sqrt());
}
}
let n = quads.len().max(1);
let side = ((n as f64).sqrt().ceil() as usize).max(1);
let bw = ((x1 - x0) / side as f64).max(1e-300);
let bh = ((y1 - y0) / side as f64).max(1e-300);
let mut bins = vec![Vec::new(); side * side];
for (idx, (q, _)) in quads.iter().enumerate() {
let (mut bx0, mut by0, mut bx1, mut by1) = (usize::MAX, usize::MAX, 0, 0);
for p in q {
let bx = (((p[0] - x0) / bw).floor() as usize).min(side - 1);
let by = (((p[1] - y0) / bh).floor() as usize).min(side - 1);
bx0 = bx0.min(bx);
by0 = by0.min(by);
bx1 = bx1.max(bx);
by1 = by1.max(by);
}
for by in by0..=by1 {
for bx in bx0..=bx1 {
bins[by * side + bx].push(idx);
}
}
}
Self {
quads,
x0,
y0,
bw,
bh,
nbx: side,
nby: side,
bins,
tol: 1e-12 * hmax * hmax,
}
}
fn candidates(&self, x: f64, y: f64) -> &[usize] {
let fx = (x - self.x0) / self.bw;
let fy = (y - self.y0) / self.bh;
if fx < 0.0 || fy < 0.0 || fx >= self.nbx as f64 || fy >= self.nby as f64 {
return &[];
}
&self.bins[(fy as usize) * self.nbx + fx as usize]
}
/// The primal cell containing `(x, y)`.
fn locate(&self, _patch: &PatchMesh, x: f64, y: f64) -> Option<usize> {
self.candidates(x, y)
.iter()
.find(|&&q| point_in_quad(&self.quads[q].0, x, y, self.tol))
.map(|&q| self.quads[q].1[0])
}
/// The dual donor of `(x, y)`.
fn dual_donor(&self, _patch: &PatchMesh, x: f64, y: f64) -> Option<DualDonor> {
for &q in self.candidates(x, y) {
let (pts, cells) = &self.quads[q];
if point_in_quad(pts, x, y, self.tol) {
let w = inverse_bilinear(pts, x, y)?;
return Some(DualDonor { cells: *cells, w });
}
}
None
}
}