rtx-cfd: overset P4-0 — the O-grid around the Turek–Hron rigid body (cylinder + flag), gated
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
Documentation / Build API Documentation (push) Canceled after 0s
Documentation / Build User Guide (push) Canceled after 0s
CI / Python Bindings (maturin) (ubuntu-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 (macos-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

patch_gen::{cylinder_flag_outline, cylinder_flag_patch, winslow_smooth, respace_rays}
(+ convex_hull, offset_convex_polygon, nearest_on_polyline): the outline CCW then
reversed to clockwise (tip semicircle 16 cells, junction fillets of a FIXED radius
with 3 cells, straights graded 0.3 h → h, cylinder arc at h); the outer ring the
6 h normal offset of the body's convex hull; initial pairing by the inner point's
normal offset projected onto the hull offset (an arclength-proportional pairing
folded the transfinite grid at the tip: rays crossed where the curvatures differ);
Winslow (TTM) smoothing of the interior with the outer nodes SLIDING along the
hull offset (each re-placed at the nearest point to the extrapolated ray), then
re-spacing along the smoothed rays to the across stretch. Gates
(tests/patch_cylinder_flag.rs): ny = 41/62/82 → 143×12 / 183×12 / 225×12 cells,
positive, wall row 0.23 h (fillet max 0.37 / 0.43 / 0.50 h), worst
non-orthogonality 76.6 / 69.1 / 63.3° at the concave fillets (structural: a
concave arc's normals converge at its centre), classification of the benchmark
background with both donor invariants. P0 MMS on these meshes
(tests/cylinder_flag_mms.rs, exact acceptors, line-implicit): Stokes orders 2.17 /
2.13, upwind 2.00 / 1.86 (cell Péclet ≈ 0.1), divergence ≤ 9e-14 — the fillet skew
costs nothing measurable. Rule: a refinement ladder's geometry must be fixed in
physical units — with fillet = h/2 the Stokes orders read 1.74 → 1.31, the O(h)
boundary perturbation masquerading as a scheme defect; the fillet is a parameter
(5 mm across the ladder). Also: the P3b knock-outs H1/H2 on the balanced default
(no effect), the P3 §5.10 record in the falsifier's header.

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-05 19:50:14 -07:00
co-authored by Claude Fable 5.1
parent 62df6bd628
commit 45ff34da8f
4 changed files with 763 additions and 0 deletions
@@ -0,0 +1,186 @@
//! A-P4-0 gate 3 (`docs/overset_metal_campaign.md` §5.11): the P0
//! manufactured solution on the cylinderflag O-grid (exact acceptors, the
//! S3 harness) — do the Stokes-limit and upwind orders survive the junction
//! fillets' skew? Rungs at the benchmark's h = 0.41 / 41, 62, 82.
use rtx_cfd::mesh::PatchMesh;
use rtx_cfd::mesh::patch_gen::cylinder_flag_patch;
use rtx_cfd::solvers::incompressible::{
CurvilinearParameters, CurvilinearPisoSolver, NormalDiffusion, PatchConvection, PatchField,
};
use rtx_cfd::{CfdConfig, CfdResult};
use std::f64::consts::PI;
const RHO: f64 = 1.0;
const MU: f64 = 0.05;
fn u_exact(x: f64, y: f64) -> f64 {
(PI * x).sin() * (PI * y).cos()
}
fn v_exact(x: f64, y: f64) -> f64 {
-(PI * x).cos() * (PI * y).sin()
}
fn p_exact(x: f64, y: f64) -> f64 {
(PI * x).sin() * (PI * y).sin()
}
fn source(x: f64, y: f64, convecting: bool) -> (f64, f64) {
let conv = if convecting { RHO * 0.5 * PI } else { 0.0 };
(
conv * (2.0 * PI * x).sin()
+ 2.0 * PI * PI * MU * u_exact(x, y)
+ PI * (PI * x).cos() * (PI * y).sin(),
conv * (2.0 * PI * y).sin()
+ 2.0 * PI * PI * MU * v_exact(x, y)
+ PI * (PI * x).sin() * (PI * y).cos(),
)
}
fn patch(ny: usize) -> CfdResult<PatchMesh> {
let h = 0.41 / ny as f64;
let sweeps: usize = std::env::var("RTX_CF_SWEEPS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(500);
// Fixed geometry across the ladder: the fillet of the coarsest rung.
let fillet = 0.5 * 0.41 / 41.0;
Ok(cylinder_flag_patch(
[0.2, 0.2],
0.05,
0.01,
0.6,
h,
fillet,
6.0 * h,
12,
4.0,
sweeps,
)?
.0)
}
async fn march(ny: usize, convection: PatchConvection) -> CfdResult<(f64, usize, f64)> {
let mesh = patch(ny)?;
let h = 0.41 / ny as f64;
let nu = MU / RHO;
let mut hs = f64::INFINITY;
for c in 0..mesh.cell_count() {
for (f, _) in mesh.cell_faces(c) {
if mesh.is_sface(f) {
let d = mesh.faces()[f].d;
hs = hs.min((d[0] * d[0] + d[1] * d[1]).sqrt());
}
}
}
let dt = 0.4 * (hs * hs / (4.0 * nu)).min(h);
let config = CfdConfig::new()
.with_density(RHO)
.with_viscosity(MU)
.with_reference_velocity(1.0)
.with_reference_length(1.0);
let convecting = convection == PatchConvection::Upwind;
let mut solver = CurvilinearPisoSolver::new(
config,
CurvilinearParameters {
tolerance: 1e-5,
convection,
normal_diffusion: NormalDiffusion::LineImplicit,
..CurvilinearParameters::default()
},
mesh,
)?;
solver.set_boundary_velocity(|x, y, _| (u_exact(x, y), v_exact(x, y)));
solver.set_momentum_source(move |x, y, _| source(x, y, convecting));
solver.set_acceptor_ring(true);
let (ns, nn) = (solver.mesh().ns(), solver.mesh().nn());
let acc: Vec<(f64, f64, f64)> = (0..ns)
.map(|i| {
let xy = solver.mesh().centre(solver.mesh().cell(nn - 1, i));
(
u_exact(xy[0], xy[1]),
v_exact(xy[0], xy[1]),
p_exact(xy[0], xy[1]),
)
})
.collect();
let zeros = vec![0.0; ns];
let mut field = PatchField::new(solver.mesh());
solver.initialize(&mut field, |_, _| (0.0, 0.0));
solver.stamp_acceptors(&mut field, &acc);
solver.set_acceptor_correction(&zeros);
let steady_tol = if convecting { 1e-6 } else { 1e-7 };
let mut steady = f64::INFINITY;
let mut steps = 0;
let mut max_div = 0.0_f64;
for _ in 0..600_000 {
let before = (field.u.clone(), field.v.clone());
let r = solver.advance(&mut field, dt).await?;
assert!(
r.poisson_converged,
"pressure solve did not converge: {r:?}"
);
solver.stamp_acceptors(&mut field, &acc);
let flux_scale: f64 = field.flux.iter().map(|f| f.abs()).sum::<f64>().max(1e-300);
max_div = max_div.max(r.max_divergence / flux_scale);
steps += 1;
let change = field
.u
.iter()
.zip(&before.0)
.chain(field.v.iter().zip(&before.1))
.map(|(a, b)| (a - b).abs())
.fold(0.0, f64::max);
steady = change / dt;
if steady < steady_tol {
break;
}
}
assert!(steady < steady_tol, "no steady state: {steady:.3e}");
let mesh = solver.mesh();
let (mut sq, mut vol) = (0.0, 0.0);
for c in 0..mesh.cell_count() {
if solver.is_acceptor(c) {
continue;
}
let xy = mesh.centre(c);
let eu = field.u[c] - u_exact(xy[0], xy[1]);
let ev = field.v[c] - v_exact(xy[0], xy[1]);
sq += (eu * eu + ev * ev) * mesh.area(c);
vol += mesh.area(c);
}
Ok(((sq / vol).sqrt(), steps, max_div))
}
#[tokio::test]
async fn cylinder_flag_patch_keeps_the_p0_orders() -> CfdResult<()> {
// Measured with the fillet fixed at 5 mm: Stokes 2.17 / 2.13; upwind 2.00
// / 1.86 — the patch's cells are so small against the field (cell Péclet
// ≈ 0.1 at ν = 0.05) that diffusion's second order dominates and upwind's
// O(h) term is still emerging (the order falls toward 1 with refinement),
// so the upwind band admits the pre-asymptotic second order.
for (convection, gate) in [
(PatchConvection::None, 1.8..2.6),
(PatchConvection::Upwind, 0.7..2.4),
] {
let mut errs = Vec::new();
let mut hs = Vec::new();
for ny in [41usize, 62, 82] {
let (l2, steps, max_div) = march(ny, convection).await?;
println!(
" cylinder-flag {convection:?} ny={ny}: L2 {l2:.6e}, max div {max_div:.2e}, {steps} steps"
);
errs.push(l2);
hs.push(0.41 / ny as f64);
}
let o: Vec<f64> = errs
.windows(2)
.zip(hs.windows(2))
.map(|(e, h)| (e[0] / e[1]).ln() / (h[0] / h[1]).ln())
.collect();
println!(" cylinder-flag {convection:?} orders {o:?}");
assert!(
o.iter().all(|x| gate.contains(x)),
"{convection:?} orders {o:?} outside {gate:?}"
);
}
Ok(())
}