rtx-cfd: curvilinear collocated PISO on a structured patch (overset A-P0, WIP) — PatchMesh (right-handed s,n; periodic seam with shift; face metrics), patch generators (TFI, skewed annulus, sheared/varying-skew channels), CSR + Jacobi-BiCGSTAB, the Zang–Street–Koseff incremental step with the node-based 9-point L_f, LSQ gradients, explicit and line-implicit-n predictors, adjustPhi; tests: mesh metrics (5 green), operators exact on linear fields incl. the seam (green), sparse (2 green), MMS ladder (Cartesian 16/32: 1.37–1.39x the staggered error, order 0.83; n=64 stalls at a |du/dt| floor 2e-4 — open, tolerance-scaling hypothesis), annulus/Poiseuille not yet run
CI / Distributed Training Tests (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 / 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 / CI Success (push) Canceled after 0s
Documentation / Build API Documentation (push) Canceled after 0s
Documentation / Build User Guide (push) Canceled after 0s

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-04 05:00:08 -07:00
co-authored by Claude Fable 5.1
parent 1347bc6772
commit 52da75a3a9
13 changed files with 2659 additions and 0 deletions
@@ -0,0 +1,184 @@
//! P0 gate 3 (`docs/overset_metal_campaign.md` §5.3): plane Poiseuille
//! flow on the patch. On the Cartesian AND the affinely sheared periodic
//! channel the discrete fixed point is exactly the `poiseuille.rs`
//! profile (congruent parallelogram cells: the non-orthogonal corrections
//! cancel by translation invariance) — recovered to 1e-8 with `v` and the
//! pressure spread at the same level. On a channel of smoothly varying
//! skew and stretch the error against the parabola falls at second
//! order. Cell mass is conserved to 1e-12 everywhere.
use rtx_cfd::mesh::PatchMesh;
use rtx_cfd::mesh::patch_gen::{cartesian, channel_sheared, channel_varying_skew};
use rtx_cfd::solvers::incompressible::{
CurvilinearParameters, CurvilinearPisoSolver, NormalDiffusion, PatchField,
};
use rtx_cfd::{CfdConfig, CfdResult};
const MU: f64 = 0.1;
const G: f64 = 0.8;
fn u_exact(y: f64) -> f64 {
G / (2.0 * MU) * y * (1.0 - y)
}
/// The 1-D discrete channel profile with half-cell wall closures
/// (`tests/poiseuille.rs`).
fn discrete_profile(n: usize) -> Vec<f64> {
let h = 1.0 / n as f64;
let rhs_value = -G * h * h / MU;
let mut diag = vec![-2.0; n];
diag[0] = -3.0;
diag[n - 1] = -3.0;
let mut rhs = vec![rhs_value; n];
let upper = vec![1.0; n];
for j in 1..n {
let factor = 1.0 / diag[j - 1];
diag[j] -= factor * upper[j - 1];
rhs[j] -= factor * rhs[j - 1];
}
let mut u = vec![0.0; n];
u[n - 1] = rhs[n - 1] / diag[n - 1];
for j in (0..n - 1).rev() {
u[j] = (rhs[j] - upper[j] * u[j + 1]) / diag[j];
}
u
}
struct Steady {
field: PatchField,
mesh: PatchMesh,
worst_mass: f64,
}
async fn steady(mesh: PatchMesh, diffusion: NormalDiffusion, dt_factor: f64) -> CfdResult<Steady> {
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());
}
}
let dt = dt_factor * 0.4 * h * h / (4.0 * MU);
let config = CfdConfig::new().with_density(1.0).with_viscosity(MU);
let params = CurvilinearParameters {
tolerance: 1e-11,
normal_diffusion: diffusion,
..CurvilinearParameters::default()
};
let mut solver = CurvilinearPisoSolver::new(config, params, mesh)?;
solver.set_boundary_velocity(|_, _, _| (0.0, 0.0));
solver.set_momentum_source(|_, _, _| (G, 0.0));
let mut field = PatchField::new(solver.mesh());
solver.initialize(&mut field, |_, _| (0.0, 0.0));
// Mass defect at the steady state, relative to the largest face flux
// (during the transient it is the pressure solver's residual).
let mut worst_mass = 0.0_f64;
let mut converged = false;
for _ in 0..2_000_000 {
let before = field.u.clone();
let r = solver.advance(&mut field, dt).await?;
assert!(r.poisson_converged, "{r:?}");
let scale: f64 = field.flux.iter().map(|f| f.abs()).fold(0.0, f64::max);
worst_mass = r.max_divergence / scale.max(1e-300);
let change = field
.u
.iter()
.zip(&before)
.map(|(a, b)| (a - b).abs())
.fold(0.0, f64::max);
if change / dt < 1e-11 {
converged = true;
break;
}
}
assert!(converged, "no steady state");
let mesh = solver.mesh().clone();
Ok(Steady {
field,
mesh,
worst_mass,
})
}
fn max_vs_discrete(s: &Steady, n: usize) -> (f64, f64, f64) {
let u_hat = discrete_profile(n);
let (mut du, mut v, mut pmin, mut pmax) = (0.0_f64, 0.0_f64, f64::INFINITY, f64::NEG_INFINITY);
for c in 0..s.mesh.cell_count() {
let (k, _) = s.mesh.cell_ki(c);
du = du.max((s.field.u[c] - u_hat[k]).abs());
v = v.max(s.field.v[c].abs());
pmin = pmin.min(s.field.p[c]);
pmax = pmax.max(s.field.p[c]);
}
(du, v, pmax - pmin)
}
#[tokio::test]
async fn cartesian_and_sheared_channels_hit_the_discrete_profile_exactly() -> CfdResult<()> {
let n = 16;
let shapes: [(&str, Box<dyn Fn() -> CfdResult<PatchMesh>>); 3] = [
(
"cartesian",
Box::new(move || cartesian(n, n, 1.0, 1.0, true)),
),
(
"sheared 0.4",
Box::new(move || channel_sheared(1.0, 1.0, n, n, 0.4, true)),
),
(
"sheared -0.7",
Box::new(move || channel_sheared(1.0, 1.0, n, n, -0.7, true)),
),
];
for (name, mesh) in &shapes {
for diffusion in [NormalDiffusion::Explicit, NormalDiffusion::LineImplicit] {
let s = steady(mesh()?, diffusion, 1.0).await?;
let (du, v, dp) = max_vs_discrete(&s, n);
println!(
"{name} {diffusion:?}: |u - u_hat| {du:.3e}, |v| {v:.3e}, p spread {dp:.3e}, mass {:.3e}",
s.worst_mass
);
assert!(du < 1e-8, "{name} {diffusion:?}: |u - u_hat| = {du:.3e}");
assert!(v < 1e-8, "{name} {diffusion:?}: |v| = {v:.3e}");
assert!(dp < 1e-8, "{name} {diffusion:?}: p spread {dp:.3e}");
assert!(
s.worst_mass < 1e-12,
"{name} {diffusion:?}: mass defect {:.3e}",
s.worst_mass
);
}
}
Ok(())
}
#[tokio::test]
async fn varying_skew_channel_converges_to_the_parabola_at_second_order() -> CfdResult<()> {
let mut errs = Vec::new();
let mut vs = Vec::new();
for n in [16usize, 32, 64] {
let s = steady(
channel_varying_skew(1.0, 1.0, n, n, 0.3, 2.0, true)?,
NormalDiffusion::Explicit,
1.0,
)
.await?;
let (mut e, mut v) = (0.0_f64, 0.0_f64);
for c in 0..s.mesh.cell_count() {
let y = s.mesh.centre(c)[1];
e = e.max((s.field.u[c] - u_exact(y)).abs());
v = v.max(s.field.v[c].abs());
}
println!(
"varying skew n={n}: |u - parabola| {e:.3e}, |v| {v:.3e}, mass {:.3e}",
s.worst_mass
);
assert!(s.worst_mass < 1e-12);
errs.push(e);
vs.push(v);
}
let o: Vec<f64> = errs.windows(2).map(|p| (p[0] / p[1]).log2()).collect();
let ov: Vec<f64> = vs.windows(2).map(|p| (p[0] / p[1]).log2()).collect();
println!("varying skew orders u {o:?}, v {ov:?}");
assert!(o.iter().all(|&x| x >= 1.8), "orders {o:?}");
Ok(())
}