Files
rustytorch/crates/specialized/rtx-cfd/src/mesh/patch_mesh.rs
T
Omar SobhandClaude Fable 5.1 e0b5983436
CI / Format Check (push) Failing after 6s
Performance Benchmarks / Run Benchmarks (push) Failing after 6s
CI / Clippy Check (push) Failing after 5s
CI / Build (ubuntu-latest) (push) Failing after 5s
Documentation / Build User Guide (push) Successful in 7s
Documentation / Build API Documentation (push) Failing after 25s
CI / Build CPU-Only (Explicit) (push) Failing after 1m30s
CI / Build (macos-latest) (push) Canceled after 0s
CI / Test (macos-latest) (push) Canceled after 0s
CI / Test (ubuntu-latest) (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 / Distributed Training Tests (push) Canceled after 0s
CI / CI Success (push) Canceled after 0s
PERF-2 P1.2: the overlap map's cell classification skips the point location for background cells outside the patch's node bounding box (no patch cell can contain them and the body lies inside the patch: Active, the class the search returns); PatchMesh::bounding_box
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YJPeT6WA2e7YvAnS875AHL
2026-09-15 23:34:40 -05:00

444 lines
16 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.
//! 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.
/// `[x_min, x_max, y_min, y_max]` over the nodes.
pub fn bounding_box(&self) -> [f64; 4] {
let (mut b, mut first) = ([0.0; 4], true);
for (&x, &y) in self.x.iter().zip(&self.y) {
if first {
b = [x, x, y, y];
first = false;
} else {
b[0] = b[0].min(x);
b[1] = b[1].max(x);
b[2] = b[2].min(y);
b[3] = b[3].max(y);
}
}
b
}
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,
}
}
}