rtx-cfd: overset A-P3 — the falsifier plate on the overset (FAILS the registered gates by one order less than the staircase); wall force; composite pressure-level pin; Schwarz stall detection
Documentation / Build API Documentation (push) Canceled after 0s
Documentation / Build User Guide (push) Canceled after 0s
Performance Benchmarks / Run Benchmarks (push) Canceled after 0s
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Build CPU-Only (Explicit) (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
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 (macos-latest) (push) Canceled after 0s

patch_gen::{stadium, graded_fractions}: the falsifier plate as a stadium O-grid
(semicircular ends r = half-thickness; 16 cells per end arc, straights graded
0.30 h -> h at 1.15, offset 6 h, 12 rows stretched 4x; 148x12 cells, every ray
a normal, worst non-orthogonality 4 deg). CurvilinearPisoSolver::surface_force
(+ PatchLoad): F = sum(-p_f S_f + mu (grad u + grad u^T)_f . S_f) on the wall
faces with the wall cell's LSQ gradients (wall Dirichlet in the velocity fit);
HELD on the phantom circle against the exact stress integral: 1.3e-2 / 6.3e-3 /
3.6e-3 at n = 32/64/128 (orders 1.05 / 0.81), 22x the staircase's accuracy.
OversetPisoSolver: the composite p' level pinned to zero mean over the active
cells every round (the coupled problem is pure Neumann; the temporal warm start
handed each step's level to the next — background pressure 1e7 growing 5e4 per
step on the falsifier; an unpinned level also inflated the relative Schwarz
stop); stall detection (no progress over three rounds = the inner solvers'
noise floor; 6560 of 150k steps burned the 20-round cap at n = 64, a 7.5 h
n = 128 march); schwarz_stalled in the result.

tests/overset_falsifier.rs (records; RTX_OVERSET_FALSIFIER_STRICT asserts the
registered gates, _LADDER runs dt/2 and dt/4, _TRACE the top-12 spike steps):
max spike 594 / 981 / 1720 N/m at dt / dt/2 / dt/4 (staircase 6490 / 12600 /
25600), rms spike 61-89 (810), far probe 502-1509 (7900), KE injection 0.16-0.21
J/m per event on the common cell set (2.6) — every large spike a ~104-cell
full-row reclassification; exponent -0.77 (-1.0). The registered 5% gate (8.75
N/m) is missed 68x: the overset's own reclassification impulse is the finding
(omni-cortex overset_metal_campaign.md §5.10); P3b = locate per cell, then the
fringe flux balance. tests/patch_stadium.rs, curvilinear_loads.rs,
overset_common::plate_patch.

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-05 04:32:43 -07:00
co-authored by Claude Fable 5.1
parent afd1bff6ee
commit e2edff9b1d
8 changed files with 943 additions and 2 deletions
@@ -609,3 +609,83 @@ pub(crate) struct StepStart {
/// Boundary-flux defect removed on a closed patch.
pub(crate) adjustment: f64,
}
/// Fluid force on a boundary side of the patch (per unit depth).
#[derive(Debug, Clone, Copy)]
pub struct PatchLoad {
/// Pressure part.
pub pressure: [f64; 2],
/// Viscous part.
pub viscous: [f64; 2],
/// Faces integrated.
pub faces: usize,
}
impl PatchLoad {
/// Total force.
pub fn total(&self) -> [f64; 2] {
[
self.pressure[0] + self.viscous[0],
self.pressure[1] + self.viscous[1],
]
}
}
impl CurvilinearPisoSolver {
/// The fluid force on the body along `side` (the wall of an O-grid:
/// `Inner`): `F = Σ_f (p_f S_f + μ (∇u + ∇uᵀ)_f · S_f)` with `S_f`
/// pointing from the body into the fluid. The face pressure is the
/// wall cell's pressure extrapolated linearly with its least-squares
/// gradient; the face velocity gradient is the wall cell's least-squares
/// gradient with the face's Dirichlet value in the fit (the wall shear
/// enters through the wall point itself). No traction reconstruction
/// through a staircase — the second thing the overset buys (§2.1).
pub fn surface_force(&self, field: &PatchField, side: PatchSide, t: f64) -> PatchLoad {
let mesh = &self.mesh;
let mu = self.config.viscosity;
let (mut fp, mut fv) = ([0.0; 2], [0.0; 2]);
let mut faces = 0usize;
for (f, face) in mesh.faces().iter().enumerate() {
if mesh.side(f) != Some(side) {
continue;
}
let c = mesh.boundary_cell(f);
// Outward from the body = into the fluid: for the Inner side
// (k = 0, the cell is the neighbour) that is +S; for the Outer
// side (the cell is the owner) it is S.
let sign = if face.neigh.is_some() { 1.0 } else { -1.0 };
let s = [sign * face.s[0], sign * face.s[1]];
let xc = mesh.centre(c);
let dxf = [face.centre[0] - xc[0], face.centre[1] - xc[1]];
let gp = self.pressure_gradient(&field.p, c);
let p_f = field.p[c] + gp[0] * dxf[0] + gp[1] * dxf[1];
fp[0] -= p_f * s[0];
fp[1] -= p_f * s[1];
let wall = |ff: usize| -> Option<(f64, f64)> {
let fc = &mesh.faces()[ff];
let sd = mesh.side(ff)?;
match self.params.boundaries.get(sd) {
SideBc::Velocity => {
Some(self.boundary_velocity(sd, fc.centre[0], fc.centre[1], t))
}
SideBc::Outlet => None,
}
};
let gu = self
.ops
.gradient(mesh, c, &field.u, &|ff| wall(ff).map(|w| w.0));
let gv = self
.ops
.gradient(mesh, c, &field.v, &|ff| wall(ff).map(|w| w.1));
// τ = μ (∇u + ∇uᵀ): τxx = 2 u_x, τxy = u_y + v_x, τyy = 2 v_y.
fv[0] += mu * (2.0 * gu[0] * s[0] + (gu[1] + gv[0]) * s[1]);
fv[1] += mu * ((gu[1] + gv[0]) * s[0] + 2.0 * gv[1] * s[1]);
faces += 1;
}
PatchLoad {
pressure: fp,
viscous: fv,
faces,
}
}
}
@@ -48,7 +48,8 @@ pub use boundary_conditions::{
};
pub use curvilinear::{
CurvilinearParameters, CurvilinearPisoSolver, CurvilinearResult, CurvilinearSolverState,
NormalDiffusion, Operators, PatchBoundaries, PatchConvection, PatchField, SideBc, StepGeometry,
NormalDiffusion, Operators, PatchBoundaries, PatchConvection, PatchField, PatchLoad, SideBc,
StepGeometry,
};
pub use embedded::{EmbeddedParameters, EmbeddedPisoSolver, EmbeddedResult, EmbeddedSolverState};
pub use embedded_body::{
@@ -85,6 +85,9 @@ pub struct OversetResult {
pub rounds: Vec<usize>,
/// Whether every corrector's Schwarz iteration met its stop.
pub schwarz_converged: bool,
/// Correctors whose rounds stalled at the inner solvers' noise floor
/// (no progress over three rounds) and were stopped there.
pub schwarz_stalled: usize,
/// Background mass residual after the last corrector (its own measure).
pub background_residual: f64,
/// Largest patch cell imbalance after the last corrector.
@@ -296,6 +299,33 @@ impl OversetPisoSolver {
self.background.initialize(&mut field.background)
}
/// Subtract the mean of `p'` over the active background cells (the
/// composite level pin; the fringe cells' Dirichlet values shift with
/// it so the face corrections across activefringe faces are unchanged).
fn remove_background_mean(&self, field: &mut FlowField) {
let (nx, ny, _, _) = self.grid;
let (mut sum, mut count) = (0.0, 0usize);
for j in 0..ny {
for i in 0..nx {
if self.overlap.class(j, i) == CellClass::Active {
sum += field.p_prime[(j, i)];
count += 1;
}
}
}
if count == 0 {
return;
}
let mean = sum / count as f64;
for j in 0..ny {
for i in 0..nx {
if self.overlap.class(j, i) != CellClass::Hole {
field.p_prime[(j, i)] -= mean;
}
}
}
}
/// 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.
@@ -410,6 +440,7 @@ impl OversetPisoSolver {
let n_acc = self.overlap.acceptors.len();
let mut rounds = Vec::with_capacity(self.params.corrector_steps);
let mut schwarz_converged = true;
let mut schwarz_stalled = 0usize;
let mut residual_history = Vec::new();
let mut final_residual = f64::INFINITY;
let mut patch_iterations = 0usize;
@@ -432,6 +463,7 @@ impl OversetPisoSolver {
let mut patch_pc = vec![0.0; self.patch.mesh().cell_count()];
let mut done = false;
let mut used = 0usize;
let mut history: Vec<f64> = Vec::new();
for round in 0..self.params.max_rounds.max(1) {
used = round + 1;
// Patch with Dirichlet a.
@@ -453,6 +485,14 @@ impl OversetPisoSolver {
dt,
corrector == 0 || round > 0,
)?;
// Pin the composite level: the coupled p' problem is pure
// Neumann (walls everywhere), so its constant mode is
// undamped by the rounds and the warm start hands each
// step's level to the next — measured as a background
// pressure of 1e7 growing 5e4 per step on the falsifier.
// A constant in p' moves no velocity; zero mean over the
// active cells pins it.
self.remove_background_mean(&mut field.background);
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()));
@@ -469,6 +509,17 @@ impl OversetPisoSolver {
done = true;
break;
}
// Stall: no progress over three rounds means the exchanged
// values sit at the inner solvers' noise floor (the step's
// own p' is that small near a steady state); more rounds
// cannot help — measured as 6560 of 150k steps burning the
// 20-round cap at n = 64, and a 7.5 h n = 128 march.
history.push(change);
if history.len() >= 4 && change > 0.7 * history[history.len() - 4] {
schwarz_stalled += 1;
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.
@@ -525,6 +576,7 @@ impl OversetPisoSolver {
Ok(OversetResult {
rounds,
schwarz_converged,
schwarz_stalled,
background_residual: bg.solver_result.final_residual,
patch_max_divergence: patch_max_div,
patch_poisson_iterations: patch_iterations,