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
280 lines
10 KiB
Rust
280 lines
10 KiB
Rust
//! 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(())
|
||
}
|