Files
rustytorch/crates/specialized/rtx-cfd/tests/overset_cfd1.rs
T
Omar SobhandClaude Fable 5.1 6f9b0d43b2
CI / Format Check (push) Canceled after 0s
Performance Benchmarks / Run Benchmarks (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 / 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
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
Documentation / Build User Guide (push) Canceled after 0s
rtx-cfd: OversetPisoSolver::momentum_residual — the background predictor's own staggered stencil (u_rhs/v_rhs, factored out of the predictor bit-identically) evaluated on every face of a NaN-masked field; solved faces read rounding, the active–fringe interface reads the composite's pressure level offset δ·h (cancels in the sum), prescribed fringe–fringe / fringe–hole faces read the stamping's momentum injection; hole ghosts (p, u, v) from a band widened three rows into the hole make every ring face evaluable; overset_cfd1 prints the buckets, the ring x-bands and δ at the settled state; pin: residual vanishes on the solved faces
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01X2GmJXeQ2njUecEKiJZ1G2
2026-09-06 13:25:14 -07:00

510 lines
20 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-P4 (`docs/overset_metal_campaign.md` §2.2 P4, §5.11): TurekHron CFD1
//! (Re = 20, steady) on the OVERSET — the rigid harness's background
//! (`turek_hron_cfd.rs`: parabolic inflow, outlet, multigrid, upwind) with
//! the cylinderflag O-grid as a static patch (no-slip wall, line-implicit
//! across). Loads by the patch's wall stress (`surface_force`) and by the
//! background's control-volume momentum balance (the two-route rule).
//! Reference (FEATFLOW level 6): drag 14.2929, lift 1.11905. The embedded
//! staircase measured drag 15.71 (surface) / 15.62 (CV) at ny = 41 (+10%).
use rtx_cfd::mesh::patch_gen::cylinder_flag_patch;
use rtx_cfd::mesh::PatchSide;
use rtx_cfd::solvers::incompressible::{
AleBoundaries, CellClass, CurvilinearParameters, CurvilinearPisoSolver, EmbeddedParameters,
EmbeddedPisoSolver, FlowField, MomentumResidual, NormalDiffusion, OversetField,
OversetParameters, OversetPisoSolver, PatchConvection, PatchField, PoissonSolverKind,
SideBoundary,
};
use rtx_cfd::{CfdConfig, CfdResult};
const L: f64 = 2.5;
const H: f64 = 0.41;
const RHO: f64 = 1000.0;
const NU: f64 = 1e-3;
const U_MEAN: f64 = 0.2;
const REF_DRAG: f64 = 14.2929;
const REF_LIFT: f64 = 1.11905;
fn inflow(y: f64) -> f64 {
1.5 * U_MEAN * y * (H - y) / (0.5 * H).powi(2)
}
struct Cfd1 {
drag_surface: f64,
lift_surface: f64,
drag_cv: f64,
lift_cv: f64,
steps: usize,
seconds: f64,
rounds_mean: f64,
dt: f64,
residual: MomentumResidual,
}
async fn run_cfd1(ny: usize, max_steps: usize) -> CfdResult<Cfd1> {
let h = H / ny as f64;
let nx = (L / h).round() as usize;
let mu = RHO * NU;
let config = CfdConfig::new()
.with_density(RHO)
.with_viscosity(mu)
.with_reference_velocity(U_MEAN)
.with_reference_length(0.1);
let mut background = EmbeddedPisoSolver::new(
config.clone(),
EmbeddedParameters {
corrector_steps: 2,
tolerance: 1e-7,
boundaries: AleBoundaries {
left: SideBoundary::Velocity,
right: SideBoundary::PressureOutlet,
bottom: SideBoundary::Velocity,
top: SideBoundary::Velocity,
},
poisson_solver: PoissonSolverKind::Multigrid,
..EmbeddedParameters::default()
},
)?;
background.set_boundary_velocity(|x, y, _| {
if x <= 0.0 {
(inflow(y), 0.0)
} else {
(0.0, 0.0)
}
});
let (mesh, _) = cylinder_flag_patch(
[0.2, 0.2],
0.05,
0.01,
0.6,
h,
0.5 * 0.41 / 41.0,
6.0 * h,
12,
4.0,
500,
)?;
// The patch's explicit along-body diffusion limit (its wall row is
// line-implicit); the harness's combined criterion for the background.
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 u_peak = 1.5 * 1.5 * U_MEAN;
let dt_bg = 0.25 / (2.0 * u_peak / h + 4.0 * NU / (h * h));
let dt_patch = 0.4 * (hs * hs / (4.0 * NU)).min(hs / u_peak);
let dt = dt_bg.min(dt_patch);
// P4 step 2: `RTX_OVERSET_CFD1_TVD=1` puts the van Albada deferred
// correction on the patch (the background stays upwind, as recorded).
let convection = if std::env::var("RTX_OVERSET_CFD1_TVD").is_ok() {
PatchConvection::TvdVanAlbada
} else {
PatchConvection::Upwind
};
let mut patch = CurvilinearPisoSolver::new(
config,
CurvilinearParameters {
tolerance: 1e-5,
convection,
normal_diffusion: NormalDiffusion::LineImplicit,
..CurvilinearParameters::default()
},
mesh,
)?;
patch.set_side_velocity(PatchSide::Inner, |_, _, _| (0.0, 0.0));
let mut patch_field = PatchField::new(patch.mesh());
patch.initialize(&mut patch_field, |_, _| (0.0, 0.0));
let mut bg_field = FlowField::new(nx, ny, h, h)?;
for j in 0..ny {
let u0 = inflow((j as f64 + 0.5) * h);
for i in 0..=nx {
bg_field.u[(j, i)] = u0;
}
}
// A steady march: stop a corrector's rounds when two rounds make no
// progress (the noise floor); measured at ny = 41 without it: 5.0 rounds
// per corrector on average (the second corrector 8 every step), 2099 s.
let params = OversetParameters {
stall_rounds: std::env::var("RTX_OVERSET_STALL")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(2),
// Cost question (P4): does the second corrector's ~9 rounds buy a
// measurable load? `RTX_OVERSET_MAX_ROUNDS=3` caps every corrector.
max_rounds: std::env::var("RTX_OVERSET_MAX_ROUNDS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(OversetParameters::default().max_rounds),
..OversetParameters::default()
};
let mut solver = OversetPisoSolver::new(background, patch, (nx, ny, h, h), params)?;
let mut field = OversetField {
background: bg_field,
patch: patch_field,
};
solver.initialize(&mut field)?;
let cv = (
(0.10 / h).round() as usize,
(0.75 / h).round() as usize,
(0.05 / h).round() as usize,
(0.36 / h).round() as usize,
);
let cv_force = |field: &OversetField, solver: &OversetPisoSolver| {
solver
.background()
.mask()
.expect("mask")
.control_volume_force(
&field.background.u,
&field.background.v,
&field.background.p,
&field.background.u_old,
&field.background.v_old,
dt,
RHO,
mu,
None,
cv,
)
};
let start = std::time::Instant::now();
let flow_through = L / U_MEAN;
let min_steps = (flow_through / dt).ceil() as usize;
let mut history: Vec<f64> = Vec::new();
let mut steps = 0;
let mut rounds_total = 0usize;
let mut correctors_total = 0usize;
let trace_first = std::env::var("RTX_OVERSET_CFD1_TRACE").is_ok();
loop {
let r = solver.advance(&mut field, dt).await?;
steps += 1;
let every: usize = std::env::var("RTX_OVERSET_CFD1_TRACE_EVERY")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(0);
if (trace_first && steps <= 6) || (every > 0 && steps % every == 0) {
let pmax = field
.background
.p
.iter()
.fold(0.0_f64, |m, v| m.max(v.abs()));
let ppmax = field.patch.p.iter().fold(0.0_f64, |m, v| m.max(v.abs()));
let upmax = field.patch.u.iter().fold(0.0_f64, |m, v| m.max(v.abs()));
println!(
" step {steps}: rounds {:?} converged {} stalled {} bg res {:.2e} patch div {:.2e} patch iters {} conv {} | max|p| bg {pmax:.3e} patch {ppmax:.3e} max|u| patch {upmax:.3e} | defect bg {:.2e} patch {:.2e}",
r.rounds,
r.schwarz_converged,
r.schwarz_stalled,
r.background_residual,
r.patch_max_divergence,
r.patch_poisson_iterations,
r.patch_converged,
r.background_mass_defect,
r.patch_mass_defect
);
}
if steps >= max_steps {
break;
}
rounds_total += r.rounds.iter().sum::<usize>();
correctors_total += r.rounds.len();
if steps % 50 == 0 {
let (fx, _) = cv_force(&field, &solver);
history.push(fx);
let load = solver
.patch()
.surface_force(&field.patch, PatchSide::Inner, solver.time());
let umax = field
.background
.u
.iter()
.fold(0.0_f64, |m, v| m.max(v.abs()));
if steps % 500 == 0 || !umax.is_finite() {
println!(
" ny = {ny}: step {steps} t = {:.2} s drag_cv {fx:.4} drag_wall {:.4} lift_wall {:.4} max|u| {umax:.3} rounds {:?} bg res {:.1e} patch div {:.1e} [{:.0} s]",
solver.time(),
load.total()[0],
load.total()[1],
r.rounds,
r.background_residual,
r.patch_max_divergence,
start.elapsed().as_secs_f64()
);
}
assert!(
umax.is_finite(),
"velocity became non-finite at step {steps}"
);
if steps >= min_steps && history.len() > 4 {
let now = history[history.len() - 1];
let then = history[history.len() - 5];
if ((now - then) / now).abs() < 1e-4 {
break;
}
}
}
assert!(steps < 2_000_000, "CFD1 at ny = {ny} did not settle");
}
let seconds = start.elapsed().as_secs_f64();
let load = solver
.patch()
.surface_force(&field.patch, PatchSide::Inner, solver.time());
let (drag_cv, lift_cv) = cv_force(&field, &solver);
// P4 momentum-defect measurement: the force the background transmits
// into the ring (fringe + hole), into the hole alone, and the patch's
// wall force — consecutive differences are the active region's
// residual, the fringe ring's momentum defect, and the patch region's.
let ring = solver
.overlap()
.region_force(&field.background, RHO, mu, |c| c != CellClass::Active);
let hole = solver
.overlap()
.region_force(&field.background, RHO, mu, |c| c == CellClass::Hole);
let wall = load.total();
println!(
" momentum routes ny = {ny}: CV box ({drag_cv:.4}, {lift_cv:.4}) | ring outer ({:.4}, {:.4}) | hole boundary ({:.4}, {:.4}) | wall ({:.4}, {:.4}); defects [% of wall drag]: active {:+.2} fringe ring {:+.2} patch region {:+.2}",
ring.0,
ring.1,
hole.0,
hole.1,
wall[0],
wall[1],
100.0 * (drag_cv - ring.0) / wall[0],
100.0 * (ring.0 - hole.0) / wall[0],
100.0 * (hole.0 - wall[0]) / wall[0],
);
// P4 option B: the momentum residual of the solver's OWN staggered
// upwind stencil on every background face at the settled state. Solved
// faces read zero by construction (the pin below); the prescribed
// faces' sum is the momentum the stamping injects, in the solver's
// metric and without the staircase curves' face-formula error.
let mr = solver.momentum_residual(&field, dt);
let pct = |b: &rtx_cfd::solvers::incompressible::ResidualBucket| 100.0 * b.fx / wall[0];
println!(
" momentum residual ny = {ny} [N/m, x / y; % of wall drag; faces evaluated/total]: solved far Σr ({:+.3e}, {:+.3e}) Σ|r| ({:.3e}, {:.3e}) {}/{} | solved near ring Σr ({:+.3e}, {:+.3e}) Σ|r| ({:.4}, {:.4}) max|r| ({:.3e}, {:.3e}) {}/{} | fringefringe ({:+.4}, {:+.4}) {:+.2}% {}/{} | fringehole ({:+.4}, {:+.4}) {:+.2}% {}/{} | holehole skipped {} (ghosts: {} cells, {} faces) | Σ|r| fringefringe ({:.4}, {:.4}) fringehole ({:.4}, {:.4}); ring total ({:+.4}, {:+.4}) {:+.2}% vs routes' ring defect {:+.4} ({:+.2}%)",
mr.solved_far.fx, mr.solved_far.fy, mr.solved_far.abs_x, mr.solved_far.abs_y, mr.solved_far.evaluated, mr.solved_far.total,
mr.solved_near.fx, mr.solved_near.fy, mr.solved_near.abs_x, mr.solved_near.abs_y, mr.solved_near.max_abs_x, mr.solved_near.max_abs_y, mr.solved_near.evaluated, mr.solved_near.total,
mr.fringe_fringe.fx, mr.fringe_fringe.fy, pct(&mr.fringe_fringe), mr.fringe_fringe.evaluated, mr.fringe_fringe.total,
mr.fringe_hole.fx, mr.fringe_hole.fy, pct(&mr.fringe_hole), mr.fringe_hole.evaluated, mr.fringe_hole.total,
mr.hole_hole_skipped, mr.hole_ghosts, mr.ghost_faces,
mr.fringe_fringe.abs_x, mr.fringe_fringe.abs_y, mr.fringe_hole.abs_x, mr.fringe_hole.abs_y,
mr.fringe_fringe.fx + mr.fringe_hole.fx, mr.fringe_fringe.fy + mr.fringe_hole.fy,
pct(&mr.fringe_fringe) + pct(&mr.fringe_hole),
hole.0 - ring.0,
100.0 * (hole.0 - ring.0) / wall[0],
);
// Where along the ring: the prescribed u faces' x-momentum residual in
// x-bands (cylinder front, cylinderflag junction, flag, trailing edge).
let mut bands = [
(0.0_f64, 0.20, 0.0_f64, 0usize),
(0.20, 0.30, 0.0, 0),
(0.30, 0.55, 0.0, 0),
(0.55, 1.0, 0.0, 0),
];
for f in mr.prescribed.iter().filter(|f| f.is_u && f.r.is_finite()) {
let x = f.i as f64 * h;
if let Some(b) = bands.iter_mut().find(|b| x >= b.0 && x < b.1) {
b.2 += f.r;
b.3 += 1;
}
}
println!(
" ring x-momentum residual by x-band ny = {ny} (N/m, u faces): {}; level offset δ = {:.3e} Pa on {} + {} interface faces",
bands
.iter()
.map(|b| format!("x {:.2}{:.2}: {:+.4} ({} faces)", b.0, b.1, b.2, b.3))
.collect::<Vec<_>>()
.join(" | "),
mr.level_offset(h),
mr.interface_u,
mr.interface_v
);
// The wall load split and the fringe ring's extent (the tight box in
// the sensitivity list must stay outside it).
let (mut jmin, mut jmax, mut imin, mut imax) = (usize::MAX, 0, usize::MAX, 0);
for e in &solver.overlap().fringe_cells {
jmin = jmin.min(e.j);
jmax = jmax.max(e.j);
imin = imin.min(e.i);
imax = imax.max(e.i);
}
println!(
" wall load split: pressure ({:.4}, {:.4}) viscous ({:.4}, {:.4}); fringe ring cells i {imin}{imax} (x {:.3}{:.3}) j {jmin}{jmax} (y {:.3}{:.3})",
load.pressure[0],
load.pressure[1],
load.viscous[0],
load.viscous[1],
imin as f64 * h,
(imax + 1) as f64 * h,
jmin as f64 * h,
(jmax + 1) as f64 * h
);
// Box sensitivity of the control-volume route: the same balance on
// other rectangles, all outside the fringe ring (x 0.1100.640, y
// 0.1100.290 at ny = 41 — a first list had a box at x0 = 0.12 cutting
// through it and read 21 %). A route that moves with the box by more
// than its own formula error cannot arbitrate the gap.
for (x0, x1, y0, y1) in [
(0.10, 0.75, 0.05, 0.36),
(0.09, 0.70, 0.07, 0.34),
(0.08, 1.00, 0.03, 0.38),
(0.10, 1.50, 0.05, 0.36),
(0.10, 0.75, 0.02, 0.39),
] {
let boxc = (
(x0 / h).round() as usize,
(x1 / h).round() as usize,
(y0 / h).round() as usize,
(y1 / h).round() as usize,
);
let (bx, by) = solver
.background()
.mask()
.expect("mask")
.control_volume_force(
&field.background.u,
&field.background.v,
&field.background.p,
&field.background.u_old,
&field.background.v_old,
dt,
RHO,
mu,
None,
boxc,
);
println!(
" CV box x {x0:.2}{x1:.2} y {y0:.2}{y1:.2}: drag {bx:.4} ({:+.2}% vs wall) lift {by:.4}",
100.0 * (bx - wall[0]) / wall[0]
);
}
Ok(Cfd1 {
drag_surface: load.total()[0],
lift_surface: load.total()[1],
drag_cv,
lift_cv,
steps,
seconds,
rounds_mean: rounds_total as f64 / correctors_total.max(1) as f64,
dt,
residual: mr,
})
}
#[tokio::test]
async fn cfd1_on_the_overset_against_the_featflow_reference() -> CfdResult<()> {
let resolutions: Vec<usize> = std::env::var("RTX_OVERSET_CFD1_NY").ok().map_or_else(
|| vec![41usize],
|list| {
list.split(',')
.map(|t| t.trim().parse().expect("ny list"))
.collect()
},
);
for &ny in &resolutions {
let max_steps: usize = std::env::var("RTX_OVERSET_CFD1_MAX_STEPS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(2_000_000);
let r = run_cfd1(ny, max_steps).await?;
let rel = |a: f64, b: f64| 100.0 * (a - b) / b;
println!(
" CFD1 overset ny = {ny} (h = {:.4}, dt = {:.2e}, patch {}): wall drag {:.4} ({:+.2}%) lift {:.4} ({:+.2}%); control volume drag {:.4} ({:+.2}%) lift {:.4}; routes differ {:.2}%; [{} steps, {:.0} s, Schwarz rounds mean {:.2}] reference {REF_DRAG} / {REF_LIFT}; embedded staircase at ny=41: 15.71 / 15.62 (+10%)",
H / ny as f64,
r.dt,
if std::env::var("RTX_OVERSET_CFD1_TVD").is_ok() {
"tvd"
} else {
"upwind"
},
r.drag_surface,
rel(r.drag_surface, REF_DRAG),
r.lift_surface,
rel(r.lift_surface, REF_LIFT),
r.drag_cv,
rel(r.drag_cv, REF_DRAG),
r.lift_cv,
100.0 * ((r.drag_surface - r.drag_cv) / r.drag_cv).abs(),
r.steps,
r.seconds,
r.rounds_mean
);
assert!(r.drag_surface.is_finite() && r.drag_cv.is_finite());
}
Ok(())
}
/// The residual diagnostic is the solver's own operator: on every SOLVED
/// background face away from the ring the momentum residual (time term
/// included) is zero to rounding; on the solved faces NEXT to the ring it
/// is a constant per face that cancels in the sum — the composite's
/// pressure LEVEL offset between the active cells (whose `p'` had its mean
/// removed) and the fringe cells (re-stamped from the patch, which never
/// saw that shift); and the ring buckets are populated. This is what makes
/// the prescribed faces' sum readable as the stamping's momentum injection
/// in the solver's metric (§5.11, option B).
#[tokio::test]
async fn momentum_residual_vanishes_on_the_solved_faces() -> CfdResult<()> {
let r = run_cfd1(41, 5).await?;
let mr = &r.residual;
let scale = r.drag_surface.abs().max(1.0);
let far = &mr.solved_far;
assert_eq!(
far.evaluated, far.total,
"every far solved face is evaluable"
);
assert!(
far.abs_x <= 1e-9 * scale && far.abs_y <= 1e-9 * scale,
"solved far: Σ|r| = ({:.3e}, {:.3e}) is not rounding against {scale:.3}",
far.abs_x,
far.abs_y
);
let near = &mr.solved_near;
assert_eq!(
near.evaluated, near.total,
"every near solved face is evaluable"
);
assert!(
near.fx.abs() <= 1e-9 * scale && near.fy.abs() <= 1e-9 * scale,
"solved near: Σr = ({:.3e}, {:.3e}) does not cancel against {scale:.3}",
near.fx,
near.fy
);
// A pure level offset: every activefringe INTERFACE face carries the
// same |r| = δ·h and every other near face (one that only reads a
// prescribed velocity) reads zero, so Σ|r| = N_interface · max|r| on
// each lattice.
assert!(
(near.abs_x - mr.interface_u as f64 * near.max_abs_x).abs() <= 1e-6 * near.abs_x.max(1e-300)
&& (near.abs_y - mr.interface_v as f64 * near.max_abs_y).abs()
<= 1e-6 * near.abs_y.max(1e-300),
"solved near: not a uniform level offset on the interface — Σ|r| ({:.4e}, {:.4e}) vs N·max|r| ({:.4e}, {:.4e}) with N = ({}, {})",
near.abs_x,
near.abs_y,
mr.interface_u as f64 * near.max_abs_x,
mr.interface_v as f64 * near.max_abs_y,
mr.interface_u,
mr.interface_v
);
assert!(mr.fringe_fringe.evaluated > 0 && mr.fringe_hole.evaluated > 0);
assert_eq!(
mr.fringe_fringe.evaluated, mr.fringe_fringe.total,
"every fringefringe face has a fully valid stencil"
);
assert_eq!(
mr.fringe_hole.evaluated, mr.fringe_hole.total,
"every fringehole face has a fully valid stencil with the ghost band"
);
Ok(())
}