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