Files
rustytorch/crates/specialized/rtx-cfd/tests/overset_moving.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

241 lines
9.1 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 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(())
}