Files
rustytorch/crates/specialized/rtx-cfd/tests/curvilinear_mms.rs
T
Omar SobhandClaude Fable 5.1 c3dbd5040a
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
CI / Build (ubuntu-latest) (push) Failing after 7s
CI / Format Check (push) Failing after 17s
Documentation / Build User Guide (push) Successful in 19s
Documentation / Build API Documentation (push) Failing after 1m51s
CI / Build CPU-Only (Explicit) (push) Failing after 1m58s
CI / Clippy Check (push) Failing after 2m13s
Performance Benchmarks / Run Benchmarks (push) Successful in 2m54s
P6-b design 2: the Robin wall on the patch's Inner side — RobinWall { alpha, datum } with the wall velocity u_s + (t_f − datum)/α (explicit, damped by the wall's own viscous gain μ/(α d)) and the compliant-wall pressure term |S| p'/α implicit in the projection (the added-mass operator in the fluid's own response); Dirichlet bit for bit when off; MMS pin on the skewed annulus (orders 2.46/2.13 at α = 10 and 100 μ/h, the Dirichlet values; a 1e12 wall reproduces Dirichlet to 2e-6); OversetPisoSolver::patch_mut; harness knob RTX_FSI2O_ROBIN_ALPHA with the previous pass's tractions as the datum, printed in the header
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YJPeT6WA2e7YvAnS875AHL
2026-09-15 10:46:35 -05:00

436 lines
15 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::patch_gen::{annulus_skewed, cartesian};
use rtx_cfd::mesh::PatchMesh;
use rtx_cfd::mesh::PatchSide;
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(())
}