//! 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 { 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, /// Fringe cells (Dirichlet `p`, `p'`). pub fringe_cells: Vec, /// Prescribed u faces with donors. pub fringe_u: Vec, /// Prescribed v faces with donors. pub fringe_v: Vec, /// 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, /// Hole–hole u faces the solver never stamps, with a widened-band /// donor: ghost velocities for the diagnostic. pub ghost_u: Vec, /// See `ghost_u`. pub ghost_v: Vec, /// Acceptor cells on the patch's outer row. pub acceptors: Vec, /// 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 { 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 hole–hole faces around them, with // donors from a band widened three rows into the hole, so every // fringe–hole 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 = 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 { 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 { 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 { 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, 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, 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, ) -> 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 (Chesshire–Henshaw 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 Gauss–Seidel 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) -> Vec { 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, b: Option| 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, c: f64, pl: Option, 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 { 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> { patch.periodic()?; Some( (0..patch.ns()) .map(|i| patch.node_xy(patch.node(0, i))) .collect(), ) } /// Even–odd 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::() - x; let py = (0..4).map(|a| n[a] * q[a][1]).sum::() - 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::() - x; let py = (0..4).map(|a| n[a] * q[a][1]).sum::() - 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>, 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 { 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 { 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 } }