Files
rustytorch/crates/specialized/rtx-cfd/tests/curvilinear_mms.rs
T
Omar SobhandClaude Fable 5.1 96a7f1c700
CI / Build (macos-latest) (push) Waiting to run
CI / Test (macos-latest) (push) Blocked by required conditions
CI / Test (ubuntu-latest) (push) Blocked by required conditions
CI / Python Bindings (maturin) (macos-latest) (push) Blocked by required conditions
CI / Python Bindings (maturin) (ubuntu-latest) (push) Blocked by required conditions
CI / WASM Build + Size Check (push) Blocked by required conditions
CI / Distributed Training Tests (push) Blocked by required conditions
CI / CI Success (push) Blocked by required conditions
Performance Benchmarks / Run Benchmarks (push) Failing after 6s
CI / Format Check (push) Failing after 6s
CI / Clippy Check (push) Failing after 7s
CI / Build (ubuntu-latest) (push) Failing after 7s
Documentation / Build User Guide (push) Successful in 5s
CI / Build CPU-Only (Explicit) (push) Failing after 1m2s
Documentation / Build API Documentation (push) Failing after 1m4s
rtx-cfd: Robin wall sign pin — a still closed annulus under a uniform pressure above a zero datum: the explicit offset recedes into the body at exactly −p₀/(α(1+g)) (3e-16), the implicit compliant term answers with a pressure DROP (mean 0.17 of p₀, max 0.36) that absorbs the recession (net wall flux 6e-19 of 0.51); the steady MMS pin is blind to the sign of both parts
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YJPeT6WA2e7YvAnS875AHL
2026-09-15 11:17:38 -05:00

