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
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:
co-authored by
Claude Fable 5.1
parent
1347bc6772
commit
52da75a3a9
@@ -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 cylinder–flag 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user