rtx-cfd: OversetPisoSolver::momentum_residual — the background predictor's own staggered stencil (u_rhs/v_rhs, factored out of the predictor bit-identically) evaluated on every face of a NaN-masked field; solved faces read rounding, the active–fringe interface reads the composite's pressure level offset δ·h (cancels in the sum), prescribed fringe–fringe / fringe–hole faces read the stamping's momentum injection; hole ghosts (p, u, v) from a band widened three rows into the hole make every ring face evaluable; overset_cfd1 prints the buckets, the ring x-bands and δ at the settled state; pin: residual vanishes on the solved faces
CI / Format Check (push) Canceled after 0s
Performance Benchmarks / Run Benchmarks (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
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
Documentation / Build API Documentation (push) Canceled after 0s
Documentation / Build User Guide (push) Canceled after 0s

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-06 13:25:14 -07:00
co-authored by Claude Fable 5.1
parent cbec40b999
commit 6f9b0d43b2
6 changed files with 793 additions and 252 deletions
@@ -0,0 +1,300 @@
//! P4 option B (`docs/overset_metal_campaign.md` §5.11): the momentum
//! residual of the background's OWN staggered predictor stencil on every
//! background face, at a settled state.
//!
//! On a solved face the discrete equation the composite marched is
//! `ρ (u^{n+1} u^n)/dt = ρ · rhs(u^n, p^{n+1})` (predictor plus the
//! correctors' `dt ∇p'/ρ`, with `p^{n+1} = p^n + Σ p'`), so the residual
//! `r = ρ [(u^{n+1} u^n)/dt rhs] · dx dy` is zero to rounding there
//! — the pin that proves the diagnostic IS the solver's operator. On a
//! PRESCRIBED face the value is stamped from the patch, the equation is
//! not solved, and `r` is the momentum source the stamping injects, in the
//! solver's own metric and without the staircase curves' face-formula
//! error. Summed over the ring it is the fringe ring's momentum defect
//! (`region_force` ring outer hole boundary, but exact).
//!
//! Validity is decided by the stencil itself: the background field is
//! copied with `NaN` on every value that is neither the solver's own nor
//! stamped from the patch (hole cells and holehole faces within two cells
//! of the ring get the patch's interpolated values, `OverlapMap::hole_p`
//! / `ghost_u` / `ghost_v`, from a band widened three rows into the
//! hole), the
//! operator is evaluated as is, and a `NaN` result means the face read
//! something invalid and is not counted. Under the upwind scheme every
//! value the stencil reads enters its arithmetic, so the test is exact.
use std::collections::HashSet;
use super::overlap::CellClass;
use super::{OversetField, OversetPisoSolver};
use crate::solvers::incompressible::embedded_body::FaceKind;
/// Sums over one class of faces.
#[derive(Debug, Clone, Copy, Default)]
pub struct ResidualBucket {
/// `Σ r` on the u faces (x-momentum source, N/m).
pub fx: f64,
/// `Σ r` on the v faces.
pub fy: f64,
/// `Σ |r|` on the u faces.
pub abs_x: f64,
/// `Σ |r|` on the v faces.
pub abs_y: f64,
/// Largest `|r|` on the u faces.
pub max_abs_x: f64,
/// Largest `|r|` on the v faces.
pub max_abs_y: f64,
/// Faces whose stencil read only valid values.
pub evaluated: usize,
/// Of `evaluated`, the u faces.
pub evaluated_u: usize,
/// Of `evaluated`, the v faces.
pub evaluated_v: usize,
/// Faces of this class.
pub total: usize,
}
impl ResidualBucket {
fn add(&mut self, r: f64, is_u: bool) {
self.total += 1;
if !r.is_finite() {
return;
}
self.evaluated += 1;
if is_u {
self.evaluated_u += 1;
self.fx += r;
self.abs_x += r.abs();
self.max_abs_x = self.max_abs_x.max(r.abs());
} else {
self.evaluated_v += 1;
self.fy += r;
self.abs_y += r.abs();
self.max_abs_y = self.max_abs_y.max(r.abs());
}
}
}
/// One prescribed face's residual.
#[derive(Debug, Clone, Copy)]
pub struct FaceResidual {
/// A u face (x-momentum) or a v face.
pub is_u: bool,
/// Row.
pub j: usize,
/// Column.
pub i: usize,
/// The residual (N/m), `NaN` when not evaluable.
pub r: f64,
/// Between two fringe cells (else fringehole).
pub fringe_fringe: bool,
}
/// The momentum residual by face class.
#[derive(Debug, Clone, Default)]
pub struct MomentumResidual {
/// Solved faces whose stencil reads only solved values.
pub solved_far: ResidualBucket,
/// Solved faces whose stencil reads a fringe cell or a prescribed face.
pub solved_near: ResidualBucket,
/// Prescribed faces between two fringe cells (tangential to the ring).
pub fringe_fringe: ResidualBucket,
/// Prescribed faces between a fringe and a hole cell (normal to it).
pub fringe_hole: ResidualBucket,
/// Prescribed faces between two hole cells: not evaluated (their
/// control volume lies in the hole).
pub hole_hole_skipped: usize,
/// Hole cells given a ghost pressure.
pub hole_ghosts: usize,
/// Holehole faces given a ghost velocity (beyond the solver's stamps).
pub ghost_faces: usize,
/// Solved u faces between an active and a fringe cell (the ring's outer
/// boundary); their residual is the pressure LEVEL offset `δ · h`.
pub interface_u: usize,
/// See `interface_u`.
pub interface_v: usize,
/// Every prescribed fringefringe / fringehole face's residual.
pub prescribed: Vec<FaceResidual>,
}
impl MomentumResidual {
/// The composite's pressure level offset `δ` (Pa) between the active
/// cells and the re-stamped fringe: `max |r| / h` over the interface.
pub fn level_offset(&self, h: f64) -> f64 {
self.solved_near.max_abs_x.max(self.solved_near.max_abs_y) / h
}
}
impl OversetPisoSolver {
/// The momentum residual of the background's own predictor stencil on
/// every interior background face, after [`Self::advance`] (the field
/// holds `u^{n+1}`, `u_old = u^n`, `p = p^{n+1}` with the fringe
/// re-stamped). `dt` is the step just taken.
pub fn momentum_residual(&self, field: &OversetField, dt: f64) -> MomentumResidual {
let (nx, ny, dx, dy) = self.grid;
let rho = self.background.config().density;
let t_old = self.background.time() - dt;
let mask = self
.background
.mask()
.expect("the overset background carries a mask");
let map = &self.overlap;
let hole = |j: usize, i: usize| map.class(j, i) == CellClass::Hole;
// The masked copy.
let mut m = field.background.clone();
for j in 0..ny {
for i in 0..nx {
if hole(j, i) {
m.p[(j, i)] = f64::NAN;
}
}
}
let ghosts = map.hole_p_values(&field.patch.p);
map.stamp_hole_p(&mut m.p, &ghosts);
map.stamp_ghost_faces(&mut m, &field.patch.u, &field.patch.v);
let prescribed_u: HashSet<(usize, usize)> = map
.fringe_u
.iter()
.chain(&map.ghost_u)
.map(|e| (e.j, e.i))
.collect();
let prescribed_v: HashSet<(usize, usize)> = map
.fringe_v
.iter()
.chain(&map.ghost_v)
.map(|e| (e.j, e.i))
.collect();
for j in 0..ny {
for i in 0..=nx {
let valid = (i > 0 && !hole(j, i - 1))
|| (i < nx && !hole(j, i))
|| prescribed_u.contains(&(j, i));
if !valid {
m.u[(j, i)] = f64::NAN;
m.u_old[(j, i)] = f64::NAN;
}
}
}
for j in 0..=ny {
for i in 0..nx {
let valid = (j > 0 && !hole(j - 1, i))
|| (j < ny && !hole(j, i))
|| prescribed_v.contains(&(j, i));
if !valid {
m.v[(j, i)] = f64::NAN;
m.v_old[(j, i)] = f64::NAN;
}
}
}
let ghost_u = |j: usize, i: usize| mask.u_kind(j, i) == FaceKind::Ghost;
let ghost_v = |j: usize, i: usize| mask.v_kind(j, i) == FaceKind::Ghost;
let active = |j: usize, i: usize| map.class(j, i) == CellClass::Active;
let mut out = MomentumResidual {
hole_ghosts: map.hole_p.len(),
ghost_faces: map.ghost_u.len() + map.ghost_v.len(),
..MomentumResidual::default()
};
let vol = dx * dy;
let mut prescribed = Vec::new();
// u faces (j, i), i = 1..nx: cells (j, i1) | (j, i).
for j in 0..ny {
for i in 1..nx {
let (w, e) = (map.class(j, i - 1), map.class(j, i));
if (w == CellClass::Active) != (e == CellClass::Active) {
out.interface_u += 1;
}
let bucket = if !ghost_u(j, i) {
// Stencil: u (j, i±1), (j±1, i); v (j, i1), (j, i), (j+1, i1), (j+1, i); p (j, i1), (j, i).
let near = !active(j, i - 1)
|| !active(j, i)
|| ghost_u(j, i - 1)
|| ghost_u(j, i + 1)
|| (j > 0 && ghost_u(j - 1, i))
|| (j + 1 < ny && ghost_u(j + 1, i))
|| ghost_v(j, i - 1)
|| ghost_v(j, i)
|| ghost_v(j + 1, i - 1)
|| ghost_v(j + 1, i);
if near {
&mut out.solved_near
} else {
&mut out.solved_far
}
} else {
match (w, e) {
(CellClass::Fringe, CellClass::Fringe) => &mut out.fringe_fringe,
(CellClass::Hole, CellClass::Hole) => {
out.hole_hole_skipped += 1;
continue;
}
_ => &mut out.fringe_hole,
}
};
let rhs = self.background.u_rhs(&m, j, i, t_old);
let r = rho * ((m.u[(j, i)] - m.u_old[(j, i)]) / dt - rhs) * vol;
bucket.add(r, true);
if ghost_u(j, i) {
prescribed.push(FaceResidual {
is_u: true,
j,
i,
r,
fringe_fringe: w == CellClass::Fringe && e == CellClass::Fringe,
});
}
}
}
// v faces (j, i), j = 1..ny: cells (j1, i) | (j, i).
for j in 1..ny {
for i in 0..nx {
let (s, n) = (map.class(j - 1, i), map.class(j, i));
if (s == CellClass::Active) != (n == CellClass::Active) {
out.interface_v += 1;
}
let bucket = if !ghost_v(j, i) {
let near = !active(j - 1, i)
|| !active(j, i)
|| ghost_v(j - 1, i)
|| ghost_v(j + 1, i)
|| (i > 0 && ghost_v(j, i - 1))
|| (i + 1 < nx && ghost_v(j, i + 1))
|| ghost_u(j - 1, i)
|| ghost_u(j, i)
|| ghost_u(j - 1, i + 1)
|| ghost_u(j, i + 1);
if near {
&mut out.solved_near
} else {
&mut out.solved_far
}
} else {
match (s, n) {
(CellClass::Fringe, CellClass::Fringe) => &mut out.fringe_fringe,
(CellClass::Hole, CellClass::Hole) => {
out.hole_hole_skipped += 1;
continue;
}
_ => &mut out.fringe_hole,
}
};
let rhs = self.background.v_rhs(&m, j, i, t_old);
let r = rho * ((m.v[(j, i)] - m.v_old[(j, i)]) / dt - rhs) * vol;
bucket.add(r, false);
if ghost_v(j, i) {
prescribed.push(FaceResidual {
is_u: false,
j,
i,
r,
fringe_fringe: s == CellClass::Fringe && n == CellClass::Fringe,
});
}
}
}
out.prescribed = prescribed;
out
}
}