Files
rustytorch/crates/specialized/rtx-cfd/src/solvers/incompressible/overset/overlap.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

1035 lines
40 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.
//! The overlap between the fixed background grid and the curvilinear patch
//! (overset A-P2, `docs/overset_metal_campaign.md` §2.1, §5.9).
//!
//! Background cells are classified from the patch's own indices — no
//! signed-distance field: a cell whose centre lies inside the body (the
//! patch's inner ring, when periodic) or inside a patch cell with
//! `k ≤ nn 1 overlap_rows` is a HOLE; a non-hole cell 4-adjacent to a
//! hole is FRINGE (no continuity equation; `p` and `p'` Dirichlet from the
//! patch; its faces that are not shared with an active cell prescribed
//! from the patch); everything else is ACTIVE. The patch's outer row of
//! cells (`k = nn 1`) are ACCEPTORS: `u, v, p` bilinear from the
//! background's staggered lattices, no momentum or continuity equation.
//!
//! Patch → background interpolation is bilinear in the DUAL quad — the four
//! cell centres `(k,i) (k,i+1) (k+1,i+1) (k+1,i)` — by inverse bilinear
//! mapping (Newton); background → patch is bilinear on each staggered
//! lattice. Both second order (`tests/overset_interp.rs`).
//!
//! Two invariants are asserted at build, so a thin patch fails loudly
//! instead of coupling acceptors to acceptors: every fringe donor quad
//! uses patch cells `k ≤ nn 2` (never an acceptor), and every acceptor
//! donor lattice node is an active cell / a fluid face. The depth budget
//! behind `overlap_rows`: from the patch's outer boundary inward, the
//! acceptor centre sits ½ outer cell in, its bilinear stencil reaches one
//! background cell further, the fringe ring is one background cell thick,
//! and the outer curve's wobble adds its amplitude — about 2.9 h with the
//! outer spacing ≈ h. Three overlap rows (≈ 2.5 h with a 3× stretch) were
//! measured to fail exactly there (acceptor 32's p donor landed on a fringe
//! cell); four rows (≈ 3.2 h) is the default.
use crate::error::{CfdError, CfdResult};
use crate::mesh::PatchMesh;
use crate::solvers::incompressible::embedded_body::{EmbeddedMask, FaceKind};
use crate::solvers::incompressible::flow_field::FlowField;
/// Background cell class.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CellClass {
/// Carries continuity; its pressure is an unknown.
Active,
/// Ring around the hole: Dirichlet `p`, prescribed outer faces.
Fringe,
/// Under the patch (or in the body): never read.
Hole,
}
/// Bilinear weights on four patch cells (a dual quad).
#[derive(Debug, Clone, Copy)]
pub struct DualDonor {
/// The four cells, in the dual quad's order.
pub cells: [usize; 4],
/// Their weights (sum to one).
pub w: [f64; 4],
}
/// Bilinear weights on four lattice nodes of a staggered field.
#[derive(Debug, Clone, Copy)]
pub struct LatticeDonor {
/// Row and column of the lower-left node.
pub j0: usize,
/// See `j0`.
pub i0: usize,
/// Weights for `(j0,i0) (j0,i0+1) (j0+1,i0) (j0+1,i0+1)`.
pub w: [f64; 4],
}
impl LatticeDonor {
#[inline]
fn value(&self, m: &nalgebra::DMatrix<f64>) -> f64 {
let (j, i) = (self.j0, self.i0);
self.w[0] * m[(j, i)]
+ self.w[1] * m[(j, i + 1)]
+ self.w[2] * m[(j + 1, i)]
+ self.w[3] * m[(j + 1, i + 1)]
}
}
/// A background fringe cell or face with its patch donor.
#[derive(Debug, Clone, Copy)]
pub struct FringeEntry {
/// Row.
pub j: usize,
/// Column.
pub i: usize,
/// Donor.
pub donor: DualDonor,
}
/// A patch acceptor cell with its background donors.
#[derive(Debug, Clone, Copy)]
pub struct Acceptor {
/// Patch cell index.
pub cell: usize,
/// Donor on the u lattice `(i dx, (j + ½) dy)`.
pub u: LatticeDonor,
/// Donor on the v lattice `((i + ½) dx, j dy)`.
pub v: LatticeDonor,
/// Donor on the cell-centre lattice.
pub p: LatticeDonor,
/// Donors of the background velocity at the acceptor's OUTER face
/// centre (u and v lattices), for the mass-defect measure.
pub outer_u: LatticeDonor,
/// See `outer_u`.
pub outer_v: LatticeDonor,
}
/// The classification and the donors of one patch position.
#[derive(Debug, Clone)]
pub struct OverlapMap {
nx: usize,
ny: usize,
dx: f64,
dy: f64,
class: Vec<CellClass>,
/// Fringe cells (Dirichlet `p`, `p'`).
pub fringe_cells: Vec<FringeEntry>,
/// Prescribed u faces with donors.
pub fringe_u: Vec<FringeEntry>,
/// Prescribed v faces with donors.
pub fringe_v: Vec<FringeEntry>,
/// Hole cells within two cells of the fringe that have a patch donor in
/// the widened band: ghost pressures for the momentum-residual
/// diagnostic (never read by the solver).
pub hole_p: Vec<FringeEntry>,
/// Holehole u faces the solver never stamps, with a widened-band
/// donor: ghost velocities for the diagnostic.
pub ghost_u: Vec<FringeEntry>,
/// See `ghost_u`.
pub ghost_v: Vec<FringeEntry>,
/// Acceptor cells on the patch's outer row.
pub acceptors: Vec<Acceptor>,
/// Patch rows searched for fringe donors (`nn 1 overlap_rows 1 ..= nn 2`).
pub donor_rows: (usize, usize),
hole_cells: usize,
}
/// Number of patch rows below the acceptor row that stay non-hole.
pub const DEFAULT_OVERLAP_ROWS: usize = 4;
impl OverlapMap {
/// Classify the `nx × ny` background of spacing `dx, dy` against
/// `patch`, with `overlap_rows` patch rows (below the acceptor row)
/// kept non-hole. Errors when a fringe cell has no interior donor or an
/// acceptor's donors are not all active (the patch is too thin or too
/// close to the domain boundary).
pub fn build(
patch: &PatchMesh,
nx: usize,
ny: usize,
dx: f64,
dy: f64,
overlap_rows: usize,
) -> CfdResult<Self> {
let (ns, nn) = (patch.ns(), patch.nn());
if nn < overlap_rows + 3 {
return Err(CfdError::mesh(format!(
"overset: patch needs nn >= overlap_rows + 3 = {}, got {nn}",
overlap_rows + 3
)));
}
let hole_row_max = nn - 1 - overlap_rows; // k <= this is hole
let primal = QuadIndex::primal(patch);
let body = body_polygon(patch);
// 1. Cells. A centre outside the patch's node bounding box lies in
// no patch cell and outside the body (which the patch encloses):
// Active without a point location (PERF-2 P1.2, the same class
// the search would return).
let bbox = patch.bounding_box();
let mut class = vec![CellClass::Active; nx * ny];
let mut hole_cells = 0;
for j in 0..ny {
for i in 0..nx {
let x = (i as f64 + 0.5) * dx;
let y = (j as f64 + 0.5) * dy;
if x < bbox[0] || x > bbox[1] || y < bbox[2] || y > bbox[3] {
continue;
}
let in_hole = match primal.locate(patch, x, y) {
Some(c) => patch.cell_ki(c).0 <= hole_row_max,
None => body
.as_ref()
.is_some_and(|poly| point_in_polygon(poly, x, y)),
};
if in_hole {
class[j * nx + i] = CellClass::Hole;
hole_cells += 1;
}
}
}
for j in 0..ny {
for i in 0..nx {
if class[j * nx + i] != CellClass::Hole {
let hole = |jj: usize, ii: usize| class[jj * nx + ii] == CellClass::Hole;
if (i > 0 && hole(j, i - 1))
|| (i + 1 < nx && hole(j, i + 1))
|| (j > 0 && hole(j - 1, i))
|| (j + 1 < ny && hole(j + 1, i))
{
class[j * nx + i] = CellClass::Fringe;
}
}
}
}
for j in 0..ny {
for i in 0..nx {
let c = class[j * nx + i];
if c != CellClass::Active && (i == 0 || j == 0 || i + 1 == nx || j + 1 == ny) {
return Err(CfdError::mesh(format!(
"overset: {c:?} cell ({j}, {i}) touches the domain boundary"
)));
}
}
}
// 2. Fringe donors in the dual quads of rows k ∈ [k_lo, nn 2].
let k_hi = nn - 2;
let k_lo = hole_row_max.saturating_sub(1);
let dual = QuadIndex::dual(patch, k_lo, k_hi);
let is_active = |jj: usize, ii: usize| class[jj * nx + ii] == CellClass::Active;
let mut fringe_cells = Vec::new();
for j in 0..ny {
for i in 0..nx {
if class[j * nx + i] == CellClass::Fringe {
let x = (i as f64 + 0.5) * dx;
let y = (j as f64 + 0.5) * dy;
let donor = dual.dual_donor(patch, x, y).ok_or_else(|| {
CfdError::mesh(format!(
"overset: fringe cell ({j}, {i}) at ({x:.4}, {y:.4}) has no interior \
patch donor in rows {k_lo}..={k_hi} — patch too thin"
))
})?;
fringe_cells.push(FringeEntry { j, i, donor });
}
}
}
// Diagnostic ghosts (never read by the solver): hole cells within
// two cells of the fringe and the holehole faces around them, with
// donors from a band widened three rows into the hole, so every
// fringehole face's momentum stencil reads a patch value.
let wide = QuadIndex::dual(patch, k_lo.saturating_sub(3), k_hi);
let near_fringe = |j: usize, i: usize| {
let lo_j = j.saturating_sub(2);
let lo_i = i.saturating_sub(2);
(lo_j..=(j + 2).min(ny - 1)).any(|jj| {
(lo_i..=(i + 2).min(nx - 1)).any(|ii| class[jj * nx + ii] == CellClass::Fringe)
})
};
let mut hole_p = Vec::new();
for j in 0..ny {
for i in 0..nx {
if class[j * nx + i] == CellClass::Hole && near_fringe(j, i) {
let (x, y) = ((i as f64 + 0.5) * dx, (j as f64 + 0.5) * dy);
if let Some(donor) = wide.dual_donor(patch, x, y) {
hole_p.push(FringeEntry { j, i, donor });
}
}
}
}
// Prescribed faces: interior faces with no active neighbour, that
// have a donor in the band (deeper ones are never read).
let mut fringe_u = Vec::new();
for j in 0..ny {
for i in 1..nx {
if !is_active(j, i - 1) && !is_active(j, i) {
let touches_fringe = class[j * nx + i - 1] == CellClass::Fringe
|| class[j * nx + i] == CellClass::Fringe;
let (x, y) = (i as f64 * dx, (j as f64 + 0.5) * dy);
match dual.dual_donor(patch, x, y) {
Some(donor) => fringe_u.push(FringeEntry { j, i, donor }),
None if touches_fringe => {
return Err(CfdError::mesh(format!(
"overset: fringe u face ({j}, {i}) has no interior patch donor"
)));
}
None => {}
}
}
}
}
let mut fringe_v = Vec::new();
for j in 1..ny {
for i in 0..nx {
if !is_active(j - 1, i) && !is_active(j, i) {
let touches_fringe = class[(j - 1) * nx + i] == CellClass::Fringe
|| class[j * nx + i] == CellClass::Fringe;
let (x, y) = ((i as f64 + 0.5) * dx, j as f64 * dy);
match dual.dual_donor(patch, x, y) {
Some(donor) => fringe_v.push(FringeEntry { j, i, donor }),
None if touches_fringe => {
return Err(CfdError::mesh(format!(
"overset: fringe v face ({j}, {i}) has no interior patch donor"
)));
}
None => {}
}
}
}
}
let hole = |jj: usize, ii: usize| class[jj * nx + ii] == CellClass::Hole;
let stamped_u: std::collections::HashSet<(usize, usize)> =
fringe_u.iter().map(|e| (e.j, e.i)).collect();
let stamped_v: std::collections::HashSet<(usize, usize)> =
fringe_v.iter().map(|e| (e.j, e.i)).collect();
let mut ghost_u = Vec::new();
for j in 0..ny {
for i in 1..nx {
if hole(j, i - 1) && hole(j, i) && (near_fringe(j, i - 1) || near_fringe(j, i)) {
let (x, y) = (i as f64 * dx, (j as f64 + 0.5) * dy);
if stamped_u.contains(&(j, i)) {
continue;
}
if let Some(donor) = wide.dual_donor(patch, x, y) {
ghost_u.push(FringeEntry { j, i, donor });
}
}
}
}
let mut ghost_v = Vec::new();
for j in 1..ny {
for i in 0..nx {
if hole(j - 1, i) && hole(j, i) && (near_fringe(j - 1, i) || near_fringe(j, i)) {
let (x, y) = ((i as f64 + 0.5) * dx, j as f64 * dy);
if stamped_v.contains(&(j, i)) {
continue;
}
if let Some(donor) = wide.dual_donor(patch, x, y) {
ghost_v.push(FringeEntry { j, i, donor });
}
}
}
}
// 3. Acceptors: patch row nn 1, lattice donors on the background.
let u_fluid = |jj: usize, ii: usize| {
// A u face is fluid unless both adjacent cells are non-active.
ii == 0 || ii == nx || is_active(jj, ii - 1) || is_active(jj, ii)
};
let v_fluid = |jj: usize, ii: usize| {
jj == 0 || jj == ny || is_active(jj - 1, ii) || is_active(jj, ii)
};
let mut acceptors = Vec::with_capacity(ns);
for i in 0..ns {
let cell = patch.cell(nn - 1, i);
let xy = patch.centre(cell);
let u = lattice_donor(xy, 0.0, 0.5, dx, dy, nx + 1, ny)?;
let v = lattice_donor(xy, 0.5, 0.0, dx, dy, nx, ny + 1)?;
let p = lattice_donor(xy, 0.5, 0.5, dx, dy, nx, ny)?;
let oc = patch.faces()[patch.nface(nn, i)].centre;
let outer_u = lattice_donor(oc, 0.0, 0.5, dx, dy, nx + 1, ny)?;
let outer_v = lattice_donor(oc, 0.5, 0.0, dx, dy, nx, ny + 1)?;
for (dj, di) in [(0, 0), (0, 1), (1, 0), (1, 1)] {
if !u_fluid(u.j0 + dj, u.i0 + di) {
return Err(CfdError::mesh(format!(
"overset: acceptor {i} u donor ({}, {}) is a prescribed face",
u.j0 + dj,
u.i0 + di
)));
}
if !v_fluid(v.j0 + dj, v.i0 + di) {
return Err(CfdError::mesh(format!(
"overset: acceptor {i} v donor ({}, {}) is a prescribed face",
v.j0 + dj,
v.i0 + di
)));
}
if !is_active(p.j0 + dj, p.i0 + di) {
return Err(CfdError::mesh(format!(
"overset: acceptor {i} p donor cell ({}, {}) is not active",
p.j0 + dj,
p.i0 + di
)));
}
}
acceptors.push(Acceptor {
cell,
u,
v,
p,
outer_u,
outer_v,
});
}
Ok(Self {
nx,
ny,
dx,
dy,
class,
fringe_cells,
fringe_u,
fringe_v,
hole_p,
ghost_u,
ghost_v,
acceptors,
donor_rows: (k_lo, k_hi),
hole_cells,
})
}
/// Class of background cell `(j, i)`.
pub fn class(&self, j: usize, i: usize) -> CellClass {
self.class[j * self.nx + i]
}
/// Number of hole cells.
pub fn hole_cells(&self) -> usize {
self.hole_cells
}
/// Number of fringe cells.
pub fn fringe_count(&self) -> usize {
self.fringe_cells.len()
}
/// Grid dimensions `(nx, ny, dx, dy)`.
pub fn grid(&self) -> (usize, usize, f64, f64) {
(self.nx, self.ny, self.dx, self.dy)
}
/// The background mask for the embedded solver: active cells fluid;
/// a face is `Fluid` unless both adjacent cells are non-active, in
/// which case it is prescribed (`Ghost`, with no reconstruction data —
/// its value is stamped from the patch).
pub fn background_mask(&self) -> EmbeddedMask {
let (nx, ny) = (self.nx, self.ny);
let cell_fluid: Vec<bool> = self.class.iter().map(|&c| c == CellClass::Active).collect();
let active = |j: usize, i: usize| cell_fluid[j * nx + i];
let mut u_kind = vec![FaceKind::Fluid; ny * (nx + 1)];
for j in 0..ny {
for i in 1..nx {
if !active(j, i - 1) && !active(j, i) {
u_kind[j * (nx + 1) + i] = FaceKind::Ghost;
}
}
}
let mut v_kind = vec![FaceKind::Fluid; (ny + 1) * nx];
for j in 1..ny {
for i in 0..nx {
if !active(j - 1, i) && !active(j, i) {
v_kind[j * nx + i] = FaceKind::Ghost;
}
}
}
EmbeddedMask::from_classification(nx, ny, self.dx, self.dy, cell_fluid, u_kind, v_kind)
}
/// Per-cell Dirichlet flags for the background projection: `true` on
/// fringe cells.
pub fn fringe_flags(&self) -> Vec<bool> {
self.class.iter().map(|&c| c == CellClass::Fringe).collect()
}
/// Interpolate a patch cell field to the fringe cells (order of
/// `fringe_cells`).
pub fn fringe_cell_values(&self, patch_vals: &[f64]) -> Vec<f64> {
self.fringe_cells
.iter()
.map(|e| dual_value(&e.donor, patch_vals))
.collect()
}
/// Interpolate a patch cell field to the hole ghost cells (order of
/// `hole_p`).
pub fn hole_p_values(&self, patch_vals: &[f64]) -> Vec<f64> {
self.hole_p
.iter()
.map(|e| dual_value(&e.donor, patch_vals))
.collect()
}
/// Stamp `values` (from [`Self::hole_p_values`]) onto a background
/// cell field.
pub fn stamp_hole_p(&self, target: &mut nalgebra::DMatrix<f64>, values: &[f64]) {
for (e, &v) in self.hole_p.iter().zip(values) {
target[(e.j, e.i)] = v;
}
}
/// Stamp the diagnostic ghost faces (`ghost_u`, `ghost_v`) from the
/// patch cell velocities, onto both `u`/`v` and `u_old`/`v_old`.
pub fn stamp_ghost_faces(&self, field: &mut FlowField, patch_u: &[f64], patch_v: &[f64]) {
for e in &self.ghost_u {
let v = dual_value(&e.donor, patch_u);
field.u[(e.j, e.i)] = v;
field.u_old[(e.j, e.i)] = v;
}
for e in &self.ghost_v {
let v = dual_value(&e.donor, patch_v);
field.v[(e.j, e.i)] = v;
field.v_old[(e.j, e.i)] = v;
}
}
/// Stamp `values` (from [`Self::fringe_cell_values`]) onto a
/// background cell field.
pub fn stamp_fringe_cells(&self, target: &mut nalgebra::DMatrix<f64>, values: &[f64]) {
for (e, &v) in self.fringe_cells.iter().zip(values) {
target[(e.j, e.i)] = v;
}
}
/// Stamp the prescribed u and v faces of the background from the patch
/// cell velocities.
pub fn stamp_fringe_faces(&self, field: &mut FlowField, patch_u: &[f64], patch_v: &[f64]) {
for e in &self.fringe_u {
field.u[(e.j, e.i)] = dual_value(&e.donor, patch_u);
}
for e in &self.fringe_v {
field.v[(e.j, e.i)] = dual_value(&e.donor, patch_v);
}
}
/// `(u, v, p)` at every acceptor from the background field (order of
/// `acceptors`); `p_source` selects which cell field supplies the
/// pressure-like value.
pub fn acceptor_values(
&self,
field: &FlowField,
p_source: &nalgebra::DMatrix<f64>,
) -> Vec<(f64, f64, f64)> {
self.acceptors
.iter()
.map(|a| {
(
a.u.value(&field.u),
a.v.value(&field.v),
a.p.value(p_source),
)
})
.collect()
}
/// The background velocity at every acceptor's outer face centre.
pub fn acceptor_outer_velocity(&self, field: &FlowField) -> Vec<(f64, f64)> {
self.acceptors
.iter()
.map(|a| (a.outer_u.value(&field.u), a.outer_v.value(&field.v)))
.collect()
}
/// Flux balance at the fringe (ChesshireHenshaw in spirit): make every
/// fringe cell divergence-free by adjusting only its PRESCRIBED faces
/// (never a face shared with an active cell), spreading each cell's
/// imbalance over them by face length, in GaussSeidel sweeps (a face
/// shared by two fringe cells is corrected by both) until the largest
/// fringe-cell imbalance is below `tol` (volume flux) or `max_sweeps`
/// is reached. Returns `(sweeps, worst imbalance)`. This is what removes
/// the reclassification impulse of A-P3: with the fringe ring a
/// staircase of the interpolated velocities' mass defect, every
/// row flip injected that defect in one step (§5.10).
pub fn balance_fringe_fluxes(
&self,
field: &mut FlowField,
tol: f64,
max_sweeps: usize,
) -> (usize, f64) {
let (nx, ny, dx, dy) = (self.nx, self.ny, self.dx, self.dy);
let is_prescribed_u: std::collections::HashSet<(usize, usize)> =
self.fringe_u.iter().map(|e| (e.j, e.i)).collect();
let is_prescribed_v: std::collections::HashSet<(usize, usize)> =
self.fringe_v.iter().map(|e| (e.j, e.i)).collect();
let _ = (nx, ny);
let mut worst = f64::INFINITY;
let mut sweeps = 0usize;
while sweeps < max_sweeps && worst > tol {
sweeps += 1;
worst = 0.0;
for e in &self.fringe_cells {
let (j, i) = (e.j, e.i);
let div = (field.u[(j, i + 1)] - field.u[(j, i)]) * dy
+ (field.v[(j + 1, i)] - field.v[(j, i)]) * dx;
// Prescribed faces of this cell with their outward sign and length.
let mut faces: Vec<(bool, usize, usize, f64, f64)> = Vec::with_capacity(4);
if is_prescribed_u.contains(&(j, i + 1)) {
faces.push((true, j, i + 1, 1.0, dy));
}
if is_prescribed_u.contains(&(j, i)) {
faces.push((true, j, i, -1.0, dy));
}
if is_prescribed_v.contains(&(j + 1, i)) {
faces.push((false, j + 1, i, 1.0, dx));
}
if is_prescribed_v.contains(&(j, i)) {
faces.push((false, j, i, -1.0, dx));
}
let total_len: f64 = faces.iter().map(|f| f.4).sum();
if total_len == 0.0 {
worst = worst.max(div.abs());
continue;
}
for (is_u, jj, ii, sign, len) in faces {
// outward flux change on this face = div · len / total_len
let dvel = -sign * div / total_len;
if is_u {
field.u[(jj, ii)] += dvel;
} else {
field.v[(jj, ii)] += dvel;
}
let _ = len;
}
}
for e in &self.fringe_cells {
let (j, i) = (e.j, e.i);
let div = (field.u[(j, i + 1)] - field.u[(j, i)]) * dy
+ (field.v[(j + 1, i)] - field.v[(j, i)]) * dx;
worst = worst.max(div.abs());
}
}
(sweeps, worst)
}
/// A cell-centred background scalar (e.g. `p'`) at every acceptor.
pub fn acceptor_scalar(&self, m: &nalgebra::DMatrix<f64>) -> Vec<f64> {
self.acceptors.iter().map(|a| a.p.value(m)).collect()
}
/// Background-side overlap mass defect: `Σ_fringe |Σ_f sign F_f|`
/// (volume flux), the continuity the fringe cells do not enforce.
/// The force the background transmits INTO the region of cells whose
/// class satisfies `inside` — the sum over the region's boundary faces
/// of `sigma·n rho u (u·n)` with `n` pointing out of the region, the
/// control-volume formula of `EmbeddedMask::control_volume_force`
/// without the unsteady term (a settled-state diagnostic). Values are
/// taken from non-hole cells only: a face next to a hole cell uses the
/// one-sided stencil from its valid side, so the hole boundary itself
/// (`inside = Hole`) is evaluated from the fringe's stamped values.
///
/// Two regions make the P4 momentum-defect measurement: `Fringe |
/// Hole` (what the active region passes to the ring) and `Hole` (what
/// the ring passes on); their difference is the fringe ring's momentum
/// defect, and the hole boundary against the patch's wall force is the
/// patch region's.
pub fn region_force(
&self,
field: &FlowField,
rho: f64,
mu: f64,
inside: impl Fn(CellClass) -> bool,
) -> (f64, f64) {
let (nx, ny, dx, dy) = (self.nx, self.ny, self.dx, self.dy);
let valid = |j: isize, i: isize| -> bool {
j >= 0
&& i >= 0
&& (j as usize) < ny
&& (i as usize) < nx
&& self.class(j as usize, i as usize) != CellClass::Hole
};
let is_in = |j: isize, i: isize| -> bool {
j >= 0
&& i >= 0
&& (j as usize) < ny
&& (i as usize) < nx
&& inside(self.class(j as usize, i as usize))
};
// Face-located u is valid when either adjacent cell is; likewise v.
let uf_valid = |j: isize, i: isize| valid(j, i - 1) || valid(j, i);
let vf_valid = |j: isize, i: isize| valid(j - 1, i) || valid(j, i);
let u = |j: isize, i: isize| field.u[(j as usize, i as usize)];
let v = |j: isize, i: isize| field.v[(j as usize, i as usize)];
let p = |j: isize, i: isize| field.p[(j as usize, i as usize)];
// Cell-centred v and u (averages of the cell's two faces).
let v_c = |j: isize, i: isize| 0.5 * (v(j, i) + v(j + 1, i));
let u_c = |j: isize, i: isize| 0.5 * (u(j, i) + u(j, i + 1));
// Average of the valid members of a pair, or `None`.
let pair = |a: Option<f64>, b: Option<f64>| match (a, b) {
(Some(a), Some(b)) => Some(0.5 * (a + b)),
(Some(a), None) | (None, Some(a)) => Some(a),
(None, None) => None,
};
// Derivative across `x0 → x1 → x2` (spacing `h`): central when both
// ends are valid, one-sided otherwise, zero when nothing is.
let deriv = |m: Option<f64>, c: f64, pl: Option<f64>, h: f64| match (m, pl) {
(Some(m), Some(pl)) => (pl - m) / (2.0 * h),
(Some(m), None) => (c - m) / h,
(None, Some(pl)) => (pl - c) / h,
(None, None) => 0.0,
};
let (mut fx, mut fy) = (0.0, 0.0);
for jc in 0..ny as isize {
for ic in 0..nx as isize {
if !is_in(jc, ic) {
continue;
}
// Vertical faces: west (u face ic, n = x) and east (ic + 1, +x).
for (i, sign, nj, ni) in [(ic, -1.0, jc, ic - 1), (ic + 1, 1.0, jc, ic + 1)] {
if is_in(nj, ni) || nj < 0 || ni < 0 || ni >= nx as isize {
continue;
}
let j = jc;
let un = u(j, i);
let p_f = pair(
valid(j, i - 1).then(|| p(j, i - 1)),
valid(j, i).then(|| p(j, i)),
)
.unwrap_or(0.0);
let dudx = deriv(
uf_valid(j, i - 1).then(|| u(j, i - 1)),
un,
(i < nx as isize && uf_valid(j, i + 1)).then(|| u(j, i + 1)),
dx,
);
let dudy = deriv(
(j >= 1 && uf_valid(j - 1, i)).then(|| u(j - 1, i)),
un,
(j + 1 < ny as isize && uf_valid(j + 1, i)).then(|| u(j + 1, i)),
dy,
);
let vw = valid(j, i - 1).then(|| v_c(j, i - 1));
let ve = valid(j, i).then(|| v_c(j, i));
let dvdx = match (vw, ve) {
(Some(a), Some(b)) => (b - a) / dx,
(Some(a), None) => {
(a - if valid(j, i - 2) { v_c(j, i - 2) } else { a }) / dx
}
(None, Some(b)) => {
((if valid(j, i + 1) { v_c(j, i + 1) } else { b }) - b) / dx
}
(None, None) => 0.0,
};
let v_f = pair(vw, ve).unwrap_or(0.0);
let sxx = -p_f + 2.0 * mu * dudx;
let sxy = mu * (dudy + dvdx);
fx += sign * (sxx - rho * un * un) * dy;
fy += sign * (sxy - rho * v_f * un) * dy;
}
// Horizontal faces: south (v face jc, n = y) and north (jc + 1, +y).
for (j, sign, nj, ni) in [(jc, -1.0, jc - 1, ic), (jc + 1, 1.0, jc + 1, ic)] {
if is_in(nj, ni) || nj < 0 || nj >= ny as isize {
continue;
}
let i = ic;
let vn = v(j, i);
let p_f = pair(
valid(j - 1, i).then(|| p(j - 1, i)),
valid(j, i).then(|| p(j, i)),
)
.unwrap_or(0.0);
let dvdy = deriv(
vf_valid(j - 1, i).then(|| v(j - 1, i)),
vn,
(j < ny as isize && vf_valid(j + 1, i)).then(|| v(j + 1, i)),
dy,
);
let dvdx = deriv(
(i >= 1 && vf_valid(j, i - 1)).then(|| v(j, i - 1)),
vn,
(i + 1 < nx as isize && vf_valid(j, i + 1)).then(|| v(j, i + 1)),
dx,
);
let us = valid(j - 1, i).then(|| u_c(j - 1, i));
let un_ = valid(j, i).then(|| u_c(j, i));
let dudy = match (us, un_) {
(Some(a), Some(b)) => (b - a) / dy,
(Some(a), None) => {
(a - if valid(j - 2, i) { u_c(j - 2, i) } else { a }) / dy
}
(None, Some(b)) => {
((if valid(j + 1, i) { u_c(j + 1, i) } else { b }) - b) / dy
}
(None, None) => 0.0,
};
let u_f = pair(us, un_).unwrap_or(0.0);
let syy = -p_f + 2.0 * mu * dvdy;
let sxy = mu * (dudy + dvdx);
fx += sign * (sxy - rho * u_f * vn) * dx;
fy += sign * (syy - rho * vn * vn) * dx;
}
}
}
(fx, fy)
}
pub fn background_mass_defect(&self, field: &FlowField) -> f64 {
let (dx, dy) = (self.dx, self.dy);
self.fringe_cells
.iter()
.map(|e| {
let (j, i) = (e.j, e.i);
((field.u[(j, i + 1)] - field.u[(j, i)]) * dy
+ (field.v[(j + 1, i)] - field.v[(j, i)]) * dx)
.abs()
})
.sum()
}
}
#[inline]
fn dual_value(d: &DualDonor, vals: &[f64]) -> f64 {
d.w[0] * vals[d.cells[0]]
+ d.w[1] * vals[d.cells[1]]
+ d.w[2] * vals[d.cells[2]]
+ d.w[3] * vals[d.cells[3]]
}
/// Bilinear donor of point `xy` on a lattice whose node `(j, i)` sits at
/// `((i + ox) dx, (j + oy) dy)`, with `cols × rows` nodes.
fn lattice_donor(
xy: [f64; 2],
ox: f64,
oy: f64,
dx: f64,
dy: f64,
cols: usize,
rows: usize,
) -> CfdResult<LatticeDonor> {
let fx = xy[0] / dx - ox;
let fy = xy[1] / dy - oy;
if fx < 0.0 || fy < 0.0 || fx >= (cols - 1) as f64 || fy >= (rows - 1) as f64 {
return Err(CfdError::mesh(format!(
"overset: acceptor at ({:.4}, {:.4}) lies outside the background lattice",
xy[0], xy[1]
)));
}
let i0 = fx.floor() as usize;
let j0 = fy.floor() as usize;
let (a, b) = (fx - i0 as f64, fy - j0 as f64);
Ok(LatticeDonor {
j0,
i0,
w: [(1.0 - a) * (1.0 - b), a * (1.0 - b), (1.0 - a) * b, a * b],
})
}
/// The patch's inner ring as a closed polygon (periodic patches only).
fn body_polygon(patch: &PatchMesh) -> Option<Vec<[f64; 2]>> {
patch.periodic()?;
Some(
(0..patch.ns())
.map(|i| patch.node_xy(patch.node(0, i)))
.collect(),
)
}
/// Evenodd point-in-polygon.
fn point_in_polygon(poly: &[[f64; 2]], x: f64, y: f64) -> bool {
let mut inside = false;
let n = poly.len();
for a in 0..n {
let (p, q) = (poly[a], poly[(a + 1) % n]);
if (p[1] > y) != (q[1] > y) {
let xi = p[0] + (y - p[1]) / (q[1] - p[1]) * (q[0] - p[0]);
if x < xi {
inside = !inside;
}
}
}
inside
}
/// Is `xy` inside the convex quad `q` (counter-clockwise)?
fn point_in_quad(q: &[[f64; 2]; 4], x: f64, y: f64, tol: f64) -> bool {
(0..4).all(|a| {
let (p, r) = (q[a], q[(a + 1) % 4]);
(r[0] - p[0]) * (y - p[1]) - (r[1] - p[1]) * (x - p[0]) >= -tol
})
}
/// Bilinear weights of `xy` in the quad `q` (corners in the order
/// `(0,0) (1,0) (1,1) (0,1)`), by Newton on the inverse map; `None` if
/// Newton does not converge in 12 steps.
pub fn inverse_bilinear(q: &[[f64; 2]; 4], x: f64, y: f64) -> Option<[f64; 4]> {
let (mut s, mut t) = (0.5, 0.5);
let scale = (0..4)
.map(|a| (q[a][0] - q[0][0]).abs().max((q[a][1] - q[0][1]).abs()))
.fold(0.0, f64::max)
.max(1e-300);
for _ in 0..12 {
let n = [(1.0 - s) * (1.0 - t), s * (1.0 - t), s * t, (1.0 - s) * t];
let px = (0..4).map(|a| n[a] * q[a][0]).sum::<f64>() - x;
let py = (0..4).map(|a| n[a] * q[a][1]).sum::<f64>() - y;
if px.abs().max(py.abs()) <= 1e-14 * scale {
return Some(n);
}
// Jacobian d(px,py)/d(s,t).
let dxs = -(1.0 - t) * q[0][0] + (1.0 - t) * q[1][0] + t * q[2][0] - t * q[3][0];
let dys = -(1.0 - t) * q[0][1] + (1.0 - t) * q[1][1] + t * q[2][1] - t * q[3][1];
let dxt = -(1.0 - s) * q[0][0] - s * q[1][0] + s * q[2][0] + (1.0 - s) * q[3][0];
let dyt = -(1.0 - s) * q[0][1] - s * q[1][1] + s * q[2][1] + (1.0 - s) * q[3][1];
let det = dxs * dyt - dxt * dys;
if det.abs() <= 1e-300 {
return None;
}
s -= (px * dyt - dxt * py) / det;
t -= (dxs * py - px * dys) / det;
}
let n = [(1.0 - s) * (1.0 - t), s * (1.0 - t), s * t, (1.0 - s) * t];
let px = (0..4).map(|a| n[a] * q[a][0]).sum::<f64>() - x;
let py = (0..4).map(|a| n[a] * q[a][1]).sum::<f64>() - y;
(px.abs().max(py.abs()) <= 1e-12 * scale).then_some(n)
}
/// Uniform bins over a set of quads for point location.
struct QuadIndex {
quads: Vec<([[f64; 2]; 4], [usize; 4])>,
x0: f64,
y0: f64,
bw: f64,
bh: f64,
nbx: usize,
nby: usize,
bins: Vec<Vec<usize>>,
tol: f64,
}
impl QuadIndex {
/// The primal cells: corners are nodes, payload the cell index (×4).
fn primal(patch: &PatchMesh) -> Self {
let (ns, nn) = (patch.ns(), patch.nn());
let mut quads = Vec::with_capacity(ns * nn);
for k in 0..nn {
for i in 0..ns {
let n = [
patch.node(k, i),
patch.node(k, i + 1),
patch.node(k + 1, i + 1),
patch.node(k + 1, i),
];
let c = patch.cell(k, i);
quads.push((n.map(|nd| patch.node_xy(nd)), [c; 4]));
}
}
Self::new(quads)
}
/// The dual quads of rows `k_lo..=k_hi` (corners are cell centres,
/// payload the four cells), periodic wrap in `i`.
fn dual(patch: &PatchMesh, k_lo: usize, k_hi: usize) -> Self {
let (ns, nn) = (patch.ns(), patch.nn());
let shift = patch.periodic();
let cols = if shift.is_some() { ns } else { ns - 1 };
let mut quads = Vec::new();
for k in k_lo..=k_hi.min(nn - 2) {
for i in 0..cols {
let i1 = (i + 1) % ns;
let wrap = shift.filter(|_| i1 == 0).unwrap_or([0.0; 2]);
let cells = [
patch.cell(k, i),
patch.cell(k, i1),
patch.cell(k + 1, i1),
patch.cell(k + 1, i),
];
let mut pts = cells.map(|c| patch.centre(c));
pts[1] = [pts[1][0] + wrap[0], pts[1][1] + wrap[1]];
pts[2] = [pts[2][0] + wrap[0], pts[2][1] + wrap[1]];
quads.push((pts, cells));
}
}
Self::new(quads)
}
fn new(quads: Vec<([[f64; 2]; 4], [usize; 4])>) -> Self {
let (mut x0, mut y0, mut x1, mut y1) = (
f64::INFINITY,
f64::INFINITY,
f64::NEG_INFINITY,
f64::NEG_INFINITY,
);
let mut hmax = 0.0_f64;
for (q, _) in &quads {
for p in q {
x0 = x0.min(p[0]);
y0 = y0.min(p[1]);
x1 = x1.max(p[0]);
y1 = y1.max(p[1]);
}
for a in 0..4 {
let (p, r) = (q[a], q[(a + 1) % 4]);
hmax = hmax.max(((r[0] - p[0]).powi(2) + (r[1] - p[1]).powi(2)).sqrt());
}
}
let n = quads.len().max(1);
let side = ((n as f64).sqrt().ceil() as usize).max(1);
let bw = ((x1 - x0) / side as f64).max(1e-300);
let bh = ((y1 - y0) / side as f64).max(1e-300);
let mut bins = vec![Vec::new(); side * side];
for (idx, (q, _)) in quads.iter().enumerate() {
let (mut bx0, mut by0, mut bx1, mut by1) = (usize::MAX, usize::MAX, 0, 0);
for p in q {
let bx = (((p[0] - x0) / bw).floor() as usize).min(side - 1);
let by = (((p[1] - y0) / bh).floor() as usize).min(side - 1);
bx0 = bx0.min(bx);
by0 = by0.min(by);
bx1 = bx1.max(bx);
by1 = by1.max(by);
}
for by in by0..=by1 {
for bx in bx0..=bx1 {
bins[by * side + bx].push(idx);
}
}
}
Self {
quads,
x0,
y0,
bw,
bh,
nbx: side,
nby: side,
bins,
tol: 1e-12 * hmax * hmax,
}
}
fn candidates(&self, x: f64, y: f64) -> &[usize] {
let fx = (x - self.x0) / self.bw;
let fy = (y - self.y0) / self.bh;
if fx < 0.0 || fy < 0.0 || fx >= self.nbx as f64 || fy >= self.nby as f64 {
return &[];
}
&self.bins[(fy as usize) * self.nbx + fx as usize]
}
/// The primal cell containing `(x, y)`.
fn locate(&self, _patch: &PatchMesh, x: f64, y: f64) -> Option<usize> {
self.candidates(x, y)
.iter()
.find(|&&q| point_in_quad(&self.quads[q].0, x, y, self.tol))
.map(|&q| self.quads[q].1[0])
}
/// The dual donor of `(x, y)`.
fn dual_donor(&self, _patch: &PatchMesh, x: f64, y: f64) -> Option<DualDonor> {
for &q in self.candidates(x, y) {
let (pts, cells) = &self.quads[q];
if point_in_quad(pts, x, y, self.tol) {
let w = inverse_bilinear(pts, x, y)?;
return Some(DualDonor { cells: *cells, w });
}
}
None
}
}