Files
rustytorch/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded_body.rs
T
Omar SobhandClaude Fable 5.1 172a26dee4
CI / Build (macos-latest) (push) Waiting to run
CI / Test (macos-latest) (push) Blocked by required conditions
CI / Test (ubuntu-latest) (push) Blocked by required conditions
CI / Python Bindings (maturin) (macos-latest) (push) Blocked by required conditions
CI / Python Bindings (maturin) (ubuntu-latest) (push) Blocked by required conditions
CI / WASM Build + Size Check (push) Blocked by required conditions
CI / Distributed Training Tests (push) Blocked by required conditions
CI / CI Success (push) Blocked by required conditions
CI / Format Check (push) Failing after 6s
CI / Build (ubuntu-latest) (push) Failing after 5s
CI / Clippy Check (push) Failing after 5s
Documentation / Build User Guide (push) Successful in 6s
Performance Benchmarks / Run Benchmarks (push) Failing after 27s
CI / Build CPU-Only (Explicit) (push) Failing after 1m24s
Documentation / Build API Documentation (push) Failing after 1m34s
PERF-2 P0: intra-step profiler — StepTimers on the overset solver (RTX_PROFILE; overlap build / predictors / patch BiCGSTAB / background Poisson with its setup-vs-iterate split / round exchange / apply / end exchange), PoissonSolution carries setup_ns and iterate_ns, the harness times advance / restore / snapshot / load sampling / force / FEA predictor and prints the wall split of the coupled phase; no clock is read when profiling is off; the reclassified/step progress denominator fixed (was ×4)
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YJPeT6WA2e7YvAnS875AHL
2026-09-15 22:25:52 -05:00

1458 lines
55 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.
//! 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 (TsengFerziger 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<dyn Fn(f64, f64, f64) -> f64 + Send + Sync>;
type VectorFn = Box<dyn Fn(f64, f64, f64) -> (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<Box<dyn Fn(f64) -> Vec<SurfaceSample> + Send + Sync>>,
}
impl EmbeddedBody {
/// A body from its signed distance function (negative inside), at rest.
pub fn from_sdf<F>(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<F>(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::<f64>()
* 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<dyn Fn(f64) -> Vec<SurfaceSample> + Send + Sync> = Box::new(move |ds| {
let mut out: Vec<SurfaceSample> = 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<SurfaceSample> {
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<f64>,
}
/// 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<bool>,
u_kind: Vec<FaceKind>,
v_kind: Vec<FaceKind>,
u_ghosts: Vec<Ghost>,
v_ghosts: Vec<Ghost>,
/// 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> {
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<Self> {
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<FaceKind>,
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<bool>,
u_kind: Vec<FaceKind>,
v_kind: Vec<FaceKind>,
) -> 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<f64>,
v: &mut DMatrix<f64>,
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<f64>,
v_source: &DMatrix<f64>,
u: &mut DMatrix<f64>,
v: &mut DMatrix<f64>,
u_old: &mut DMatrix<f64>,
v_old: &mut DMatrix<f64>,
) -> 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<f64>,
v_source: &DMatrix<f64>,
u: &mut DMatrix<f64>,
v: &mut DMatrix<f64>,
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<f64> = self
.u_ghosts
.iter()
.map(|g| g.reconstruct(u_source))
.collect();
let v_vals: Vec<f64> = 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<f64>,
v: &DMatrix<f64>,
p: &DMatrix<f64>,
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<f64>,
v: &DMatrix<f64>,
p: &DMatrix<f64>,
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<f64>,
v: &DMatrix<f64>,
p: &DMatrix<f64>,
u_old: &DMatrix<f64>,
v_old: &DMatrix<f64>,
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<f64>, x: f64, y: f64) -> Option<f64> {
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<f64>,
v: &DMatrix<f64>,
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<f64>, 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>) -> 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<f64> {
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<f64>,
) -> [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}"
);
}
}