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,240 @@
//! A-P2, step S5 (`docs/overset_metal_campaign.md` §5.9): the overset with a
//! MOVING patch.
//!
//! 1. A stationary patch pushed through `set_patch_mesh` every step is the
//! static overset path to the bit (the overlap rebuild and the fringe
//! re-stamping reproduce the same numbers).
//! 2. The phantom circle and its patch translating through the steady
//! manufactured field (the `embedded_moving.rs` pattern: the wall carries
//! the exact velocity, so the exact solution is unchanged while the
//! hole, fringe and acceptors sweep the background) keep the time-max
//! L2 on both meshes within 1.5× the static level; reclassification
//! counts are printed.
//! 3. Snapshot/restore with a pending patch mesh re-runs bit-identically.
mod overset_common;
use overset_common::{build, errors, p2_patch};
use rtx_cfd::CfdResult;
use rtx_cfd::mesh::PatchMesh;
use rtx_cfd::mesh::patch_gen::annulus_skewed;
use rtx_cfd::solvers::incompressible::OversetField;
fn fields_identical(a: &OversetField, b: &OversetField) -> bool {
let bits = |v: &[f64], w: &[f64]| v.iter().zip(w).all(|(x, y)| x.to_bits() == y.to_bits());
bits(a.background.u.as_slice(), b.background.u.as_slice())
&& bits(a.background.v.as_slice(), b.background.v.as_slice())
&& bits(a.background.p.as_slice(), b.background.p.as_slice())
&& bits(&a.patch.u, &b.patch.u)
&& bits(&a.patch.v, &b.patch.v)
&& bits(&a.patch.p, &b.patch.p)
&& bits(&a.patch.flux, &b.patch.flux)
}
fn max_diff(a: &OversetField, b: &OversetField) -> f64 {
let d = |v: &[f64], w: &[f64]| {
v.iter()
.zip(w)
.map(|(x, y)| (x - y).abs())
.fold(0.0, f64::max)
};
d(a.background.u.as_slice(), b.background.u.as_slice())
.max(d(a.background.v.as_slice(), b.background.v.as_slice()))
.max(d(a.background.p.as_slice(), b.background.p.as_slice()))
.max(d(&a.patch.u, &b.patch.u))
.max(d(&a.patch.v, &b.patch.v))
.max(d(&a.patch.p, &b.patch.p))
.max(d(&a.patch.flux, &b.patch.flux))
}
/// The P2 patch translated to centre `(cx, cy)`.
fn translated_patch(n: usize, cx: f64, cy: f64) -> CfdResult<PatchMesh> {
annulus_skewed([cx, cy], 0.2, 0.354, 9 * n / 4, n / 4, 0.3, 3.0)
}
fn time_step(solver: &rtx_cfd::solvers::incompressible::OversetPisoSolver, n: usize) -> f64 {
let h = 1.0 / n as f64;
let mut hp = f64::INFINITY;
for c in 0..solver.patch().mesh().cell_count() {
for (f, _) in solver.patch().mesh().cell_faces(c) {
let d = solver.patch().mesh().faces()[f].d;
hp = hp.min((d[0] * d[0] + d[1] * d[1]).sqrt());
}
}
0.4 * (hp * hp / (4.0 * 0.05)).min(h)
}
#[tokio::test]
async fn stationary_patch_through_the_moving_path_is_bit_identical() -> CfdResult<()> {
let n = 32;
let (mut plain, mut f_plain) = build(n, p2_patch(n)?, 1e-3)?;
let (mut moving, mut f_moving) = build(n, p2_patch(n)?, 1e-3)?;
let dt = time_step(&plain, n);
for _ in 0..20 {
plain.advance(&mut f_plain, dt).await?;
let same = moving.patch().mesh().clone();
moving.set_patch_mesh(same)?;
let r = moving.advance(&mut f_moving, dt).await?;
assert_eq!(r.reclassified_cells, 0);
}
assert_eq!(plain.time().to_bits(), moving.time().to_bits());
assert!(
fields_identical(&f_plain, &f_moving),
"moving path with a stationary patch differs from the static path by {:.3e}",
max_diff(&f_plain, &f_moving)
);
println!(" stationary patch through the moving overset path — bit-identical over 20 steps");
Ok(())
}
#[tokio::test]
async fn translating_phantom_circle_keeps_the_static_error_level() -> CfdResult<()> {
let n: usize = std::env::var("RTX_OVERSET_N")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(32);
let h = 1.0 / n as f64;
let (mut solver, mut field) = build(n, p2_patch(n)?, 1e-3)?;
let dt = time_step(&solver, n);
// Static phase to the steady state.
let mut steady = f64::INFINITY;
for _ in 0..400_000 {
let before = (field.background.u.clone(), field.patch.u.clone());
solver.advance(&mut field, dt).await?;
let change = (&field.background.u - &before.0).abs().max().max(
field
.patch
.u
.iter()
.zip(&before.1)
.map(|(a, b)| (a - b).abs())
.fold(0.0, f64::max),
);
steady = change / dt;
if steady < 1e-6 {
break;
}
}
assert!(steady < 1e-6, "no static steady state: {steady:.3e}");
let (static_bg, static_patch) = errors(&solver, &field, n);
println!(" static level n = {n}: L2 background {static_bg:.4e}, patch {static_patch:.4e}");
// Moving phase: the circle and its patch translate at speed 0.3 to the
// left through ~4.5 background cells (per step ≤ 0.01 h).
let speed = 0.3;
let t0 = solver.time();
let distance = 4.5 * h;
let steps = (distance / (speed * dt)).ceil() as usize;
let (mut worst_bg, mut worst_patch) = (0.0_f64, 0.0_f64);
let (mut reclassified, mut fresh) = (0usize, 0usize);
let mut max_rounds = 0usize;
for step in 0..steps {
let t_new = solver.time() + dt;
let cx = 0.6 - speed * (t_new - t0);
solver.set_patch_mesh(translated_patch(n, cx, 0.45)?)?;
let r = solver.advance(&mut field, dt).await?;
reclassified += r.reclassified_cells;
fresh += r.fresh_cells;
max_rounds = max_rounds.max(r.rounds.iter().copied().max().unwrap_or(0));
let (eb, ep) = errors(&solver, &field, n);
worst_bg = worst_bg.max(eb);
worst_patch = worst_patch.max(ep);
if std::env::var("RTX_OVERSET_TRACE").is_ok() && step % 200 == 0 {
println!(
" moving step {step}: cx {cx:.4} L2 bg {eb:.4e} patch {ep:.4e} reclassified {} fresh {} rounds {:?} defect bg {:.2e}",
r.reclassified_cells,
r.fresh_cells,
r.rounds,
r.background_mass_defect / r.overlap_flux_scale.max(1e-300)
);
}
}
println!(
" moving n = {n}: {steps} steps, {distance:.3} travelled; time-max L2 background {worst_bg:.4e} ({:.2}x static), \
patch {worst_patch:.4e} ({:.2}x static); reclassified {reclassified} cells, fresh {fresh}; max Schwarz rounds {max_rounds}",
worst_bg / static_bg,
worst_patch / static_patch
);
assert!(
worst_bg < 1.5 * static_bg,
"background error rose to {worst_bg:.3e} (static {static_bg:.3e})"
);
assert!(
worst_patch < 1.5 * static_patch,
"patch error rose to {worst_patch:.3e} (static {static_patch:.3e})"
);
Ok(())
}
#[tokio::test]
async fn snapshot_restore_with_a_pending_mesh_is_bit_identical() -> CfdResult<()> {
let n = 32;
let (mut solver, mut field) = build(n, p2_patch(n)?, 1e-3)?;
let dt = time_step(&solver, n);
let speed = 0.3;
let mut step = 0usize;
let cx = |s: usize| 0.6 - speed * s as f64 * dt;
for _ in 0..5 {
step += 1;
solver.set_patch_mesh(translated_patch(n, cx(step), 0.45)?)?;
solver.advance(&mut field, dt).await?;
}
solver.set_patch_mesh(translated_patch(n, cx(step + 1), 0.45)?)?;
let saved = solver.snapshot();
let field_saved = field.clone();
let step_saved = step;
for _ in 0..10 {
step += 1;
if step > step_saved + 1 {
solver.set_patch_mesh(translated_patch(n, cx(step), 0.45)?)?;
}
solver.advance(&mut field, dt).await?;
}
let reference = field.clone();
let t_ref = solver.time();
solver.restore(&saved);
field = field_saved;
step = step_saved;
for _ in 0..10 {
step += 1;
if step > step_saved + 1 {
solver.set_patch_mesh(translated_patch(n, cx(step), 0.45)?)?;
}
solver.advance(&mut field, dt).await?;
}
assert_eq!(solver.time().to_bits(), t_ref.to_bits());
assert!(
fields_identical(&field, &reference),
"re-run on the moving overset differs by {:.3e}",
max_diff(&field, &reference)
);
println!(" snapshot/restore with a pending patch mesh — bit-identical re-run over 10 steps");
Ok(())
}
/// One step with the patch jumped by two background cells must reclassify
/// background cells (the overlap follows the pending mesh).
#[tokio::test]
async fn a_translated_patch_reclassifies_the_background() -> CfdResult<()> {
let n = 32;
let (mut solver, mut field) = build(n, p2_patch(n)?, 1e-3)?;
let dt = time_step(&solver, n);
solver.advance(&mut field, dt).await?;
let before = solver.overlap().hole_cells();
solver.set_patch_mesh(translated_patch(n, 0.6 - 2.0 / n as f64, 0.45)?)?;
let r = solver.advance(&mut field, dt).await?;
println!(
" 2 h jump: reclassified {}, fresh {}, holes {} -> {}",
r.reclassified_cells,
r.fresh_cells,
before,
solver.overlap().hole_cells()
);
assert!(
r.reclassified_cells > 0,
"the overlap did not follow the patch"
);
Ok(())
}