//! Geometry for embedded (immersed) bodies on the fixed staggered grid: //! signed-distance descriptions, the fluid/ghost/solid classification of //! cells and faces, the ghost-face velocity imposition, and the two load //! routes the embedded solver's verification leans on. //! //! # The method, in one paragraph //! //! A body is a signed-distance function `phi(x, y, t)`, negative inside the //! solid, with a surface-velocity function for the no-slip value on its //! boundary. A pressure cell is *fluid* when `phi` at its centre is //! positive. A velocity face is *fluid* — an unknown of the momentum and //! continuity equations — only when both cells it separates are fluid. //! Every other interior face is prescribed: a *ghost* face, within reach of //! the fluid, takes the value a linear velocity profile along the surface //! normal would have there (the profile pinned to the surface velocity at //! the foot of the normal and to the interpolated fluid velocity at a probe //! point one cell beyond the ghost's mirror image); a deep *solid* face //! takes the surface velocity itself and is never read by a fluid stencil. //! The fluid stencils need no special casing at all: diffusion and //! convection at a fluid face read whatever its neighbours hold, and the //! ghost values encode where the wall actually is. This is the sharp- //! interface ghost-cell family (Tseng–Ferziger 2003, Mittal et al. 2008) //! transcribed onto the MAC layout; it is the incompressible counterpart of //! Farhat's embedded-boundary line, not FIVER itself. //! //! # Compatibility //! //! Prescribed faces carry the mass flux continuity sees. With every domain //! side prescribed the projection is a pure Neumann problem, solvable only //! if the net flux through all prescribed faces vanishes; extrapolated //! ghost values do not sum to zero around a body on their own. [`EmbeddedMask::impose`] //! therefore removes the net ghost flux of each step uniformly from the //! ghost faces that bound the fluid (the target net flux of a rigid body is //! exactly zero). The correction is reported so a test can watch its size //! fall with the mesh; it is not a hidden mean-source subtraction on the //! Poisson equation. //! //! # Limits (stated before results) //! //! Bodies must not touch the domain boundary (the outer ring of cells must //! be fluid — checked). A body thinner than about two cells has ghost faces //! on both sides whose probes reach across it; the classification still //! works, the reconstruction degrades. use crate::{CfdError, CfdResult}; use nalgebra::DMatrix; type ScalarFn = Box f64 + Send + Sync>; type VectorFn = Box (f64, f64) + Send + Sync>; /// One sample of a body's surface, for load integration. #[derive(Debug, Clone, Copy)] pub struct SurfaceSample { /// Point on the surface. pub x: f64, /// Point on the surface. pub y: f64, /// Unit normal pointing out of the solid into the fluid. pub nx: f64, /// Unit normal pointing out of the solid into the fluid. pub ny: f64, /// Arc length the sample represents. pub ds: f64, } /// A rigid or prescribed-motion body embedded in the grid. pub struct EmbeddedBody { sdf: ScalarFn, surface_velocity: VectorFn, /// Surface sampler at a requested spacing; `None` for a bare SDF body /// (loads by surface reconstruction are then unavailable). sampler: Option Vec + Send + Sync>>, } impl EmbeddedBody { /// A body from its signed distance function (negative inside), at rest. pub fn from_sdf(sdf: F) -> Self where F: Fn(f64, f64, f64) -> f64 + Send + Sync + 'static, { Self { sdf: Box::new(sdf), surface_velocity: Box::new(|_, _, _| (0.0, 0.0)), sampler: None, } } /// Prescribe the surface velocity `(x, y, t) -> (u, v)`; the default is /// rest. For a manufactured solution this is the exact field, evaluated /// wherever the mask asks. #[must_use] pub fn with_surface_velocity(mut self, f: F) -> Self where F: Fn(f64, f64, f64) -> (f64, f64) + Send + Sync + 'static, { self.surface_velocity = Box::new(f); self } /// A fixed circle. pub fn circle(cx: f64, cy: f64, r: f64) -> Self { let mut body = Self::from_sdf(move |x, y, _| ((x - cx).powi(2) + (y - cy).powi(2)).sqrt() - r); body.sampler = Some(Box::new(move |ds| { let n = ((2.0 * std::f64::consts::PI * r / ds).ceil() as usize).max(8); let dtheta = 2.0 * std::f64::consts::PI / n as f64; (0..n) .map(|k| { let theta = (k as f64 + 0.5) * dtheta; let (s, c) = theta.sin_cos(); SurfaceSample { x: cx + r * c, y: cy + r * s, nx: c, ny: s, ds: r * dtheta, } }) .collect() })); body } /// A fixed axis-aligned rectangle `[x0, x1] x [y0, y1]` (exact SDF). pub fn rectangle(x0: f64, y0: f64, x1: f64, y1: f64) -> Self { let (cx, cy) = (0.5 * (x0 + x1), 0.5 * (y0 + y1)); let (hx, hy) = (0.5 * (x1 - x0), 0.5 * (y1 - y0)); let mut body = Self::from_sdf(move |x, y, _| { let qx = (x - cx).abs() - hx; let qy = (y - cy).abs() - hy; let outside = (qx.max(0.0).powi(2) + qy.max(0.0).powi(2)).sqrt(); outside + qx.max(qy).min(0.0) }); body.sampler = Some(Box::new(move |ds| { let mut out = Vec::new(); let mut edge = |ax: f64, ay: f64, bx: f64, by: f64, nx: f64, ny: f64| { let len = ((bx - ax).powi(2) + (by - ay).powi(2)).sqrt(); let n = ((len / ds).ceil() as usize).max(1); for k in 0..n { let s = (k as f64 + 0.5) / n as f64; out.push(SurfaceSample { x: ax + s * (bx - ax), y: ay + s * (by - ay), nx, ny, ds: len / n as f64, }); } }; edge(x0, y0, x1, y0, 0.0, -1.0); edge(x1, y0, x1, y1, 1.0, 0.0); edge(x1, y1, x0, y1, 0.0, 1.0); edge(x0, y1, x0, y0, -1.0, 0.0); out })); body } /// A closed polygon (vertices in order, either winding), at rest. The /// signed distance is exact (min distance to the edges, sign by even-odd /// ray crossing); the sampler walks the edges with outward normals. A /// coupling loop can rebuild the body each subiteration from a deformed /// structure boundary — or share the vertex list behind a lock and let /// the moving-body path pick the new shape up on its per-step rebuild. pub fn polygon(vertices: Vec<(f64, f64)>) -> Self { assert!(vertices.len() >= 3, "a polygon needs at least 3 vertices"); // Signed area decides which perpendicular points outward. let signed_area: f64 = vertices .iter() .zip(vertices.iter().cycle().skip(1)) .map(|(a, b)| a.0 * b.1 - b.0 * a.1) .take(vertices.len()) .sum::() * 0.5; let ccw = signed_area > 0.0; // Indexed query, bit-identical to `polygon_signed_distance` // (asserted in polygon_sdf's tests) — the SDF is the measured // hot function of the embedded mask rebuild. let sdf = super::polygon_sdf::PolygonSdf::new(vertices.clone()); let mut body = Self::from_sdf(move |x, y, _| sdf.signed_distance(x, y)); let sampler_vertices = vertices; body.sampler = Some(Box::new(move |ds| { let n = sampler_vertices.len(); let mut out = Vec::new(); for k in 0..n { let (ax, ay) = sampler_vertices[k]; let (bx, by) = sampler_vertices[(k + 1) % n]; let (ex, ey) = (bx - ax, by - ay); let len = (ex * ex + ey * ey).sqrt(); if len == 0.0 { continue; } // Outward normal: right of the direction for CCW winding. let (mut nx, mut ny) = (ey / len, -ex / len); if !ccw { nx = -nx; ny = -ny; } let count = ((len / ds).ceil() as usize).max(1); for q in 0..count { let s = (q as f64 + 0.5) / count as f64; out.push(SurfaceSample { x: ax + s * ex, y: ay + s * ey, nx, ny, ds: len / count as f64, }); } } out })); body } /// Union of two bodies: the SDF is the minimum; the surface velocity and /// samples come from whichever body a point is closer to. Samples of one /// body lying inside the other are dropped. pub fn union(a: EmbeddedBody, b: EmbeddedBody) -> Self { let a = std::sync::Arc::new(a); let b = std::sync::Arc::new(b); let (a1, b1) = (a.clone(), b.clone()); let (a2, b2) = (a.clone(), b.clone()); let (a3, b3) = (a, b); let sampler: Box Vec + Send + Sync> = Box::new(move |ds| { let mut out: Vec = a3 .surface_samples(ds) .into_iter() .filter(|s| b3.phi(s.x, s.y, 0.0) > -1e-12) .collect(); out.extend( b3.surface_samples(ds) .into_iter() .filter(|s| a3.phi(s.x, s.y, 0.0) > -1e-12), ); out }); Self { sdf: Box::new(move |x, y, t| a1.phi(x, y, t).min(b1.phi(x, y, t))), surface_velocity: Box::new(move |x, y, t| { if a2.phi(x, y, t) <= b2.phi(x, y, t) { a2.surface_velocity(x, y, t) } else { b2.surface_velocity(x, y, t) } }), sampler: Some(sampler), } } /// Signed distance, negative inside the solid. pub fn phi(&self, x: f64, y: f64, t: f64) -> f64 { (self.sdf)(x, y, t) } /// Surface (no-slip) velocity. pub fn surface_velocity(&self, x: f64, y: f64, t: f64) -> (f64, f64) { (self.surface_velocity)(x, y, t) } /// Unit normal out of the solid (the SDF gradient), by central /// differences at spacing `eps`. pub fn normal(&self, x: f64, y: f64, t: f64, eps: f64) -> (f64, f64) { let gx = (self.phi(x + eps, y, t) - self.phi(x - eps, y, t)) / (2.0 * eps); let gy = (self.phi(x, y + eps, t) - self.phi(x, y - eps, t)) / (2.0 * eps); let norm = (gx * gx + gy * gy).sqrt(); if norm > 0.0 { (gx / norm, gy / norm) } else { (1.0, 0.0) } } /// Surface samples at roughly spacing `ds`; empty for a bare SDF body. pub fn surface_samples(&self, ds: f64) -> Vec { self.sampler.as_ref().map_or_else(Vec::new, |s| s(ds)) } } /// Force on a body by surface-stress reconstruction, with its bookkeeping. #[derive(Debug, Clone, Copy, PartialEq)] pub struct SurfaceForce { /// Force per unit depth. pub fx: f64, /// Force per unit depth. pub fy: f64, /// Surface samples integrated. pub samples: usize, /// Samples whose probes could not be reconstructed (no fluid cells to /// fit, e.g. deep in a concave corner) and were skipped. Non-zero means /// the load is missing a piece of surface; the caller decides whether /// that piece matters. pub skipped: usize, } /// Signed distance to a closed polygon (negative inside, either winding): /// minimum distance over the edges, sign by the even-odd ray-crossing rule. /// Public so a coupling loop can build a time-dependent body from a shared, /// mutating vertex list via [`EmbeddedBody::from_sdf`]. #[must_use] pub fn polygon_signed_distance(vertices: &[(f64, f64)], x: f64, y: f64) -> f64 { let n = vertices.len(); let mut dist2 = f64::MAX; let mut inside = false; for k in 0..n { let (ax, ay) = vertices[k]; let (bx, by) = vertices[(k + 1) % n]; let (ex, ey) = (bx - ax, by - ay); let len2 = ex * ex + ey * ey; let s = if len2 > 0.0 { (((x - ax) * ex + (y - ay) * ey) / len2).clamp(0.0, 1.0) } else { 0.0 }; let (qx, qy) = (ax + s * ex - x, ay + s * ey - y); dist2 = dist2.min(qx * qx + qy * qy); if (ay > y) != (by > y) { let x_cross = ax + (y - ay) / (by - ay) * ex; if x < x_cross { inside = !inside; } } } let dist = dist2.sqrt(); if inside { -dist } else { dist } } /// Velocity of the point on a closed polygon nearest to `(x, y)`, where /// the vertices carry velocities: the nearest edge point is found exactly /// as in [`polygon_signed_distance`], and that edge's endpoint velocities /// are interpolated linearly along it. This is the no-slip closure of a /// deforming body whose boundary nodes move with known velocities — exact /// wherever the boundary velocity is linear along an edge, which is what a /// finite-element interface hands over. `velocities` must have one entry /// per vertex. #[must_use] pub fn polygon_interface_velocity( vertices: &[(f64, f64)], velocities: &[(f64, f64)], x: f64, y: f64, ) -> (f64, f64) { assert_eq!( vertices.len(), velocities.len(), "one velocity per polygon vertex" ); let n = vertices.len(); let mut best = (f64::MAX, 0usize, 0.0f64); for k in 0..n { let (ax, ay) = vertices[k]; let (bx, by) = vertices[(k + 1) % n]; let (ex, ey) = (bx - ax, by - ay); let len2 = ex * ex + ey * ey; let s = if len2 > 0.0 { (((x - ax) * ex + (y - ay) * ey) / len2).clamp(0.0, 1.0) } else { 0.0 }; let (qx, qy) = (ax + s * ex - x, ay + s * ey - y); let d2 = qx * qx + qy * qy; if d2 < best.0 { best = (d2, k, s); } } let (_, k, s) = best; let (vax, vay) = velocities[k]; let (vbx, vby) = velocities[(k + 1) % n]; (vax + s * (vbx - vax), vay + s * (vby - vay)) } /// What a velocity face is. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FaceKind { /// Unknown: both adjacent cells are fluid. Fluid, /// Prescribed by the near-wall reconstruction. Ghost, /// Deep inside the body: prescribed the surface velocity, never read. Solid, } /// One lattice node entering a bilinear interpolation, with the value to /// use instead when the node is not a fluid face: the surface velocity at /// that node's own nearest surface point. #[derive(Debug, Clone, Copy)] struct StencilNode { j: usize, i: usize, x: f64, y: f64, weight: f64, fallback: Option, } /// The reconstruction data of one ghost face. #[derive(Debug, Clone)] struct Ghost { j: usize, i: usize, x: f64, y: f64, /// Foot of the normal on the surface. foot: (f64, f64), /// Surface velocity component at the foot of the normal. u_surface: f64, /// Signed distance of the face and of the probe (probe is positive). s_face: f64, s_probe: f64, nodes: [StencilNode; 4], /// The face bounds a fluid cell (it carries mass flux continuity sees) /// and its outward-from-fluid orientation sign, for the compatibility /// correction. `0.0` when no fluid cell is adjacent. flux_sign: f64, } /// Classification of a grid against a body at one instant. `Clone` so a /// coupling loop can snapshot the solver's step state and re-run a step /// within a subiteration ([`super::EmbeddedPisoSolver::snapshot`]). #[derive(Clone)] pub struct EmbeddedMask { nx: usize, ny: usize, dx: f64, dy: f64, /// Row-major `(j, i)`. cell_fluid: Vec, u_kind: Vec, v_kind: Vec, u_ghosts: Vec, v_ghosts: Vec, /// First fluid cell, for anchoring a pure-Neumann projection. anchor: (usize, usize), fluid_cells: usize, } impl EmbeddedMask { /// Classify the `nx` by `ny` grid of spacing `dx`, `dy` against `body` /// at time `t`. Errors if the body touches the domain boundary. pub fn build( body: &EmbeddedBody, nx: usize, ny: usize, dx: f64, dy: f64, t: f64, ) -> CfdResult { Self::build_with_reference(body, nx, ny, dx, dy, t, None, 0.0) } /// [`Self::build`] with mask hysteresis: a cell whose centre lies /// within `band` (metres) of the surface keeps the classification it /// has in `reference`, flipping only once `phi` crosses `band` on the /// far side — a formerly-fluid cell goes solid only at `phi < -band`, /// a formerly-solid cell goes fluid only at `phi > band`. This makes /// the classification a single-valued function of geometry around the /// reference: two candidate geometries within the band produce the /// SAME mask, at the cost of the effective wall lagging the true /// surface by up to `band`. With `band = 0` or no reference (or a /// reference of different dimensions) this is exactly [`Self::build`]. #[allow(clippy::too_many_arguments)] pub fn build_with_reference( body: &EmbeddedBody, nx: usize, ny: usize, dx: f64, dy: f64, t: f64, reference: Option<&EmbeddedMask>, band: f64, ) -> CfdResult { let sticky = reference.filter(|m| band > 0.0 && m.nx == nx && m.ny == ny); let xc = |i: usize| (i as f64 + 0.5) * dx; let yc = |j: usize| (j as f64 + 0.5) * dy; let mut cell_fluid = vec![true; nx * ny]; let mut fluid_cells = 0; let mut anchor = None; for j in 0..ny { for i in 0..nx { let phi = body.phi(xc(i), yc(j), t); let fluid = match sticky.map(|m| m.cell_fluid[j * nx + i]) { Some(true) => phi > -band, Some(false) => phi > band, None => phi > 0.0, }; cell_fluid[j * nx + i] = fluid; if fluid { fluid_cells += 1; if anchor.is_none() { anchor = Some((j, i)); } } else if i == 0 || j == 0 || i + 1 == nx || j + 1 == ny { return Err(CfdError::invalid_parameter(format!( "embedded body reaches the domain boundary at cell ({j}, {i}); \ bodies must be surrounded by fluid" ))); } } } let Some(anchor) = anchor else { return Err(CfdError::invalid_parameter( "embedded body covers the whole domain", )); }; let h_min = dx.min(dy); // Faces further inside than this are never read by a fluid stencil // (a fluid face's neighbours lie within one cell; SDFs are // 1-Lipschitz). let reach = 1.5 * h_min; let eps = 1e-6 * h_min; let is_fluid = |j: usize, i: usize| cell_fluid[j * nx + i]; let mut u_kind = vec![FaceKind::Fluid; ny * (nx + 1)]; let mut v_kind = vec![FaceKind::Fluid; (ny + 1) * nx]; // Interior u faces: i = 1..nx separate cells (j, i-1) and (j, i). for j in 0..ny { for i in 1..nx { if !(is_fluid(j, i - 1) && is_fluid(j, i)) { let phi = body.phi(i as f64 * dx, yc(j), t); u_kind[j * (nx + 1) + i] = if phi > -reach { FaceKind::Ghost } else { FaceKind::Solid }; } } } for j in 1..ny { for i in 0..nx { if !(is_fluid(j - 1, i) && is_fluid(j, i)) { let phi = body.phi(xc(i), j as f64 * dy, t); v_kind[j * nx + i] = if phi > -reach { FaceKind::Ghost } else { FaceKind::Solid }; } } } // Ghost reconstruction data. The probe sits one cell beyond the // face's mirror image: `s_probe = |s_face| + h_min`. let u_pos = |j: usize, i: usize| (i as f64 * dx, yc(j)); let v_pos = |j: usize, i: usize| (xc(i), j as f64 * dy); let build_ghost = |j: usize, i: usize, x: f64, y: f64, component: usize, kinds: &Vec, flux_sign: f64| -> Ghost { let s_face = body.phi(x, y, t); let (nrm_x, nrm_y) = body.normal(x, y, t, eps); let foot = (x - s_face * nrm_x, y - s_face * nrm_y); let s_probe = s_face.abs() + h_min; let probe = (foot.0 + s_probe * nrm_x, foot.1 + s_probe * nrm_y); let vel = body.surface_velocity(foot.0, foot.1, t); let u_surface = if component == 0 { vel.0 } else { vel.1 }; let nodes = bilinear_nodes(probe, component, nx, ny, dx, dy, |jj, ii| { let k = if component == 0 { kinds[jj * (nx + 1) + ii] } else { kinds[jj * nx + ii] }; if k == FaceKind::Fluid { None } else { let (px, py) = if component == 0 { u_pos(jj, ii) } else { v_pos(jj, ii) }; let s = body.phi(px, py, t); let (nx_, ny_) = body.normal(px, py, t, eps); let f = body.surface_velocity(px - s * nx_, py - s * ny_, t); Some(if component == 0 { f.0 } else { f.1 }) } }); Ghost { j, i, x, y, foot, u_surface, s_face, s_probe, nodes, flux_sign, } }; let mut u_ghosts = Vec::new(); for j in 0..ny { for i in 1..nx { if u_kind[j * (nx + 1) + i] == FaceKind::Ghost { let (x, y) = u_pos(j, i); // Outward from the fluid cell: +1 if the fluid cell is // west of the face, -1 if east. let flux_sign = if is_fluid(j, i - 1) { 1.0 } else if is_fluid(j, i) { -1.0 } else { 0.0 }; u_ghosts.push(build_ghost(j, i, x, y, 0, &u_kind, flux_sign)); } } } let mut v_ghosts = Vec::new(); for j in 1..ny { for i in 0..nx { if v_kind[j * nx + i] == FaceKind::Ghost { let (x, y) = v_pos(j, i); let flux_sign = if is_fluid(j - 1, i) { 1.0 } else if is_fluid(j, i) { -1.0 } else { 0.0 }; v_ghosts.push(build_ghost(j, i, x, y, 1, &v_kind, flux_sign)); } } } Ok(Self { nx, ny, dx, dy, cell_fluid, u_kind, v_kind, u_ghosts, v_ghosts, anchor, fluid_cells, }) } /// A mask from an explicit classification (the overset's hole/fringe /// map): `cell_fluid` row-major, `u_kind` `ny × (nx + 1)`, `v_kind` /// `(ny + 1) × nx`. No ghost reconstruction data — every non-fluid face /// is prescribed by whoever built the classification, and /// [`Self::impose`] has nothing to do. The anchor is the first fluid cell. pub fn from_classification( nx: usize, ny: usize, dx: f64, dy: f64, cell_fluid: Vec, u_kind: Vec, v_kind: Vec, ) -> Self { assert_eq!(cell_fluid.len(), nx * ny); assert_eq!(u_kind.len(), ny * (nx + 1)); assert_eq!(v_kind.len(), (ny + 1) * nx); let fluid_cells = cell_fluid.iter().filter(|&&f| f).count(); let anchor = cell_fluid .iter() .position(|&f| f) .map_or((0, 0), |idx| (idx / nx, idx % nx)); Self { nx, ny, dx, dy, cell_fluid, u_kind, v_kind, u_ghosts: Vec::new(), v_ghosts: Vec::new(), anchor, fluid_cells, } } /// Cells in x. pub fn nx(&self) -> usize { self.nx } /// Cells in y. pub fn ny(&self) -> usize { self.ny } /// Is pressure cell `(j, i)` fluid? #[inline] pub fn is_fluid_cell(&self, j: usize, i: usize) -> bool { self.cell_fluid[j * self.nx + i] } /// Kind of the u face `(j, i)`, `i = 0..=nx` (boundary faces read Fluid; /// the solver prescribes them from the side boundary). #[inline] pub fn u_kind(&self, j: usize, i: usize) -> FaceKind { self.u_kind[j * (self.nx + 1) + i] } /// Kind of the v face `(j, i)`, `j = 0..=ny`. #[inline] pub fn v_kind(&self, j: usize, i: usize) -> FaceKind { self.v_kind[j * self.nx + i] } /// The first fluid cell — the projection's anchor when it is pure Neumann. pub fn anchor(&self) -> (usize, usize) { self.anchor } /// Number of fluid cells. pub fn fluid_cells(&self) -> usize { self.fluid_cells } /// Number of ghost faces (u + v). pub fn ghost_faces(&self) -> usize { self.u_ghosts.len() + self.v_ghosts.len() } /// Write the prescribed values onto every non-fluid interior face of /// `u`, `v`: ghost faces by the linear normal reconstruction from the /// current fluid values, deep solid faces the surface velocity. Then /// remove the net ghost mass flux around the body uniformly from the /// flux-carrying ghost faces so the projection stays compatible. /// /// Returns the per-face compatibility correction applied (velocity /// units) — a diagnostic a test can watch shrink with the mesh. pub fn impose( &self, body: &EmbeddedBody, u: &mut DMatrix, v: &mut DMatrix, t: f64, ) -> f64 { let (u_source, v_source) = (u.clone(), v.clone()); self.impose_from(body, &u_source, &v_source, u, v, t) } /// Field extension for the faces that were ghosts in this (old) mask /// and are fluid in `new_mask`: overwrite their velocity AND history /// with the fluid-side reconstruction at their new distance from the /// wall ([`Ghost::extend`]), instead of the inherited ghost value (a /// plane fit extrapolated through the wall, or — where the fit has too /// few fluid nodes, at corners — the mirror formula with the /// deviation's sign flipped). Returns the number of faces extended. /// Knob-gated by the solver; off, nothing here runs. #[allow(clippy::too_many_arguments)] pub fn extend_fresh_faces( &self, new_mask: &EmbeddedMask, body: &EmbeddedBody, t: f64, u_source: &DMatrix, v_source: &DMatrix, u: &mut DMatrix, v: &mut DMatrix, u_old: &mut DMatrix, v_old: &mut DMatrix, ) -> usize { let mut extended = 0usize; for g in &self.u_ghosts { if new_mask.u_kind(g.j, g.i) == FaceKind::Fluid { let val = g.extend(u_source, body.phi(g.x, g.y, t)); u[(g.j, g.i)] = val; u_old[(g.j, g.i)] = val; extended += 1; } } for g in &self.v_ghosts { if new_mask.v_kind(g.j, g.i) == FaceKind::Fluid { let val = g.extend(v_source, body.phi(g.x, g.y, t)); v[(g.j, g.i)] = val; v_old[(g.j, g.i)] = val; extended += 1; } } extended } /// [`Self::impose`] with the fluid values read from a *different* field /// than the one written: the moving-body step reconstructs the new /// mask's ghost values from the previous step's corrected field (the /// boundary-history principle — ghost data, like domain-boundary data, /// is carried by what the previous step left, not by the uncorrected /// predictor state). With `source == target` values this is `impose`. #[allow(clippy::too_many_arguments)] pub fn impose_from( &self, body: &EmbeddedBody, u_source: &DMatrix, v_source: &DMatrix, u: &mut DMatrix, v: &mut DMatrix, t: f64, ) -> f64 { let (nx, ny, dx, dy) = (self.nx, self.ny, self.dx, self.dy); // Deep solid faces first (cheap, and ghost probes never read them // — fallbacks replace non-fluid nodes). for j in 0..ny { for i in 1..nx { if self.u_kind(j, i) == FaceKind::Solid { u[(j, i)] = body .surface_velocity(i as f64 * dx, (j as f64 + 0.5) * dy, t) .0; } } } for j in 1..ny { for i in 0..nx { if self.v_kind(j, i) == FaceKind::Solid { v[(j, i)] = body .surface_velocity((i as f64 + 0.5) * dx, j as f64 * dy, t) .1; } } } // Ghost values from the source fluid field (reads only fluid faces // and fallbacks, so order does not matter). let u_vals: Vec = self .u_ghosts .iter() .map(|g| g.reconstruct(u_source)) .collect(); let v_vals: Vec = self .v_ghosts .iter() .map(|g| g.reconstruct(v_source)) .collect(); // Net outward (from fluid) flux through flux-carrying ghost faces. let mut net = 0.0; let mut area = 0.0; for (g, &val) in self.u_ghosts.iter().zip(&u_vals) { if g.flux_sign != 0.0 { net += g.flux_sign * val * dy; area += dy; } } for (g, &val) in self.v_ghosts.iter().zip(&v_vals) { if g.flux_sign != 0.0 { net += g.flux_sign * val * dx; area += dx; } } let correction = if area > 0.0 { net / area } else { 0.0 }; for (g, &val) in self.u_ghosts.iter().zip(&u_vals) { u[(g.j, g.i)] = val - g.flux_sign * correction; } for (g, &val) in self.v_ghosts.iter().zip(&v_vals) { v[(g.j, g.i)] = val - g.flux_sign * correction; } correction } /// Force on the body by surface-stress reconstruction. At each surface /// sample the pressure is linearly extrapolated to the wall from probes /// at `1 h` and `2 h` along the normal; the normal derivatives of the /// normal and tangential velocity at the wall come from the same probes /// with the surface velocity at the wall (quadratic fit `a s + b s^2`); /// the tangential derivative of the normal velocity along the surface /// comes from the surface-velocity function. The viscous traction is /// then the full `mu (grad u + grad u^T) n`, written in the sample's /// normal/tangent frame with `n` held fixed: /// /// ```text /// tau.n = mu [ 2 n (n.grad)(u.n) + t ( (n.grad)(u.t) + (t.grad)(u.n) ) ] /// ``` /// /// which reduces to the wall shear `mu (n.grad)(u.t) t` on a rigid /// no-slip wall and is exact in principle for a manufactured surface /// velocity, so a manufactured solution can test this route against the /// exact surface integral. Returns `(F_x, F_y)` per unit depth. /// /// `ds` is the surface sampling spacing. A sample whose probes cannot /// be reconstructed is skipped and counted in `skipped`; the force of a /// body with no sampler is zero with zero samples. #[allow(clippy::too_many_arguments)] pub fn surface_force( &self, body: &EmbeddedBody, u: &DMatrix, v: &DMatrix, p: &DMatrix, mu: f64, t: f64, ds: f64, ) -> SurfaceForce { let samples = body.surface_samples(ds); let (mut fx, mut fy) = (0.0, 0.0); let mut skipped = 0; for s in &samples { match self.traction_at(body, u, v, p, mu, t, s.x, s.y, s.nx, s.ny) { Some((tx, ty)) => { fx += tx * s.ds; fy += ty * s.ds; } None => skipped += 1, } } SurfaceForce { fx, fy, samples: samples.len(), skipped, } } /// The reconstructed traction `sigma . n` (force per unit area) at one /// surface point with outward normal `(nx, ny)` — the per-sample core /// of [`Self::surface_force`], exposed so a coupling loop can hand the /// fluid load to a structure at its own quadrature points. `None` when /// a probe cannot be reconstructed (deep concave corner). #[allow(clippy::too_many_arguments)] pub fn traction_at( &self, body: &EmbeddedBody, u: &DMatrix, v: &DMatrix, p: &DMatrix, mu: f64, t: f64, x: f64, y: f64, nx: f64, ny: f64, ) -> Option<(f64, f64)> { let h = self.dx.min(self.dy); let d1 = h; let d2 = 2.0 * h; let p1 = self.pressure_at(p, x + d1 * nx, y + d1 * ny)?; let p2 = self.pressure_at(p, x + d2 * nx, y + d2 * ny)?; let (u1, v1) = self.velocity_at(body, u, v, x + d1 * nx, y + d1 * ny, t)?; let (u2, v2) = self.velocity_at(body, u, v, x + d2 * nx, y + d2 * ny, t)?; let p_wall = p1 + (p1 - p2) * d1 / (d2 - d1); let (tx, ty) = (-ny, nx); let (u_s, v_s) = body.surface_velocity(x, y, t); let ut_wall = u_s * tx + v_s * ty; let un_wall = u_s * nx + v_s * ny; let wall_gradient = |f1: f64, f2: f64| (f1 * d2 * d2 - f2 * d1 * d1) / (d1 * d2 * (d2 - d1)); let dn_ut = wall_gradient(u1 * tx + v1 * ty - ut_wall, u2 * tx + v2 * ty - ut_wall); let dn_un = wall_gradient(u1 * nx + v1 * ny - un_wall, u2 * nx + v2 * ny - un_wall); // Tangential derivative of (u . n) along the surface, n fixed. let eps = 1e-6 * h; let (up, vp) = body.surface_velocity(x + eps * tx, y + eps * ty, t); let (um, vm) = body.surface_velocity(x - eps * tx, y - eps * ty, t); let dt_un = ((up - um) * nx + (vp - vm) * ny) / (2.0 * eps); let traction_n = -p_wall + 2.0 * mu * dn_un; let traction_t = mu * (dn_ut + dt_un); Some(( traction_n * nx + traction_t * tx, traction_n * ny + traction_t * ty, )) } /// Force on the body by a momentum balance over the rectangle of whole /// Force on the body by a momentum balance over the rectangle of whole /// cells `[i0, i1) x [j0, j1)` (which must enclose the body and lie in /// the fluid on its boundary): /// `F = sum_outer (sigma.n - rho u (u.n)) A - d/dt int rho u dV + int f dV`, /// with the unsteady term from `(u - u_old)/dt` over the fluid cells of /// the box and `f` an optional volumetric momentum source evaluated at /// cell centres. Independent of the surface reconstruction: it reads no /// near-wall value at all. #[allow(clippy::too_many_arguments)] pub fn control_volume_force( &self, u: &DMatrix, v: &DMatrix, p: &DMatrix, u_old: &DMatrix, v_old: &DMatrix, dt: f64, rho: f64, mu: f64, source: Option<&dyn Fn(f64, f64) -> (f64, f64)>, (i0, i1, j0, j1): (usize, usize, usize, usize), ) -> (f64, f64) { let (dx, dy) = (self.dx, self.dy); let (mut fx, mut fy) = (0.0, 0.0); // East (i = i1, n = +x) and west (i = i0, n = -x) faces: u lives // on them; p and v are averaged across. for j in j0..j1 { for (i, sign) in [(i1, 1.0), (i0, -1.0)] { let un = u[(j, i)]; let p_f = 0.5 * (p[(j, i - 1)] + p[(j, i)]); let dudx = (u[(j, i + 1)] - u[(j, i - 1)]) / (2.0 * dx); let v_c = |jj: usize, ii: usize| 0.5 * (v[(jj, ii)] + v[(jj + 1, ii)]); let dvdx = (v_c(j, i) - v_c(j, i - 1)) / dx; let dudy = if j == 0 { (u[(j + 1, i)] - u[(j, i)]) / dy } else if j + 1 == self.ny { (u[(j, i)] - u[(j - 1, i)]) / dy } else { (u[(j + 1, i)] - u[(j - 1, i)]) / (2.0 * dy) }; let v_f = 0.5 * (v_c(j, i - 1) + v_c(j, i)); let sxx = -p_f + 2.0 * mu * dudx; let sxy = mu * (dudy + dvdx); // sigma.n - rho u (u.n), n = sign * e_x fx += sign * (sxx - rho * un * un) * dy; fy += sign * (sxy - rho * v_f * un) * dy; } } // North (j = j1, n = +y) and south (j = j0, n = -y) faces. for i in i0..i1 { for (j, sign) in [(j1, 1.0), (j0, -1.0)] { let vn = v[(j, i)]; let p_f = 0.5 * (p[(j - 1, i)] + p[(j, i)]); let dvdy = (v[(j + 1, i)] - v[(j - 1, i)]) / (2.0 * dy); let u_c = |jj: usize, ii: usize| 0.5 * (u[(jj, ii)] + u[(jj, ii + 1)]); let dudy = (u_c(j, i) - u_c(j - 1, i)) / dy; let dvdx = if i == 0 { (v[(j, i + 1)] - v[(j, i)]) / dx } else if i + 1 == self.nx { (v[(j, i)] - v[(j, i - 1)]) / dx } else { (v[(j, i + 1)] - v[(j, i - 1)]) / (2.0 * dx) }; let u_f = 0.5 * (u_c(j - 1, i) + u_c(j, i)); 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; } } // Unsteady term and source over the fluid cells of the box. for j in j0..j1 { for i in i0..i1 { if !self.is_fluid_cell(j, i) { continue; } let du = 0.5 * ((u[(j, i)] - u_old[(j, i)]) + (u[(j, i + 1)] - u_old[(j, i + 1)])); let dv = 0.5 * ((v[(j, i)] - v_old[(j, i)]) + (v[(j + 1, i)] - v_old[(j + 1, i)])); fx -= rho * du / dt * dx * dy; fy -= rho * dv / dt * dx * dy; if let Some(f) = source { let (sx, sy) = f((i as f64 + 0.5) * dx, (j as f64 + 0.5) * dy); fx += sx * dx * dy; fy += sy * dx * dy; } } } (fx, fy) } /// Pressure at a point from cell centres: bilinear when all four /// surrounding cells are fluid, otherwise the least-squares linear fit /// through the fluid ones (pressure has no wall value to add — it is a /// Neumann quantity there); `None` if fewer than three usable cells. fn pressure_at(&self, p: &DMatrix, x: f64, y: f64) -> Option { let (nx, ny, dx, dy) = (self.nx, self.ny, self.dx, self.dy); let gx = x / dx - 0.5; let gy = y / dy - 0.5; let i0 = gx.floor().clamp(0.0, (nx - 2) as f64) as usize; let j0 = gy.floor().clamp(0.0, (ny - 2) as f64) as usize; let fx = (gx - i0 as f64).clamp(0.0, 1.0); let fy = (gy - j0 as f64).clamp(0.0, 1.0); let nodes = [ (j0, i0, (1.0 - fx) * (1.0 - fy)), (j0, i0 + 1, fx * (1.0 - fy)), (j0 + 1, i0, (1.0 - fx) * fy), (j0 + 1, i0 + 1, fx * fy), ]; if nodes.iter().all(|&(jj, ii, _)| self.is_fluid_cell(jj, ii)) { return Some(nodes.iter().map(|&(jj, ii, w)| w * p[(jj, ii)]).sum()); } let pts: Vec<(f64, f64, f64)> = nodes .iter() .filter(|&&(jj, ii, _)| self.is_fluid_cell(jj, ii)) .map(|&(jj, ii, _)| ((ii as f64 + 0.5) * dx, (jj as f64 + 0.5) * dy, p[(jj, ii)])) .collect(); linear_fit(&pts, (x, y)) } /// Velocity at a point from the staggered lattices: bilinear when all /// four surrounding nodes of a component are fluid faces, otherwise the /// least-squares linear fit through the fluid nodes and the point's own /// boundary intercept (nearest surface point, surface velocity). `None` /// if even that is degenerate. fn velocity_at( &self, body: &EmbeddedBody, u: &DMatrix, v: &DMatrix, x: f64, y: f64, t: f64, ) -> Option<(f64, f64)> { let (nx, ny, dx, dy) = (self.nx, self.ny, self.dx, self.dy); let eps = 1e-6 * dx.min(dy); let s = body.phi(x, y, t); let (nrm_x, nrm_y) = body.normal(x, y, t, eps); let foot = (x - s * nrm_x, y - s * nrm_y); let vel_foot = body.surface_velocity(foot.0, foot.1, t); let mut out = [0.0; 2]; for component in 0..2 { let nodes = bilinear_nodes((x, y), component, nx, ny, dx, dy, |_, _| None); let values = if component == 0 { u } else { v }; let fluid = |n: &StencilNode| { if component == 0 { self.u_kind(n.j, n.i) == FaceKind::Fluid } else { self.v_kind(n.j, n.i) == FaceKind::Fluid } }; if nodes.iter().all(fluid) { out[component] = nodes.iter().map(|n| n.weight * values[(n.j, n.i)]).sum(); } else { let mut pts: Vec<(f64, f64, f64)> = nodes .iter() .filter(|n| fluid(n)) .map(|n| (n.x, n.y, values[(n.j, n.i)])) .collect(); let foot_val = if component == 0 { vel_foot.0 } else { vel_foot.1 }; pts.push((foot.0, foot.1, foot_val)); out[component] = linear_fit(&pts, (x, y))?; } } Some((out[0], out[1])) } } impl Ghost { /// The ghost value: a linear function fitted (least squares) through the /// fluid nodes around the probe and the boundary-intercept point with /// its surface velocity, evaluated at the ghost face — exact for linear /// fields. If the points are degenerate (fewer than three, or /// collinear), fall back to the linear profile along the normal with the /// non-fluid nodes replaced by the surface velocity at their own /// projections. /// The fluid-side value at signed distance `s_new >= 0` from the wall /// along this ghost's normal: the wall value plus the probe's /// deviation scaled by `s_new / s_probe` — never the mirror. This is /// the field extension for a face that has just turned fluid /// (Yang & Balaras 2006; Lee, Kim, Choi & Yang 2011's temporal /// velocity discontinuity is what it removes). fn extend(&self, values: &DMatrix, s_new: f64) -> f64 { let mut probe = 0.0; for n in &self.nodes { probe += n.weight * n.fallback.unwrap_or_else(|| values[(n.j, n.i)]); } self.u_surface + (probe - self.u_surface) * (s_new.max(0.0) / self.s_probe) } fn reconstruct(&self, values: &DMatrix) -> f64 { let mut pts: Vec<(f64, f64, f64)> = self .nodes .iter() .filter(|n| n.fallback.is_none()) .map(|n| (n.x, n.y, values[(n.j, n.i)])) .collect(); pts.push((self.foot.0, self.foot.1, self.u_surface)); if let Some(val) = linear_fit(&pts, (self.x, self.y)) { return val; } let mut probe = 0.0; for n in &self.nodes { probe += n.weight * n.fallback.unwrap_or_else(|| values[(n.j, n.i)]); } self.u_surface + (probe - self.u_surface) * (self.s_face / self.s_probe) } } /// Least-squares fit of `a + b (x - x0) + c (y - y0)` through `pts`, /// evaluated at `at = (x0, y0)`; `None` if the normal matrix is singular /// (fewer than three points or collinear ones), judged relative to its /// scale. fn linear_fit(pts: &[(f64, f64, f64)], at: (f64, f64)) -> Option { if pts.len() < 3 { return None; } // Normal equations for [1, dx, dy]. let mut m = [[0.0f64; 3]; 3]; let mut rhs = [0.0f64; 3]; for &(x, y, val) in pts { let r = [1.0, x - at.0, y - at.1]; for a in 0..3 { for b in 0..3 { m[a][b] += r[a] * r[b]; } rhs[a] += r[a] * val; } } let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]); let scale = m[0][0] * m[1][1] * m[2][2]; if scale <= 0.0 || det.abs() <= 1e-10 * scale { return None; } // Cramer's rule for the constant term only (the value at `at`). let det_a = rhs[0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (rhs[1] * m[2][2] - m[1][2] * rhs[2]) + m[0][2] * (rhs[1] * m[2][1] - m[1][1] * rhs[2]); Some(det_a / det) } /// The four lattice nodes of velocity `component` (0 = u on `x = i dx, /// y = (j + 1/2) dy`; 1 = v on `x = (i + 1/2) dx, y = j dy`) surrounding /// `point`, with bilinear weights, clamped to the lattice. `fallback(j, i)` /// supplies the value to use for a node that is not a fluid face. fn bilinear_nodes( point: (f64, f64), component: usize, nx: usize, ny: usize, dx: f64, dy: f64, fallback: impl Fn(usize, usize) -> Option, ) -> [StencilNode; 4] { let (gx, gy, max_i, max_j) = if component == 0 { (point.0 / dx, point.1 / dy - 0.5, nx, ny - 1) } else { (point.0 / dx - 0.5, point.1 / dy, nx - 1, ny) }; let i0 = gx.floor().clamp(0.0, (max_i - 1) as f64) as usize; let j0 = gy.floor().clamp(0.0, (max_j - 1) as f64) as usize; let fx = (gx - i0 as f64).clamp(0.0, 1.0); let fy = (gy - j0 as f64).clamp(0.0, 1.0); let mk = |j: usize, i: usize, weight: f64| { let (x, y) = if component == 0 { (i as f64 * dx, (j as f64 + 0.5) * dy) } else { ((i as f64 + 0.5) * dx, j as f64 * dy) }; StencilNode { j, i, x, y, weight, fallback: fallback(j, i), } }; [ mk(j0, i0, (1.0 - fx) * (1.0 - fy)), mk(j0, i0 + 1, fx * (1.0 - fy)), mk(j0 + 1, i0, (1.0 - fx) * fy), mk(j0 + 1, i0 + 1, fx * fy), ] } #[cfg(test)] mod tests { use super::*; #[test] fn circle_sdf_and_normal_are_exact() { let body = EmbeddedBody::circle(0.5, 0.5, 0.2); assert!((body.phi(0.9, 0.5, 0.0) - 0.2).abs() < 1e-14); assert!((body.phi(0.5, 0.5, 0.0) + 0.2).abs() < 1e-14); let (nx, ny) = body.normal(0.5 + 0.2 * 0.6, 0.5 + 0.2 * 0.8, 0.0, 1e-7); assert!((nx - 0.6).abs() < 1e-6 && (ny - 0.8).abs() < 1e-6); let samples = body.surface_samples(0.01); let len: f64 = samples.iter().map(|s| s.ds).sum(); assert!((len - 2.0 * std::f64::consts::PI * 0.2).abs() < 1e-12); } #[test] fn rectangle_sdf_is_a_distance() { let body = EmbeddedBody::rectangle(0.25, 0.19, 0.6, 0.21); assert!((body.phi(0.7, 0.2, 0.0) - 0.1).abs() < 1e-14); assert!((body.phi(0.4, 0.2, 0.0) + 0.01).abs() < 1e-14); // Corner: Euclidean distance. assert!((body.phi(0.63, 0.25, 0.0) - 0.05).abs() < 1e-14); } #[test] fn classification_of_a_circle_on_a_box() { let n = 32; let h = 1.0 / n as f64; let body = EmbeddedBody::circle(0.5, 0.5, 0.2); let mask = EmbeddedMask::build(&body, n, n, h, h, 0.0).unwrap(); let solid_cells = n * n - mask.fluid_cells(); let expected = std::f64::consts::PI * 0.04 / (h * h); assert!( (solid_cells as f64 - expected).abs() < 0.1 * expected, "solid cells {solid_cells} vs area/h^2 {expected:.1}" ); // A face deep inside is Solid, a face just inside is Ghost, the // anchor is a fluid cell, the outer ring is fluid. assert_eq!(mask.u_kind(n / 2, n / 2), FaceKind::Solid); assert!(mask.ghost_faces() > 0); let (aj, ai) = mask.anchor(); assert!(mask.is_fluid_cell(aj, ai)); for i in 0..n { assert!(mask.is_fluid_cell(0, i) && mask.is_fluid_cell(n - 1, i)); } } /// Mask hysteresis: within the band every cell keeps the reference /// classification (two geometries within the band produce the SAME /// mask); past the band cells flip; band 0 with a reference is exactly /// the plain build. #[test] fn hysteresis_keeps_the_reference_classification_within_the_band() { let n = 32; let h = 1.0 / n as f64; let band = 0.5 * h; let circle_at = |cx: f64| EmbeddedBody::circle(cx, 0.5, 0.2); let reference = EmbeddedMask::build(&circle_at(0.5), n, n, h, h, 0.0).unwrap(); let flips = |a: &EmbeddedMask, b: &EmbeddedMask| { let mut count = 0; for j in 0..n { for i in 0..n { if a.is_fluid_cell(j, i) != b.is_fluid_cell(j, i) { count += 1; } } } count }; // A shift inside the band: the plain build flips cells, the sticky // build must equal the reference cell-for-cell. let shifted = circle_at(0.5 + 0.4 * h); let plain = EmbeddedMask::build(&shifted, n, n, h, h, 0.0).unwrap(); let sticky = EmbeddedMask::build_with_reference(&shifted, n, n, h, h, 0.0, Some(&reference), band) .unwrap(); assert!( flips(&plain, &reference) > 0, "a 0.4h shift flips no cells — the test is vacuous" ); assert_eq!( flips(&sticky, &reference), 0, "cells flipped inside the hysteresis band" ); // A shift past the band flips cells even with hysteresis. let far = circle_at(0.5 + 2.0 * h); let sticky_far = EmbeddedMask::build_with_reference(&far, n, n, h, h, 0.0, Some(&reference), band) .unwrap(); assert!( flips(&sticky_far, &reference) > 0, "the band froze the mask against a 2h shift" ); // Band 0 with a reference is exactly the plain build. let zero = EmbeddedMask::build_with_reference(&shifted, n, n, h, h, 0.0, Some(&reference), 0.0) .unwrap(); assert_eq!(flips(&zero, &plain), 0); } #[test] fn interface_velocity_interpolates_along_the_nearest_edge() { // Unit square, CCW; each vertex carries a distinct velocity. let vertices = vec![(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]; let velocities = vec![(0.0, 0.0), (1.0, -1.0), (2.0, 4.0), (3.0, 9.0)]; // Near the bottom edge at s = 0.25: linear interpolation of the // edge's endpoint velocities, regardless of the offset off the edge. let (u, v) = polygon_interface_velocity(&vertices, &velocities, 0.25, -0.3); assert!((u - 0.25).abs() < 1e-14 && (v + 0.25).abs() < 1e-14); let (u, v) = polygon_interface_velocity(&vertices, &velocities, 0.25, 0.1); assert!((u - 0.25).abs() < 1e-14 && (v + 0.25).abs() < 1e-14); // Near a vertex (outside the corner): the vertex velocity. let (u, v) = polygon_interface_velocity(&vertices, &velocities, 1.2, 1.3); assert!((u - 2.0).abs() < 1e-14 && (v - 4.0).abs() < 1e-14); // Midpoint of the right edge. let (u, v) = polygon_interface_velocity(&vertices, &velocities, 1.4, 0.5); assert!((u - 1.5).abs() < 1e-14 && (v - 1.5).abs() < 1e-14); } #[test] fn body_touching_the_boundary_is_refused() { let body = EmbeddedBody::circle(0.0, 0.5, 0.2); assert!(EmbeddedMask::build(&body, 16, 16, 1.0 / 16.0, 1.0 / 16.0, 0.0).is_err()); } /// A linear velocity field is reproduced exactly by the ghost /// reconstruction: surface value + linear profile + bilinear probe are /// all exact for linear fields, so every ghost face must read the field /// itself, and the compatibility correction must be zero for a /// divergence-free one. #[test] fn ghost_reconstruction_is_exact_for_linear_fields() { let n = 24; let h = 1.0 / n as f64; let lin_u = |x: f64, y: f64| 0.3 + 0.7 * x - 0.2 * y; let lin_v = |x: f64, y: f64| -0.1 + 0.4 * x - 0.7 * y; // div = 0 let body = EmbeddedBody::circle(0.5, 0.5, 0.2) .with_surface_velocity(move |x, y, _| (lin_u(x, y), lin_v(x, y))); let mask = EmbeddedMask::build(&body, n, n, h, h, 0.0).unwrap(); let mut u = DMatrix::zeros(n, n + 1); let mut v = DMatrix::zeros(n + 1, n); for j in 0..n { for i in 0..=n { u[(j, i)] = lin_u(i as f64 * h, (j as f64 + 0.5) * h); } } for j in 0..=n { for i in 0..n { v[(j, i)] = lin_v((i as f64 + 0.5) * h, j as f64 * h); } } // Scramble the non-fluid faces so the test is not vacuous. for j in 0..n { for i in 1..n { if mask.u_kind(j, i) != FaceKind::Fluid { u[(j, i)] = 99.0; } } } for j in 1..n { for i in 0..n { if mask.v_kind(j, i) != FaceKind::Fluid { v[(j, i)] = 99.0; } } } let correction = mask.impose(&body, &mut u, &mut v, 0.0); assert!(correction.abs() < 1e-10, "correction {correction:.3e}"); let mut max_err: f64 = 0.0; for j in 0..n { for i in 1..n { if mask.u_kind(j, i) == FaceKind::Ghost { max_err = max_err.max((u[(j, i)] - lin_u(i as f64 * h, (j as f64 + 0.5) * h)).abs()); } } } for j in 1..n { for i in 0..n { if mask.v_kind(j, i) == FaceKind::Ghost { max_err = max_err.max((v[(j, i)] - lin_v((i as f64 + 0.5) * h, j as f64 * h)).abs()); } } } assert!( max_err < 1e-9, "ghost reconstruction error on a linear field: {max_err:.3e}" ); } }