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,426 @@
//! A structured curvilinear 2-D patch: the mesh a body-fitted overset
//! patch lives on (`docs/overset_metal_campaign.md` §2.1, A-P0).
//!
//! Cells are indexed `(k, i)` with `i` along the body (`s`, possibly
//! periodic — an O-grid) and `k` across it (`n`, from the wall outward).
//! Nodes are stored as `(nn + 1) × (ns + 1)` coordinates even when the
//! patch is periodic: column `ns` is then a copy of column `0` (bitwise
//! when the periodic shift is zero), so every cell reads its four corners
//! from its own columns and the seam is never a special case.
//!
//! The `(s, n)` frame must be right-handed (every cell's corner loop
//! `(k,i) (k,i+1) (k+1,i+1) (k+1,i)` counter-clockwise, positive area): for
//! an O-grid with `n` pointing away from the body that means `s` runs
//! CLOCKWISE around it — walk the outline with the body on your right.
//!
//! Every face carries its geometry once: the area vector `S_f` (length ×
//! unit normal, oriented toward +s for s-faces and +n for n-faces), the
//! owner (side) and neighbour (+side) cells, the centre-to-centre vector
//! `d_f` with the periodic shift already applied, and the owner's linear
//! interpolation weight. Boundary faces have one of owner/neighbour
//! missing and `d_f` spanning cell centre ↔ face centre, still oriented +.
//!
//! The metrics are what the collocated solver consumes; the solver never
//! touches node coordinates. Neighbour access goes through the face
//! lists, so the seam and the boundaries are handled here, once.
use crate::error::{CfdError, CfdResult};
/// One face of the patch with its geometry.
#[derive(Debug, Clone, Copy)]
pub struct Face {
/// End nodes (flat node indices), ordered so that `S_f` is the
/// +90° rotation of `n0 → n1` for s-faces and the 90° rotation for
/// n-faces; the solver's tangential derivative runs `n0 → n1`.
pub n0: usize,
/// See `n0`.
pub n1: usize,
/// Face centre (midpoint of the two nodes).
pub centre: [f64; 2],
/// Area vector `S_f`: length × unit normal, oriented +s or +n.
pub s: [f64; 2],
/// Cell on the side of the face (`None` on a boundary).
pub owner: Option<usize>,
/// Cell on the + side of the face (`None` on a + boundary).
pub neigh: Option<usize>,
/// Owner centre → neighbour centre (periodic shift applied), or cell
/// centre ↔ face centre on a boundary; always oriented +.
pub d: [f64; 2],
/// Owner weight for linear interpolation: `φ_f = w φ_P + (1 w) φ_N`,
/// distance-weighted. `1` on boundary faces (the interior cell).
pub w: f64,
}
/// Which boundary of the patch a boundary face lies on.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Side {
/// `k = 0`: the body side (the wall of an O-grid).
Inner,
/// `k = nn`: the far side (the overlap ring of an O-grid).
Outer,
/// `i = 0` (non-periodic only).
SStart,
/// `i = ns` (non-periodic only).
SEnd,
}
/// A structured curvilinear patch with precomputed metrics.
#[derive(Debug, Clone)]
pub struct PatchMesh {
ns: usize,
nn: usize,
periodic: Option<[f64; 2]>,
x: Vec<f64>,
y: Vec<f64>,
centre: Vec<[f64; 2]>,
area: Vec<f64>,
faces: Vec<Face>,
n_sfaces: usize,
}
impl PatchMesh {
/// Build a patch from node coordinates laid out `(nn + 1)` rows of
/// `(ns + 1)` columns, flat index `k * (ns + 1) + i`.
///
/// `periodic = Some(shift)` closes the s direction: column `ns` must
/// equal column `0 + shift` (to 1e-12 relative; when `shift` is zero
/// it is overwritten with a bitwise copy so the seam face metrics
/// agree exactly). Every cell must have positive area.
pub fn from_nodes(
ns: usize,
nn: usize,
mut x: Vec<f64>,
mut y: Vec<f64>,
periodic: Option<[f64; 2]>,
) -> CfdResult<Self> {
let cols = ns + 1;
if ns < 4 || nn < 2 {
return Err(CfdError::mesh(format!(
"patch needs ns >= 4 and nn >= 2, got ns = {ns}, nn = {nn}"
)));
}
if x.len() != (nn + 1) * cols || y.len() != x.len() {
return Err(CfdError::mesh(format!(
"patch nodes: expected {} coordinates, got {} / {}",
(nn + 1) * cols,
x.len(),
y.len()
)));
}
if let Some(shift) = periodic {
let scale = 1e-12
* x.iter()
.chain(y.iter())
.fold(0.0_f64, |m, v| m.max(v.abs()))
.max(1.0);
for k in 0..=nn {
let (a, b) = (k * cols, k * cols + ns);
let (ex, ey) = (x[a] + shift[0], y[a] + shift[1]);
if (x[b] - ex).abs() > scale || (y[b] - ey).abs() > scale {
return Err(CfdError::mesh(format!(
"periodic patch: node column {ns} != column 0 + shift at row {k}"
)));
}
if shift == [0.0, 0.0] {
x[b] = x[a];
y[b] = y[a];
}
}
}
let mut mesh = Self {
ns,
nn,
periodic,
x,
y,
centre: Vec::new(),
area: Vec::new(),
faces: Vec::new(),
n_sfaces: 0,
};
mesh.build_cells()?;
mesh.build_faces();
Ok(mesh)
}
/// Cells along the body.
pub fn ns(&self) -> usize {
self.ns
}
/// Cells across the patch.
pub fn nn(&self) -> usize {
self.nn
}
/// Number of cells.
pub fn cell_count(&self) -> usize {
self.ns * self.nn
}
/// Whether the s direction is closed, and its translation.
pub fn periodic(&self) -> Option<[f64; 2]> {
self.periodic
}
/// Flat cell index of `(k, i)`.
pub fn cell(&self, k: usize, i: usize) -> usize {
k * self.ns + i
}
/// `(k, i)` of a flat cell index.
pub fn cell_ki(&self, c: usize) -> (usize, usize) {
(c / self.ns, c % self.ns)
}
/// Flat node index of row `k`, column `i`.
pub fn node(&self, k: usize, i: usize) -> usize {
k * (self.ns + 1) + i
}
/// Node coordinates.
pub fn node_xy(&self, n: usize) -> [f64; 2] {
[self.x[n], self.y[n]]
}
/// Cell centroid.
pub fn centre(&self, c: usize) -> [f64; 2] {
self.centre[c]
}
/// Cell area.
pub fn area(&self, c: usize) -> f64 {
self.area[c]
}
/// All faces: s-faces first, then n-faces.
pub fn faces(&self) -> &[Face] {
&self.faces
}
/// Number of s-faces per row.
pub fn sfaces_per_row(&self) -> usize {
if self.periodic.is_some() {
self.ns
} else {
self.ns + 1
}
}
/// Face index of the s-face at node column `i` in cell row `k`
/// (`i` in `0..sfaces_per_row()`; for a periodic patch column 0 is the
/// seam, adjacent to cells `ns 1` and `0`).
pub fn sface(&self, k: usize, i: usize) -> usize {
k * self.sfaces_per_row() + i
}
/// Face index of the n-face at node row `k` in cell column `i`
/// (`k` in `0..=nn`).
pub fn nface(&self, k: usize, i: usize) -> usize {
self.n_sfaces + k * self.ns + i
}
/// Whether face `f` is an s-face.
pub fn is_sface(&self, f: usize) -> bool {
f < self.n_sfaces
}
/// The four faces of a cell with the sign that makes `sign · S_f` the
/// outward area vector: `[west, east, south, north]`.
pub fn cell_faces(&self, c: usize) -> [(usize, f64); 4] {
let (k, i) = self.cell_ki(c);
let east = if self.periodic.is_some() {
(i + 1) % self.ns
} else {
i + 1
};
[
(self.sface(k, i), -1.0),
(self.sface(k, east), 1.0),
(self.nface(k, i), -1.0),
(self.nface(k + 1, i), 1.0),
]
}
/// Which boundary a face lies on, if any.
pub fn side(&self, f: usize) -> Option<Side> {
let face = &self.faces[f];
match (face.owner, face.neigh) {
(Some(_), Some(_)) => None,
(None, Some(_)) => Some(if self.is_sface(f) {
Side::SStart
} else {
Side::Inner
}),
(Some(_), None) => Some(if self.is_sface(f) {
Side::SEnd
} else {
Side::Outer
}),
(None, None) => unreachable!("a face without cells"),
}
}
/// The cells touching node `(k, i)` (2 to 4 of them; the seam node is
/// resolved by wrapping when periodic).
pub fn node_cells(&self, k: usize, i: usize) -> Vec<usize> {
let mut out = Vec::with_capacity(4);
let rows = [k.checked_sub(1), (k < self.nn).then_some(k)];
let cols: [Option<usize>; 2] = if self.periodic.is_some() {
let i = i % self.ns;
[Some((i + self.ns - 1) % self.ns), Some(i)]
} else {
[i.checked_sub(1), (i < self.ns).then_some(i)]
};
for r in rows.into_iter().flatten() {
for c in cols.into_iter().flatten() {
out.push(self.cell(r, c));
}
}
out
}
/// The interior cell of a boundary face.
pub fn boundary_cell(&self, f: usize) -> usize {
let face = &self.faces[f];
face.owner.or(face.neigh).expect("a face without cells")
}
/// Mesh quality check: positive areas (built in), interior
/// non-orthogonality angle below `max_angle_deg`, and no collapsed
/// faces. The overset's later shapes (the cylinderflag junction) must
/// fail here loudly rather than produce a NaN in the solver.
pub fn validate(&self, max_angle_deg: f64) -> Result<(), String> {
let cos_min = max_angle_deg.to_radians().cos();
for (f, face) in self.faces.iter().enumerate() {
let len = (face.s[0] * face.s[0] + face.s[1] * face.s[1]).sqrt();
let dl = (face.d[0] * face.d[0] + face.d[1] * face.d[1]).sqrt();
if len == 0.0 || dl == 0.0 {
return Err(format!(
"face {f} is collapsed (|S| = {len:.3e}, |d| = {dl:.3e})"
));
}
if face.owner.is_some() && face.neigh.is_some() {
let cos = (face.s[0] * face.d[0] + face.s[1] * face.d[1]) / (len * dl);
if cos < cos_min {
return Err(format!(
"face {f}: non-orthogonality {:.1}° exceeds {max_angle_deg}°",
cos.clamp(-1.0, 1.0).acos().to_degrees()
));
}
}
}
Ok(())
}
fn build_cells(&mut self) -> CfdResult<()> {
let (ns, nn) = (self.ns, self.nn);
self.centre = vec![[0.0; 2]; ns * nn];
self.area = vec![0.0; ns * nn];
for k in 0..nn {
for i in 0..ns {
// Corners counter-clockwise: (k,i) (k,i+1) (k+1,i+1) (k+1,i).
let n = [
self.node(k, i),
self.node(k, i + 1),
self.node(k + 1, i + 1),
self.node(k + 1, i),
];
let (mut a2, mut cx, mut cy) = (0.0, 0.0, 0.0);
for q in 0..4 {
let (p0, p1) = (n[q], n[(q + 1) % 4]);
let cross = self.x[p0] * self.y[p1] - self.x[p1] * self.y[p0];
a2 += cross;
cx += (self.x[p0] + self.x[p1]) * cross;
cy += (self.y[p0] + self.y[p1]) * cross;
}
if a2 <= 0.0 {
return Err(CfdError::mesh(format!(
"patch cell ({k}, {i}) has non-positive area {:.3e}",
0.5 * a2
)));
}
let c = self.cell(k, i);
self.area[c] = 0.5 * a2;
self.centre[c] = [cx / (3.0 * a2), cy / (3.0 * a2)];
}
}
Ok(())
}
fn build_faces(&mut self) {
let (ns, nn) = (self.ns, self.nn);
let per_row = self.sfaces_per_row();
let mut faces = Vec::with_capacity(nn * per_row + (nn + 1) * ns);
// s-faces: at node column i, between cells (k, i-1) and (k, i).
for k in 0..nn {
for i in 0..per_row {
let (owner, neigh, col) = if self.periodic.is_some() {
if i == 0 {
// The seam: geometry from column ns (owner's side),
// the neighbour (cell 0) sits one period ahead.
(Some(self.cell(k, ns - 1)), Some(self.cell(k, 0)), ns)
} else {
(Some(self.cell(k, i - 1)), Some(self.cell(k, i)), i)
}
} else {
(
(i > 0).then(|| self.cell(k, i - 1)),
(i < ns).then(|| self.cell(k, i)),
i,
)
};
let (n0, n1) = (self.node(k, col), self.node(k + 1, col));
let t = [self.x[n1] - self.x[n0], self.y[n1] - self.y[n0]];
let s = [t[1], -t[0]];
let seam_shift = if self.periodic.is_some() && i == 0 {
self.periodic.unwrap_or([0.0; 2])
} else {
[0.0; 2]
};
faces.push(self.make_face(n0, n1, s, owner, neigh, seam_shift));
}
}
self.n_sfaces = faces.len();
// n-faces: at node row k, between cells (k-1, i) and (k, i).
for k in 0..=nn {
for i in 0..ns {
let owner = (k > 0).then(|| self.cell(k - 1, i));
let neigh = (k < nn).then(|| self.cell(k, i));
let (n0, n1) = (self.node(k, i), self.node(k, i + 1));
let t = [self.x[n1] - self.x[n0], self.y[n1] - self.y[n0]];
let s = [-t[1], t[0]];
faces.push(self.make_face(n0, n1, s, owner, neigh, [0.0; 2]));
}
}
self.faces = faces;
}
fn make_face(
&self,
n0: usize,
n1: usize,
s: [f64; 2],
owner: Option<usize>,
neigh: Option<usize>,
shift: [f64; 2],
) -> Face {
let centre = [
0.5 * (self.x[n0] + self.x[n1]),
0.5 * (self.y[n0] + self.y[n1]),
];
let (d, w) = match (owner, neigh) {
(Some(p), Some(q)) => {
let cp = self.centre[p];
let cq = [self.centre[q][0] + shift[0], self.centre[q][1] + shift[1]];
let dp = ((centre[0] - cp[0]).powi(2) + (centre[1] - cp[1]).powi(2)).sqrt();
let dq = ((cq[0] - centre[0]).powi(2) + (cq[1] - centre[1]).powi(2)).sqrt();
([cq[0] - cp[0], cq[1] - cp[1]], dq / (dp + dq))
}
(Some(p), None) => {
let cp = self.centre[p];
([centre[0] - cp[0], centre[1] - cp[1]], 1.0)
}
(None, Some(q)) => {
let cq = self.centre[q];
([cq[0] - centre[0], cq[1] - centre[1]], 1.0)
}
(None, None) => unreachable!("a face without cells"),
};
Face {
n0,
n1,
centre,
s,
owner,
neigh,
d,
w,
}
}
}