embedded3 item 10: the apertured cut-cell wall (AM-wall) — cutwall.rs classification, apertured projection with the compatible wall flux, cut predictor (V_u = αhA, averaged mass fluxes, implicit wall shear, inertia floor), cut load route; sphere MMS CutCell ≤ GhostBinary at n 12/24 (ratio 0.96), loads 9.3/10.7 % at n 24
CI / Test (ubuntu-latest) (push) Blocked by required conditions
CI / Build (macos-latest) (push) Waiting to run
CI / Test (macos-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 / Build CPU-Only (Explicit) (push) Failing after 4s
Documentation / Build API Documentation (push) Failing after 4s
CI / Format Check (push) Failing after 12s
Documentation / Build User Guide (push) Successful in 5s
CI / Build (ubuntu-latest) (push) Failing after 1m50s
CI / Clippy Check (push) Failing after 2m5s
Performance Benchmarks / Run Benchmarks (push) Successful in 2m40s

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
Omar Sobh
2026-09-17 15:34:07 -05:00
co-authored by Claude Fable 5.1
parent d337afa8f9
commit 0e4c97ed24
8 changed files with 864 additions and 67 deletions
@@ -0,0 +1,400 @@
//! The apertured cut-cell wall (`WallScheme::CutCell`, item 10 — the
//! "AM-wall"): the cut geometry classifies the grid (a cell is fluid where
//! its fluid volume is positive; an interior face is an unknown where its
//! aperture is positive, prescribed the surface velocity otherwise — no
//! ghost faces), and the wall enters the operators through the apertures:
//! continuity `Σ_f A_f u_f·n_f + U_b·W_c = 0` per cell, the projection
//! coefficient `dt·A_f/δ`, the momentum control volume of an unknown face
//! `V_u = A_f h` with its own faces' apertures averaged from the two
//! adjacent cells (mass fluxes averaged, so the momentum volume conserves
//! mass exactly when the cells do), the wall closing it (`W = −Σ A n`),
//! an implicit wall shear `μ A_w (u_f U_b)/d_f`, and an inertia floor of
//! 0.1 in the time derivative only. Every prescribed value is the limit of
//! the computed one as the aperture closes (the shear coefficient grows as
//! `1/A_f`), which is what makes the wall smooth in the interface position.
use super::Grid;
use super::body::Body;
use super::cut::CutGeometry;
use super::field::Field;
use super::step::{Boundaries, Side};
use super::wall::{FaceKind, Mask};
/// The inertia floor: the momentum volume's fraction in the time
/// derivative is at least this.
pub(super) const INERTIA_FLOOR: f64 = 0.1;
/// The wall-distance floor of a face, in units of the smallest spacing.
pub(super) const DISTANCE_FLOOR: f64 = 0.05;
/// Lattice addressing of faces and cells with the periodic wrap in z as
/// data: a face of component `c` at `p = [i, j, k]` (its own coordinate is
/// the face index, the others the cell's), a cell at `[i, j, k]`.
#[derive(Debug, Clone, Copy)]
pub(super) struct Lattice {
pub(super) g: Grid,
pub(super) periodic_z: bool,
}
impl Lattice {
fn wrap_z(&self, k: i64, planes: i64) -> Option<usize> {
if self.periodic_z {
Some(k.rem_euclid(self.g.nz as i64) as usize)
} else if (0..planes).contains(&k) {
Some(k as usize)
} else {
None
}
}
/// The index of the face of component `c` at `p`, `None` outside.
pub(super) fn face(&self, c: usize, p: [i64; 3]) -> Option<usize> {
let (nx, ny, nz) = (self.g.nx as i64, self.g.ny as i64, self.g.nz as i64);
let (i, j) = (p[0], p[1]);
let (ni, nj, nk) = match c {
0 => (nx + 1, ny, nz),
1 => (nx, ny + 1, nz),
_ => (nx, ny, nz + 1),
};
if !(0..ni).contains(&i) || !(0..nj).contains(&j) {
return None;
}
let k = self.wrap_z(p[2], nk)?;
let (i, j) = (i as usize, j as usize);
Some(match c {
0 => self.g.uface(k, j, i),
1 => self.g.vface(k, j, i),
_ => self.g.wface(k, j, i),
})
}
/// The index of the cell at `p`, `None` outside.
pub(super) fn cell(&self, p: [i64; 3]) -> Option<usize> {
let (nx, ny, nz) = (self.g.nx as i64, self.g.ny as i64, self.g.nz as i64);
if !(0..nx).contains(&p[0]) || !(0..ny).contains(&p[1]) {
return None;
}
let k = self.wrap_z(p[2], nz)?;
Some(self.g.cell(k, p[1] as usize, p[0] as usize))
}
/// The centre of the face of component `c` at `p`.
pub(super) fn face_position(&self, c: usize, p: [i64; 3]) -> [f64; 3] {
let h = [self.g.dx, self.g.dy, self.g.dz];
let mut x = [0.0; 3];
for d in 0..3 {
let off = if d == c { 0.0 } else { 0.5 };
x[d] = (p[d] as f64 + off) * h[d];
}
x
}
}
/// The geometry of an unknown face's momentum control volume.
#[derive(Debug, Clone, Copy)]
pub(super) struct CvGeometry {
/// The face's own aperture.
pub(super) alpha: f64,
/// The control volume's face apertures `[direction][minus, plus]`.
pub(super) ap: [[f64; 2]; 3],
/// The wall's vector area closing the control volume (into the body).
pub(super) wall: [f64; 3],
/// The wall distance of the face (floored).
pub(super) distance: f64,
}
impl Mask {
/// Classify the grid against `body` at `t` by its cut geometry.
pub fn build_cut(body: &Body, g: Grid, t: f64, b: Boundaries) -> Result<Self, String> {
let cut = CutGeometry::build(body, g, t);
let (nx, ny, nz) = (g.nx, g.ny, g.nz);
let periodic = b.z0 == Side::Periodic;
let allowed = |side: Side| matches!(side, Side::Velocity | Side::Periodic | Side::SlipWall);
let mut cell_fluid = vec![true; g.cells()];
let mut fluid_cells = 0;
let mut anchor = None;
for k in 0..nz {
for j in 0..ny {
for i in 0..nx {
let idx = g.cell(k, j, i);
let fluid = cut.vol[idx] > 0.0;
cell_fluid[idx] = fluid;
if fluid {
fluid_cells += 1;
if anchor.is_none() {
anchor = Some(idx);
}
} else {
let touches = (i == 0 && !allowed(b.x0))
|| (i + 1 == nx && !allowed(b.x1))
|| (j == 0 && !allowed(b.y0))
|| (j + 1 == ny && !allowed(b.y1))
|| (k == 0 && !allowed(b.z0))
|| (k + 1 == nz && !allowed(b.z1));
if touches {
return Err(format!(
"embedded body reaches a domain side that is not a Velocity/Periodic side at cell ({k}, {j}, {i})"
));
}
}
}
}
}
let Some(anchor) = anchor else {
return Err("embedded body covers the whole domain".into());
};
let kind = |a: f64| {
if a > 0.0 {
FaceKind::Fluid
} else {
FaceKind::Solid
}
};
let mut u_kind = vec![FaceKind::Fluid; g.n_ufaces()];
let mut v_kind = vec![FaceKind::Fluid; g.n_vfaces()];
let mut w_kind = vec![FaceKind::Fluid; g.n_wfaces()];
for k in 0..nz {
for j in 0..ny {
for i in 1..nx {
let f = g.uface(k, j, i);
u_kind[f] = kind(cut.a_u[f]);
}
}
for j in 1..ny {
for i in 0..nx {
let f = g.vface(k, j, i);
v_kind[f] = kind(cut.a_v[f]);
}
}
}
let w_range = if periodic { 0..nz + 1 } else { 1..nz };
for k in w_range {
for j in 0..ny {
for i in 0..nx {
let f = g.wface(k, j, i);
w_kind[f] = kind(cut.a_w[f]);
}
}
}
Ok(Self {
grid: g,
periodic_z: periodic,
cell_fluid,
u_kind,
v_kind,
w_kind,
u_ghosts: Vec::new(),
v_ghosts: Vec::new(),
w_ghosts: Vec::new(),
anchor,
fluid_cells,
cut: Some(cut),
})
}
pub(super) fn lattice(&self) -> Lattice {
Lattice {
g: self.grid,
periodic_z: self.periodic_z,
}
}
/// Aperture of the face of component `c` at lattice `p` (1 without a
/// cut geometry), `None` outside the grid.
pub(super) fn aperture(&self, c: usize, p: [i64; 3]) -> Option<f64> {
let f = self.lattice().face(c, p)?;
Some(match c {
0 => self.a_u(f),
1 => self.a_v(f),
_ => self.a_w(f),
})
}
/// The momentum control volume of the unknown face of component `c` at
/// `p`: its face apertures are the averages of the two adjacent cells'
/// (the own-direction faces at the cell centres average the face's and
/// its own-direction neighbours' apertures), its wall closes them, its
/// wall distance is the face centre's signed distance moved to the
/// fluid part's centre, `φ + ½h(1 α)`, floored.
pub(super) fn cv_geometry(&self, c: usize, p: [i64; 3]) -> CvGeometry {
let g = self.grid;
let h = [g.dx, g.dy, g.dz];
let area = [g.dy * g.dz, g.dx * g.dz, g.dx * g.dy];
let mut e = |d: usize| {
let mut v = [0i64; 3];
v[d] = 1;
v
};
let add =
|a: [i64; 3], b: [i64; 3], s: i64| [a[0] + s * b[0], a[1] + s * b[1], a[2] + s * b[2]];
let ec = e(c);
let cell_minus = add(p, ec, -1);
let cell_plus = p;
let alpha = self.aperture(c, p).unwrap_or(1.0);
let mut ap = [[1.0; 2]; 3];
for d in 0..3 {
let ed = e(d);
if d == c {
let am = self.aperture(c, add(p, ec, -1)).unwrap_or(alpha);
let apl = self.aperture(c, add(p, ec, 1)).unwrap_or(alpha);
ap[d] = [0.5 * (am + alpha), 0.5 * (alpha + apl)];
} else {
let minus = 0.5
* (self.aperture(d, cell_minus).unwrap_or(1.0)
+ self.aperture(d, cell_plus).unwrap_or(1.0));
let plus = 0.5
* (self.aperture(d, add(cell_minus, ed, 1)).unwrap_or(1.0)
+ self.aperture(d, add(cell_plus, ed, 1)).unwrap_or(1.0));
ap[d] = [minus, plus];
}
}
let mut wall = [0.0; 3];
for d in 0..3 {
wall[d] = -(ap[d][1] - ap[d][0]) * area[d];
}
let h_min = g.dx.min(g.dy).min(g.dz);
let phi_face = self.cut.as_ref().map_or(h_min, |cut| {
let f = self
.lattice()
.face(c, p)
.expect("unknown face inside the grid");
match c {
0 => cut.d_u[f],
1 => cut.d_v[f],
_ => cut.d_w[f],
}
});
let distance = (phi_face + 0.5 * h[c] * (1.0 - alpha)).max(DISTANCE_FLOOR * h_min);
CvGeometry {
alpha,
ap,
wall,
distance,
}
}
/// The surface velocity component `c` at the foot of the normal from
/// the face centre.
pub fn surface_velocity_at(&self, body: &Body, x: [f64; 3], c: usize, t: f64) -> f64 {
let g = self.grid;
let eps = 1e-6 * g.dx.min(g.dy).min(g.dz);
let s = body.phi(x[0], x[1], x[2], t);
let (n1, n2, n3) = body.normal(x[0], x[1], x[2], t, eps);
let v = body.surface_velocity(x[0] - s * n1, x[1] - s * n2, x[2] - s * n3, t);
[v.0, v.1, v.2][c]
}
/// The volume fluxes of the surface velocity through every cell's wall
/// into the body, `U_b·W_c` (zero for a body at rest; the porous
/// manufactured surface's flux otherwise), made compatible: the net
/// flux (the quadrature's defect on a closed surface — a rigid
/// translation's is zero by closure) is redistributed over the wall
/// cells by wall area, as the binary wall's ghost fluxes are. Returns
/// the table and the correction (flux per unit wall area).
pub fn wall_flux_table(&self, body: &Body, t: f64) -> (Vec<f64>, f64) {
let mut table = vec![0.0; self.grid.cells()];
let Some(cut) = self.cut.as_ref() else {
return (table, 0.0);
};
let (mut net, mut area) = (0.0, 0.0);
for idx in 0..table.len() {
if !self.cell_fluid[idx] {
continue;
}
let w = cut.wall[idx];
let a = (w[0] * w[0] + w[1] * w[1] + w[2] * w[2]).sqrt();
if a == 0.0 {
continue;
}
table[idx] = self.wall_flux(body, idx, t);
net += table[idx];
area += a;
}
let correction = if area > 0.0 { net / area } else { 0.0 };
if correction != 0.0 {
for (idx, w) in cut.wall.iter().enumerate() {
let a = (w[0] * w[0] + w[1] * w[1] + w[2] * w[2]).sqrt();
table[idx] -= correction * a;
}
}
(table, correction)
}
/// The volume flux of the surface velocity through a cell's wall into
/// the body, `U_b·W_c`, uncorrected. Zero without a cut geometry.
pub fn wall_flux(&self, body: &Body, idx: usize, t: f64) -> f64 {
let Some(cut) = self.cut.as_ref() else {
return 0.0;
};
let w = cut.wall[idx];
if w == [0.0; 3] {
return 0.0;
}
let g = self.grid;
let (k, j, i) = g.kji(idx);
let x = [
(i as f64 + 0.5) * g.dx,
(j as f64 + 0.5) * g.dy,
(k as f64 + 0.5) * g.dz,
];
let mut flux = 0.0;
for (c, wc) in w.iter().enumerate() {
flux += self.surface_velocity_at(body, x, c, t) * wc;
}
flux
}
/// The cut-cell load route: the force on the body from the operators
/// themselves — `Σ_c p_c W_c` over the cells plus the implicit wall
/// shear `Σ_f μ A_w (u_f U_b)/d_f` over the unknown faces. `None`
/// without a cut geometry.
pub fn cut_wall_force(&self, body: &Body, f: &Field, mu: f64, t: f64) -> Option<[f64; 3]> {
let cut = self.cut.as_ref()?;
let g = self.grid;
let (nx, ny, nz) = (g.nx, g.ny, g.nz);
let mut force = [0.0; 3];
for (idx, w) in cut.wall.iter().enumerate() {
if self.cell_fluid[idx] {
for c in 0..3 {
force[c] += f.p[idx] * w[c];
}
}
}
let lat = self.lattice();
let values: [&[f64]; 3] = [&f.u, &f.v, &f.w];
let w_range = if self.periodic_z { 0..nz } else { 1..nz };
for c in 0..3 {
let (ir, jr, kr) = match c {
0 => (1..nx, 0..ny, 0..nz),
1 => (0..nx, 1..ny, 0..nz),
_ => (0..nx, 0..ny, w_range.clone()),
};
for k in kr {
for j in jr.clone() {
for i in ir.clone() {
let p = [i as i64, j as i64, k as i64];
let idx = lat.face(c, p).expect("face");
let kind = match c {
0 => self.u_kind[idx],
1 => self.v_kind[idx],
_ => self.w_kind[idx],
};
if kind != FaceKind::Fluid {
continue;
}
let cv = self.cv_geometry(c, p);
let a_w = (cv.wall[0] * cv.wall[0]
+ cv.wall[1] * cv.wall[1]
+ cv.wall[2] * cv.wall[2])
.sqrt();
if a_w == 0.0 {
continue;
}
let ub = self.surface_velocity_at(body, lat.face_position(c, p), c, t);
force[c] += mu * a_w * (values[c][idx] - ub) / cv.distance;
}
}
}
}
Some(force)
}
}