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
Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01YJPeT6WA2e7YvAnS875AHL
436 lines
15 KiB
Rust
436 lines
15 KiB
Rust
//! 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(())
|
||
}
|