Files
rustytorch/crates/specialized/rtx-cfd/tests/overset_interp.rs
T
Omar SobhandClaude Fable 5.1 afd1bff6ee
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
rtx-cfd: overset A-P2 — the patch overlaps the background (OversetPisoSolver), gated S1–S5
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
2026-09-04 19:24:13 -07:00

280 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! A-P2, step S2 (`docs/overset_metal_campaign.md` §5.9): the overlap map
//! alone. Classification of the background against the P2 MMS geometry
//! (unit square, phantom circle at (0.6, 0.45) r = 0.2, annulus to
//! r = 0.32) with the donor invariants; linear fields reproduced to
//! rounding in both directions; quadratic fields at second order under
//! refinement; the inverse bilinear on every dual quad of the skewed annulus.
use rtx_cfd::CfdResult;
use rtx_cfd::mesh::PatchMesh;
use rtx_cfd::mesh::patch_gen::annulus_skewed;
use rtx_cfd::solvers::incompressible::overset::overlap::{DEFAULT_OVERLAP_ROWS, inverse_bilinear};
use rtx_cfd::solvers::incompressible::{CellClass, FlowField, OverlapMap};
const CX: f64 = 0.6;
const CY: f64 = 0.45;
const R0: f64 = 0.2;
/// Chosen so the OUTER patch spacing is exactly h at `nn = n/4` with the
/// 3× geometric stretch: span = h · total(nn)/3 = 4.92 h at n = 32.
const R1: f64 = 0.354;
/// The P2 patch at background resolution `n`: `ns = 9n/4` (arc ≈ h at the
/// outer ring), `nn = n/4`, skew 0.3, stretch 3.
fn patch(n: usize) -> CfdResult<PatchMesh> {
annulus_skewed([CX, CY], R0, R1, 9 * n / 4, n / 4, 0.3, 3.0)
}
fn overlap(n: usize) -> CfdResult<(OverlapMap, PatchMesh)> {
let p = patch(n)?;
let h = 1.0 / n as f64;
let map = OverlapMap::build(&p, n, n, h, h, DEFAULT_OVERLAP_ROWS)?;
Ok((map, p))
}
fn lattice_field(n: usize, f: &dyn Fn(f64, f64) -> f64) -> FlowField {
let h = 1.0 / n as f64;
let mut field = FlowField::new(n, n, h, h).unwrap();
for j in 0..n {
for i in 0..=n {
field.u[(j, i)] = f(i as f64 * h, (j as f64 + 0.5) * h);
}
}
for j in 0..=n {
for i in 0..n {
field.v[(j, i)] = f((i as f64 + 0.5) * h, j as f64 * h);
}
}
for j in 0..n {
for i in 0..n {
field.p[(j, i)] = f((i as f64 + 0.5) * h, (j as f64 + 0.5) * h);
}
}
field
}
fn patch_field(p: &PatchMesh, f: &dyn Fn(f64, f64) -> f64) -> Vec<f64> {
(0..p.cell_count())
.map(|c| {
let xy = p.centre(c);
f(xy[0], xy[1])
})
.collect()
}
/// Largest interpolation error of `f` at the fringe cells, fringe faces and
/// acceptors.
fn interpolation_errors(n: usize, f: &dyn Fn(f64, f64) -> f64) -> CfdResult<(f64, f64)> {
let (map, p) = overlap(n)?;
let h = 1.0 / n as f64;
let vals = patch_field(&p, f);
let cells = map.fringe_cell_values(&vals);
let mut worst_fringe = 0.0_f64;
for (e, v) in map.fringe_cells.iter().zip(&cells) {
let exact = f((e.i as f64 + 0.5) * h, (e.j as f64 + 0.5) * h);
worst_fringe = worst_fringe.max((v - exact).abs());
}
let mut field = FlowField::new(n, n, h, h).unwrap();
map.stamp_fringe_faces(&mut field, &vals, &vals);
for e in &map.fringe_u {
let exact = f(e.i as f64 * h, (e.j as f64 + 0.5) * h);
worst_fringe = worst_fringe.max((field.u[(e.j, e.i)] - exact).abs());
}
for e in &map.fringe_v {
let exact = f((e.i as f64 + 0.5) * h, e.j as f64 * h);
worst_fringe = worst_fringe.max((field.v[(e.j, e.i)] - exact).abs());
}
let bg = lattice_field(n, f);
let acc = map.acceptor_values(&bg, &bg.p);
let mut worst_acc = 0.0_f64;
for (a, (u, v, pp)) in map.acceptors.iter().zip(&acc) {
let xy = p.centre(a.cell);
let exact = f(xy[0], xy[1]);
worst_acc = worst_acc.max(
(u - exact)
.abs()
.max((v - exact).abs())
.max((pp - exact).abs()),
);
}
Ok((worst_fringe, worst_acc))
}
#[test]
fn classification_on_the_mms_geometry_holds_the_donor_invariants() -> CfdResult<()> {
for n in [32usize, 64, 128] {
let (map, p) = overlap(n)?;
let h = 1.0 / n as f64;
let mut counts = [0usize; 3];
for j in 0..n {
for i in 0..n {
counts[match map.class(j, i) {
CellClass::Active => 0,
CellClass::Fringe => 1,
CellClass::Hole => 2,
}] += 1;
}
}
println!(
" n = {n} (patch {}x{}): active {}, fringe {}, hole {}; prescribed u {} v {}; acceptors {}; donor rows {:?}",
p.ns(),
p.nn(),
counts[0],
counts[1],
counts[2],
map.fringe_u.len(),
map.fringe_v.len(),
map.acceptors.len(),
map.donor_rows
);
// The hole covers the body and the inner part of the patch: more
// than the circle's area, less than the patch's outer disc.
let circle = std::f64::consts::PI * R0 * R0 / (h * h);
let disc = std::f64::consts::PI * R1 * R1 / (h * h);
assert!(
(counts[2] as f64) > circle && (counts[2] as f64) < disc,
"hole count {} outside ({circle:.0}, {disc:.0})",
counts[2]
);
assert!(counts[1] > 0 && map.acceptors.len() == p.ns());
// Every fringe cell has all its non-active-side faces prescribed.
for e in &map.fringe_cells {
let (j, i) = (e.j, e.i);
for (jj, ii, is_u) in [
(j, i, true),
(j, i + 1, true),
(j, i, false),
(j + 1, i, false),
] {
let other_active = if is_u {
let left = ii > 0 && map.class(jj, ii - 1) == CellClass::Active;
let right = ii < n && map.class(jj, ii) == CellClass::Active;
left || right
} else {
let below = jj > 0 && map.class(jj - 1, ii) == CellClass::Active;
let above = jj < n && map.class(jj, ii) == CellClass::Active;
below || above
};
if !other_active {
let listed = if is_u {
map.fringe_u.iter().any(|f| f.j == jj && f.i == ii)
} else {
map.fringe_v.iter().any(|f| f.j == jj && f.i == ii)
};
assert!(
listed,
"fringe cell ({j}, {i}) face ({jj}, {ii}, u = {is_u}) not prescribed"
);
}
}
}
}
Ok(())
}
#[test]
fn linear_fields_are_reproduced_to_rounding_in_both_directions() -> CfdResult<()> {
let lin = |x: f64, y: f64| 0.3 + 1.7 * x - 0.9 * y;
for n in [32usize, 64] {
let (fringe, acc) = interpolation_errors(n, &lin)?;
println!(" n = {n}: linear field — fringe {fringe:.3e}, acceptors {acc:.3e}");
assert!(
fringe < 1e-13,
"fringe interpolation not linear-exact: {fringe:.3e}"
);
assert!(
acc < 1e-13,
"acceptor interpolation not linear-exact: {acc:.3e}"
);
}
Ok(())
}
#[test]
fn quadratic_fields_are_interpolated_at_second_order() -> CfdResult<()> {
let quad = |x: f64, y: f64| x * x + x * y - y * y;
let mut fringe = Vec::new();
let mut acc = Vec::new();
for n in [32usize, 64, 128] {
let (f, a) = interpolation_errors(n, &quad)?;
println!(" n = {n}: quadratic field — fringe {f:.3e}, acceptors {a:.3e}");
fringe.push(f);
acc.push(a);
}
let order = |e: &[f64]| -> Vec<f64> { e.windows(2).map(|w| (w[0] / w[1]).log2()).collect() };
let (of, oa) = (order(&fringe), order(&acc));
println!(" orders: fringe {of:?}, acceptors {oa:?}");
// Measured: fringe 1.40 / 1.91, acceptors 1.96 / 1.99. The fringe's
// first rung is pre-asymptotic — at nn = 8 the dual quads of the 3×
// stretched annulus are markedly non-parallelogram (per-row ratio
// 1.17 against 1.08 at nn = 16), which sets the bilinear map's error
// constant; the asymptotic pair is second order.
assert!(
of.iter().all(|&o| o > 1.0) && of.last().is_some_and(|&o| o > 1.8),
"fringe orders {of:?}"
);
assert!(oa.iter().all(|&o| o > 1.8), "acceptor orders {oa:?}");
Ok(())
}
#[test]
fn inverse_bilinear_converges_on_every_dual_quad_of_the_skewed_annulus() -> CfdResult<()> {
let p = patch(64)?;
let (ns, nn) = (p.ns(), p.nn());
let mut worst = 0.0_f64;
for k in 0..nn - 1 {
for i in 0..ns {
let i1 = (i + 1) % ns;
let q = [
p.centre(p.cell(k, i)),
p.centre(p.cell(k, i1)),
p.centre(p.cell(k + 1, i1)),
p.centre(p.cell(k + 1, i)),
];
for (s, t) in [(0.5, 0.5), (0.1, 0.9), (0.85, 0.2), (0.01, 0.01)] {
let n = [(1.0 - s) * (1.0 - t), s * (1.0 - t), s * t, (1.0 - s) * t];
let x = (0..4).map(|a| n[a] * q[a][0]).sum::<f64>();
let y = (0..4).map(|a| n[a] * q[a][1]).sum::<f64>();
let w = inverse_bilinear(&q, x, y).unwrap_or_else(|| {
panic!("no convergence in dual quad ({k}, {i}) at ({s}, {t})")
});
for a in 0..4 {
worst = worst.max((w[a] - n[a]).abs());
}
}
}
}
println!(" inverse bilinear: worst weight error over every dual quad {worst:.3e}");
assert!(worst < 1e-12);
Ok(())
}
/// The classification must follow a translating patch: cells flip as the
/// hole boundary sweeps over their centres.
#[test]
fn classification_follows_a_translating_patch() -> CfdResult<()> {
let n = 32;
let h = 1.0 / n as f64;
let at = |cx: f64| -> CfdResult<OverlapMap> {
let p = annulus_skewed([cx, CY], R0, R1, 9 * n / 4, n / 4, 0.3, 3.0)?;
OverlapMap::build(&p, n, n, h, h, DEFAULT_OVERLAP_ROWS)
};
let base = at(0.6)?;
for cx in [0.6 - 0.25 * h, 0.6 - 0.8 * h, 0.6 - 2.0 * h] {
let moved = at(cx)?;
let changed = (0..n)
.flat_map(|j| (0..n).map(move |i| (j, i)))
.filter(|&(j, i)| base.class(j, i) != moved.class(j, i))
.count();
println!(
" patch translated by {:.2} h: {changed} background cells reclassified",
(0.6 - cx) / h
);
assert!(
changed > 0,
"no cell reclassified after a {:.2} h translation",
(0.6 - cx) / h
);
}
Ok(())
}