528 lines
19 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.
//! P0 gates 1, 2, 4 (`docs/overset_metal_campaign.md` §5.3): the
//! curvilinear collocated PISO on a Cartesian patch reproduces the
//! staggered PISO's manufactured-solution order and error to within 2×
//! (not bit-identical — a different discretisation); on a skewed,
//! stretched, periodic annulus it reaches order ≥ 1.8 in the Stokes limit
//! and ≈ 1 with upwind; every step is divergence-free to the solver's
//! tolerance.
use rtx_cfd::mesh::PatchMesh;
use rtx_cfd::mesh::PatchSide;
use rtx_cfd::mesh::patch_gen::{annulus_skewed, cartesian};
use rtx_cfd::solvers::incompressible::{
CurvilinearParameters, CurvilinearPisoSolver, NormalDiffusion, PatchConvection, PatchField,
RobinWall,
};
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()
}
/// `f = ρ u·∇u (if convecting) μ ∇²u + ∇p`, `p = sin(πx) sin(πy)`
/// (the `mms_piso.rs` forcing).
fn source(x: f64, y: f64, convecting: bool) -> (f64, f64) {
let conv = if convecting { RHO * 0.5 * PI } else { 0.0 };
let fx = conv * (2.0 * PI * x).sin()
+ 2.0 * PI * PI * MU * u_exact(x, y)
+ PI * (PI * x).cos() * (PI * y).sin();
let fy = conv * (2.0 * PI * y).sin()
+ 2.0 * PI * PI * MU * v_exact(x, y)
+ PI * (PI * x).sin() * (PI * y).cos();
(fx, fy)
}
struct Measurement {
l2_velocity: f64,
max_div_rel: f64,
steps: usize,
}
/// Smallest across-patch cell size (the explicit diffusion limit).
fn min_spacing(mesh: &PatchMesh) -> f64 {
let mut h = f64::INFINITY;
for c in 0..mesh.cell_count() {
for (f, _) in mesh.cell_faces(c) {
let d = mesh.faces()[f].d;
h = h.min((d[0] * d[0] + d[1] * d[1]).sqrt());
}
}
h
}
/// The manufactured traction on the body per Inner face (the
/// `wall_tractions` convention: `S` into the fluid, `(p S + μ (∇u + ∇uᵀ) S)/|S|`).
fn robin_datum(mesh: &PatchMesh) -> Vec<[f64; 2]> {
let mut out = Vec::new();
for (f, face) in mesh.faces().iter().enumerate() {
if mesh.side(f) != Some(PatchSide::Inner) {
continue;
}
let [x, y] = face.centre;
let sign = if face.neigh.is_some() { 1.0 } else { -1.0 };
let s = [sign * face.s[0], sign * face.s[1]];
let len = (s[0] * s[0] + s[1] * s[1]).sqrt();
let p = (PI * x).sin() * (PI * y).sin();
let ux = PI * (PI * x).cos() * (PI * y).cos();
let uy = -PI * (PI * x).sin() * (PI * y).sin();
let vx = PI * (PI * x).sin() * (PI * y).sin();
let vy = -PI * (PI * x).cos() * (PI * y).cos();
let tx = MU * (2.0 * ux * s[0] + (uy + vx) * s[1]);
let ty = MU * ((uy + vx) * s[0] + 2.0 * vy * s[1]);
out.push([(-p * s[0] + tx) / len, (-p * s[1] + ty) / len]);
}
out
}
async fn march(
mesh: PatchMesh,
convection: PatchConvection,
diffusion: NormalDiffusion,
steady_tol: f64,
) -> CfdResult<Measurement> {
march_with(mesh, convection, diffusion, steady_tol, None).await
}
/// `robin_alpha`: put a Robin wall of that impedance on the Inner side with
/// the manufactured traction as its datum (P6-b's MMS pin).
async fn march_with(
mesh: PatchMesh,
convection: PatchConvection,
diffusion: NormalDiffusion,
steady_tol: f64,
robin_alpha: Option<f64>,
) -> CfdResult<Measurement> {
let nu = MU / RHO;
let h = min_spacing(&mesh);
let env = |k: &str, d: f64| {
std::env::var(k)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(d)
};
let dt = env("RTX_CURV_DTFRAC", 0.4) * (h * h / (4.0 * nu)).min(h);
let tol = env("RTX_CURV_TOL", 1e-5);
let config = CfdConfig::new()
.with_density(RHO)
.with_viscosity(MU)
.with_reference_velocity(1.0)
.with_reference_length(1.0);
let params = CurvilinearParameters {
tolerance: tol,
convection,
normal_diffusion: diffusion,
..CurvilinearParameters::default()
};
let convecting = convection != PatchConvection::None;
let mut solver = CurvilinearPisoSolver::new(config, params, mesh)?;
solver.set_boundary_velocity(|x, y, _t| (u_exact(x, y), v_exact(x, y)));
solver.set_momentum_source(move |x, y, _t| source(x, y, convecting));
if let Some(alpha) = robin_alpha {
let datum = robin_datum(solver.mesh());
solver.set_robin_wall(Some(RobinWall { alpha, datum }));
}
let mut field = PatchField::new(solver.mesh());
solver.initialize(&mut field, |_, _| (0.0, 0.0));
let mut steady = f64::INFINITY;
let mut max_div_rel = 0.0_f64;
let mut steps = 0;
for step in 0..400_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 at step {step}: {r:?}"
);
let flux_scale: f64 = field.flux.iter().map(|f| f.abs()).sum();
max_div_rel = max_div_rel.max(r.max_divergence / flux_scale.max(1e-300));
steps = step + 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 step % 20_000 == 0 && std::env::var("RTX_CURV_TRACE").is_ok() {
println!(
" step {step} t={:.2} |du/dt| {steady:.3e} poisson iters {} div {:.2e}",
solver.time(),
r.poisson_iterations,
r.max_divergence
);
}
if steady < steady_tol {
break;
}
}
assert!(
steady < steady_tol,
"no steady state: |du/dt| = {steady:.3e}"
);
let mesh = solver.mesh();
let (mut sq, mut vol) = (0.0, 0.0);
for c in 0..mesh.cell_count() {
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(Measurement {
l2_velocity: (sq / vol).sqrt(),
max_div_rel,
steps,
})
}
fn orders(errs: &[f64]) -> Vec<f64> {
errs.windows(2).map(|p| (p[0] / p[1]).log2()).collect()
}
#[tokio::test]
async fn cartesian_patch_reproduces_the_staggered_piso_order_and_error() -> CfdResult<()> {
// mms_piso.rs (staggered PISO, upwind): 3.516214e-2 / 1.953750e-2 /
// 1.037512e-2 at 16/32/64, orders 0.85 / 0.91.
let reference = [3.516214e-2, 1.953750e-2, 1.037512e-2];
let only: Option<usize> = std::env::var("RTX_CURV_N")
.ok()
.and_then(|v| v.parse().ok());
let mut errs = Vec::new();
for (&n, &r) in [16usize, 32, 64].iter().zip(&reference) {
if only.is_some_and(|o| o != n) {
continue;
}
let m = march(
cartesian(n, n, 1.0, 1.0, false)?,
PatchConvection::Upwind,
NormalDiffusion::Explicit,
1e-6,
)
.await?;
println!(
"cartesian n={n}: L2 {:.6e} (staggered {r:.6e}, ratio {:.2}), max div {:.2e}, {} steps",
m.l2_velocity,
m.l2_velocity / r,
m.max_div_rel,
m.steps
);
assert!(m.max_div_rel < 1e-9, "divergence {:.3e}", m.max_div_rel);
assert!(
m.l2_velocity < 2.0 * r,
"L2 {:.3e} > 2x staggered {r:.3e}",
m.l2_velocity
);
errs.push(m.l2_velocity);
}
let o = orders(&errs);
println!("cartesian orders {o:?}");
if only.is_none() {
assert!(o.iter().all(|&x| x > 0.75 && x < 2.3), "orders {o:?}");
}
Ok(())
}
#[tokio::test]
async fn skewed_annulus_stokes_limit_is_second_order() -> CfdResult<()> {
for diffusion in [NormalDiffusion::Explicit, NormalDiffusion::LineImplicit] {
let mut errs = Vec::new();
for ns in [32usize, 64, 128] {
let m = march(
annulus_skewed([0.0, 0.0], 0.5, 1.5, ns, ns / 4, 0.3, 3.0)?,
PatchConvection::None,
diffusion,
1e-7,
)
.await?;
println!(
"annulus {diffusion:?} ns={ns}: L2 {:.6e}, max div {:.2e}, {} steps",
m.l2_velocity, m.max_div_rel, m.steps
);
assert!(m.max_div_rel < 1e-9, "divergence {:.3e}", m.max_div_rel);
errs.push(m.l2_velocity);
}
let o = orders(&errs);
println!("annulus {diffusion:?} Stokes orders {o:?}");
assert!(
o.iter().all(|&x| x >= 1.8),
"Stokes-limit orders {o:?} (gate >= 1.8)"
);
}
Ok(())
}
#[tokio::test]
async fn skewed_annulus_with_upwind_is_first_order() -> CfdResult<()> {
let mut errs = Vec::new();
for ns in [32usize, 64, 128] {
let m = march(
annulus_skewed([0.0, 0.0], 0.5, 1.5, ns, ns / 4, 0.3, 3.0)?,
PatchConvection::Upwind,
NormalDiffusion::Explicit,
1e-6,
)
.await?;
println!(
"annulus upwind ns={ns}: L2 {:.6e}, max div {:.2e}, {} steps",
m.l2_velocity, m.max_div_rel, m.steps
);
assert!(m.max_div_rel < 1e-9, "divergence {:.3e}", m.max_div_rel);
errs.push(m.l2_velocity);
}
let o = orders(&errs);
println!("annulus upwind orders {o:?}");
assert!(o.iter().all(|&x| x > 0.7 && x < 1.6), "upwind orders {o:?}");
Ok(())
}
/// P4 step 2 gate (ii): the van Albada deferred correction on the skewed
/// annulus beats upwind at every rung (upwind: 1.151502e-1 / 5.445770e-2 /
/// 3.119486e-2) with orders >= 1.4.
#[tokio::test]
async fn skewed_annulus_with_tvd_beats_upwind() -> CfdResult<()> {
let upwind = [1.151502e-1, 5.445770e-2, 3.119486e-2];
let mut errs = Vec::new();
for (&ns, &u) in [32usize, 64, 128].iter().zip(&upwind) {
let m = march(
annulus_skewed([0.0, 0.0], 0.5, 1.5, ns, ns / 4, 0.3, 3.0)?,
PatchConvection::TvdVanAlbada,
NormalDiffusion::Explicit,
1e-6,
)
.await?;
println!(
"annulus tvd ns={ns}: L2 {:.6e} (upwind {u:.6e}, ratio {:.2}), max div {:.2e}, {} steps",
m.l2_velocity,
m.l2_velocity / u,
m.max_div_rel,
m.steps
);
assert!(m.max_div_rel < 1e-9, "divergence {:.3e}", m.max_div_rel);
assert!(
m.l2_velocity < u,
"TVD {:.4e} not below upwind {u:.4e}",
m.l2_velocity
);
errs.push(m.l2_velocity);
}
let o = orders(&errs);
println!("annulus tvd orders {o:?}");
assert!(
o.iter().all(|&x| x >= 1.4),
"tvd orders {o:?} (gate >= 1.4)"
);
Ok(())
}
#[tokio::test]
async fn snapshot_restore_rerun_is_bit_identical() -> CfdResult<()> {
let mesh = annulus_skewed([0.0, 0.0], 0.5, 1.5, 24, 6, 0.3, 2.0)?;
let config = CfdConfig::new().with_density(RHO).with_viscosity(MU);
let mut solver = CurvilinearPisoSolver::new(config, CurvilinearParameters::default(), mesh)?;
solver.set_boundary_velocity(|x, y, t| (u_exact(x, y) * (1.0 + 0.1 * t), v_exact(x, y)));
solver.set_momentum_source(|x, y, _| source(x, y, true));
let mut field = PatchField::new(solver.mesh());
solver.initialize(&mut field, |x, y| (u_exact(x, y), v_exact(x, y)));
let dt = 1e-3;
for _ in 0..5 {
solver.advance(&mut field, dt).await?;
}
let saved = solver.snapshot();
let field_saved = field.clone();
for _ in 0..10 {
solver.advance(&mut field, dt).await?;
}
let reference = (field.clone(), solver.time());
solver.restore(&saved);
field = field_saved;
for _ in 0..10 {
solver.advance(&mut field, dt).await?;
}
assert_eq!(solver.time().to_bits(), reference.1.to_bits());
let mut max_diff = 0.0_f64;
for (a, b) in field
.u
.iter()
.zip(&reference.0.u)
.chain(field.v.iter().zip(&reference.0.v))
.chain(field.p.iter().zip(&reference.0.p))
.chain(field.flux.iter().zip(&reference.0.flux))
{
max_diff = max_diff.max((a - b).abs());
}
assert!(max_diff == 0.0, "re-run differs by {max_diff:.3e}");
Ok(())
}
/// P6-b's MMS pin: the annulus in the Stokes limit with a Robin wall of
/// impedance `alpha` on the Inner side (datum = the manufactured traction)
/// keeps the Dirichlet wall's order (≥ 1.8) at two impedances of the
/// viscous scale, and an effectively rigid wall (`alpha` = 1e12)
/// reproduces the Dirichlet march to rounding.
#[tokio::test]
async fn skewed_annulus_stokes_with_a_robin_inner_wall_keeps_second_order() -> CfdResult<()> {
let steady_tol = 1e-4;
let mut dirichlet = Vec::new();
for ns in [24, 48, 96] {
let m = march(
annulus_skewed([0.0, 0.0], 0.5, 1.5, ns, ns / 4, 0.3, 3.0)?,
PatchConvection::None,
NormalDiffusion::LineImplicit,
steady_tol,
)
.await?;
dirichlet.push(m.l2_velocity);
}
println!("dirichlet L2 {dirichlet:?} orders {:?}", orders(&dirichlet));
for &scale in &[10.0, 100.0] {
let mut errs = Vec::new();
for ns in [24, 48, 96] {
let mesh = annulus_skewed([0.0, 0.0], 0.5, 1.5, ns, ns / 4, 0.3, 3.0)?;
let h = min_spacing(&mesh);
let alpha = scale * MU / h;
let m = march_with(
mesh,
PatchConvection::None,
NormalDiffusion::LineImplicit,
steady_tol,
Some(alpha),
)
.await?;
println!(
"robin alpha = {scale} μ/h ns={ns}: L2 {:.6e}, max div {:.2e}, {} steps",
m.l2_velocity, m.max_div_rel, m.steps
);
errs.push(m.l2_velocity);
}
let o = orders(&errs);
println!("robin alpha = {scale} μ/h orders {o:?}");
assert!(
o.iter().all(|&x| x > 1.8),
"Robin ({scale} μ/h) orders {o:?} (gate >= 1.8)"
);
}
let mesh = annulus_skewed([0.0, 0.0], 0.5, 1.5, 48, 12, 0.3, 3.0)?;
let rigid = march_with(
mesh,
PatchConvection::None,
NormalDiffusion::LineImplicit,
steady_tol,
Some(1e12),
)
.await?;
let rel = (rigid.l2_velocity - dirichlet[1]).abs() / dirichlet[1];
println!(
"rigid Robin (1e12) vs Dirichlet at ns 48: L2 {:.6e} vs {:.6e} (rel {rel:.2e})",
rigid.l2_velocity, dirichlet[1]
);
// 1e-5: the two marches stop at different steps under the 1e-4 steady
// tolerance and the Robin path skips the closed-patch flux adjustment
// (measured 1.9e-6 at ns 48).
assert!(
rel < 1e-5,
"an effectively rigid Robin wall differs from Dirichlet by {rel:.2e}"
);
Ok(())
}
/// The Robin wall's SIGN — the steady MMS pin above is blind to it (both
/// parts vanish at its fixed point). A still CLOSED annulus under a
/// uniform pressure `p₀` above a zero datum: the explicit part must move
/// the wall INTO the body (offset · n < 0 with n = S/|S| into the fluid,
/// the magnitude `p₀/(α (1 + g))`, `g = μ/(α d)` the viscous damping);
/// the fluid cannot follow (the outer wall is fixed), so the implicit
/// compliant term `|S| p'/α` must answer with a pressure DROP that stops
/// the recession — `p' ≈ p₀/(1 + g)`, the net wall flux absorbed. With
/// the wrong sign the pressure would rise by the same amount.
#[tokio::test]
async fn robin_wall_recedes_under_pressure_on_both_parts() -> CfdResult<()> {
let mesh = annulus_skewed([0.0, 0.0], 0.5, 1.5, 48, 12, 0.3, 3.0)?;
let config = CfdConfig::new()
.with_density(RHO)
.with_viscosity(MU)
.with_reference_velocity(1.0)
.with_reference_length(1.0);
let params = CurvilinearParameters {
tolerance: 1e-12,
convection: PatchConvection::None,
normal_diffusion: NormalDiffusion::Explicit,
..CurvilinearParameters::default()
};
let mut solver = CurvilinearPisoSolver::new(config, params, mesh)?;
solver.set_boundary_velocity(|_, _, _| (0.0, 0.0));
let n_inner = (0..solver.mesh().faces().len())
.filter(|&f| solver.mesh().side(f) == Some(PatchSide::Inner))
.count();
let alpha = 4.0;
solver.set_robin_wall(Some(RobinWall {
alpha,
datum: vec![[0.0, 0.0]; n_inner],
}));
let mut field = PatchField::new(solver.mesh());
solver.initialize(&mut field, |_, _| (0.0, 0.0));
let p0 = 1.0;
field.p.iter_mut().for_each(|p| *p = p0);
let h = min_spacing(solver.mesh());
let dt = 0.1 * (h * h / (4.0 * MU / RHO)).min(h);
solver.advance(&mut field, dt).await?;
let mesh = solver.mesh();
let inner: Vec<usize> = (0..mesh.faces().len())
.filter(|&f| mesh.side(f) == Some(PatchSide::Inner))
.collect();
let offsets = solver.robin_offsets();
assert_eq!(offsets.len(), inner.len());
let (mut worst_rel, mut net_flux, mut recession, mut p_expect) = (0.0_f64, 0.0, 0.0, 0.0);
for (j, &f) in inner.iter().enumerate() {
let face = &mesh.faces()[f];
let s = face.s; // owner None, neigh Some: S points into the fluid
let len = (s[0] * s[0] + s[1] * s[1]).sqrt();
let n = [s[0] / len, s[1] / len];
let c = mesh.boundary_cell(f);
let xc = mesh.centre(c);
let d = ((face.centre[0] - xc[0]).powi(2) + (face.centre[1] - xc[1]).powi(2)).sqrt();
let g = MU / (alpha * d);
let expect = -p0 / (alpha * (1.0 + g));
let got = offsets[j][0] * n[0] + offsets[j][1] * n[1];
assert!(
got < 0.0,
"face {f}: explicit offset · n = {got:.3e} (must recede)"
);
worst_rel = worst_rel.max(((got - expect) / expect).abs());
net_flux += field.flux[f];
recession += got.abs() * len;
p_expect += (p0 - p0 / (1.0 + g)) / inner.len() as f64;
}
let p_mean = field.p.iter().sum::<f64>() / field.p.len() as f64;
let p_max = field.p.iter().cloned().fold(f64::MIN, f64::max);
println!(
" Robin sign pin: {} Inner faces, explicit offset p₀/(α(1+g)) to {worst_rel:.2e}; net wall flux {net_flux:.2e} vs the recession's {recession:.2e}; pressure after the step mean {p_mean:.4} max {p_max:.4} (p₀ {p0}, compliant answer ≈ {p_expect:.4})",
inner.len()
);
assert!(
worst_rel < 1e-9,
"explicit offset magnitude off by {worst_rel:.2e}"
);
assert!(
net_flux.abs() < 1e-6 * recession,
"the compliant wall did not absorb the recession: net {net_flux:.3e} of {recession:.3e}"
);
assert!(
p_max < p0,
"the pressure ROSE (max {p_max:.4} ≥ p₀): the implicit term advances the wall"
);
assert!(
(p_mean - p_expect).abs() < 0.3 * p0,
"pressure drop {p_mean:.4} far from the compliant answer {p_expect:.4}"
);
Ok(())
}