Files
rustytorch/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/cutwall.rs
T
Omar SobhandClaude Fable 5.1 f6add276c0
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 / Build (macos-latest) (push) Waiting to run
CI / Test (macos-latest) (push) Blocked by required conditions
CI / Build CPU-Only (Explicit) (push) Failing after 3s
Documentation / Build API Documentation (push) Failing after 4s
CI / Build (ubuntu-latest) (push) Failing after 4s
Documentation / Build User Guide (push) Successful in 4s
CI / Format Check (push) Failing after 10s
CI / Clippy Check (push) Failing after 35s
Performance Benchmarks / Run Benchmarks (push) Successful in 1m32s
embedded3: cut predictor convection carries ρ (host + e3_cut.cu; density-scaling pin); operator load route includes the wall exchange (exchange.rs); reconstructed_parts, probe aperture floor knob; dfg_split diagnostic test
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-18 03:59:57 -05:00

764 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The 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;
/// Virtual merging: a cell whose fluid fraction (at either end of the
/// step) stays below this shares its pressure unknown with a neighbour.
pub(super) const MERGE_FRACTION: f64 = 0.1;
/// 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> {
Self::build_cut_from(body, g, t, b, None)
}
/// As [`Self::build_cut`], re-evaluating φ only within `band` of the
/// previous geometry moved by at most `motion` (see
/// [`CutGeometry::build_from`]).
pub fn build_cut_from(
body: &Body,
g: Grid,
t: f64,
b: Boundaries,
prev: Option<(&CutGeometry, f64, f64)>,
) -> Result<Self, String> {
let cut = CutGeometry::build_from(body, g, t, prev);
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]);
}
}
}
let mut mask = 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),
step_apertures: None,
step_open: None,
merge_master: Vec::new(),
scheme: crate::solvers::incompressible::ConvectionScheme::Upwind,
density: 1.0,
};
mask.compute_merging(None);
Ok(mask)
}
/// The virtual merging map: a small cell (fraction < `MERGE_FRACTION`
/// at both ends of the step) takes as master its active face
/// neighbour of largest fraction that is not small itself; a small
/// cell without such a neighbour keeps its own row.
pub(super) fn compute_merging(&mut self, old: Option<&Mask>) {
let Some(cut) = self.cut.as_ref() else {
return;
};
let g = self.grid;
let n = g.cells();
let lat = self.lattice();
let frac = |idx: usize| {
let v = cut.vol[idx];
old.and_then(|o| o.cut.as_ref())
.map_or(v, |oc| v.max(oc.vol[idx]))
};
// `RTX_E3_MERGE_FRACTION` overrides the threshold (a study knob).
let threshold = std::env::var("RTX_E3_MERGE_FRACTION")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(MERGE_FRACTION);
let small: Vec<bool> = (0..n)
.map(|idx| self.cell_active(idx) && frac(idx) < threshold)
.collect();
let mut master = vec![usize::MAX; n];
for idx in (0..n).filter(|&i| small[i]) {
let (k, j, i) = g.kji(idx);
let p = [i as i64, j as i64, k as i64];
let mut best: Option<(f64, usize)> = None;
for d in 0..3 {
for side in [-1i64, 1] {
let mut q = p;
q[d] += side;
if let Some(nb) = lat.cell(q) {
let v = cut.vol[nb];
if self.cell_active(nb) && !small[nb] && best.is_none_or(|b| v > b.0) {
best = Some((v, nb));
}
}
}
}
if let Some((_, m)) = best {
master[idx] = m;
}
}
self.merge_master = master;
}
/// Set the step-averaged apertures and the space-time classification
/// from the previous mask's geometry: the trapezoid `½(αⁿ + αⁿ⁺¹)`,
/// or with `inner` intermediate geometries the composite trapezoid
/// over the step (the exact time integral of the space-time cut cell,
/// arXiv 2512.23358, approached as the sub-sampling refines).
pub fn set_step_apertures(&mut self, old: &Mask) {
self.set_step_apertures_with(old, &[]);
}
/// As [`Self::set_step_apertures`] with the apertures of the
/// intermediate geometries `inner` (in time order) inside the step.
pub fn set_step_apertures_with(&mut self, old: &Mask, inner: &[&CutGeometry]) {
let (Some(cut), Some(old_cut)) = (self.cut.as_ref(), old.cut.as_ref()) else {
return;
};
let n = inner.len() + 1;
let w_end = 0.5 / n as f64;
let w_in = 1.0 / n as f64;
let avg = |pick: &dyn Fn(&CutGeometry) -> &[f64]| -> Vec<f64> {
let a = pick(cut);
let b = pick(old_cut);
let mut out: Vec<f64> = a.iter().zip(b).map(|(x, y)| w_end * (x + y)).collect();
for g in inner {
for (o, v) in out.iter_mut().zip(pick(g)) {
*o += w_in * v;
}
}
out
};
let au = avg(&|g: &CutGeometry| &g.a_u);
let av = avg(&|g: &CutGeometry| &g.a_v);
let aw = avg(&|g: &CutGeometry| &g.a_w);
let open = |a: &[f64]| -> Vec<bool> { a.iter().map(|&x| x > 0.0).collect() };
let active = self
.cell_fluid
.iter()
.zip(&old.cell_fluid)
.map(|(&n, &o)| n || o)
.collect();
self.step_open = Some((open(&au), open(&av), open(&aw), active));
self.step_apertures = Some((au, av, aw));
self.compute_merging(Some(old));
}
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 `x`. With a cut geometry the signed distance and
/// the normal come from the geometry's own corner values (the
/// trilinear interpolant at the face centre, its gradient by central
/// differences of the neighbouring face centres) — one call of the
/// body's velocity per face instead of eight of its distance.
pub fn surface_velocity_at(&self, body: &Body, x: [f64; 3], c: usize, t: f64) -> f64 {
let g = self.grid;
let (s, n) = match self.cut.as_ref() {
Some(cut) => {
let (s, n) = self.interpolant_distance_and_normal(cut, x);
(s, n)
}
None => {
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);
(s, [n1, n2, n3])
}
};
let v = body.surface_velocity(x[0] - s * n[0], x[1] - s * n[1], x[2] - s * n[2], t);
[v.0, v.1, v.2][c]
}
/// φ and its unit gradient at `x` from the trilinear interpolant of the
/// corner values (the cut geometry's own surface).
fn interpolant_distance_and_normal(&self, cut: &CutGeometry, x: [f64; 3]) -> (f64, [f64; 3]) {
let g = self.grid;
let (nx, ny, nz) = (g.nx as i64, g.ny as i64, g.nz as i64);
let h = [g.dx, g.dy, g.dz];
let node = |i: i64, j: i64, k: i64| -> f64 {
let i = i.clamp(0, nx);
let j = j.clamp(0, ny);
let k = k.clamp(0, nz);
cut.phi[((k * (ny + 1) + j) * (nx + 1) + i) as usize]
};
let gx = x[0] / h[0];
let gy = x[1] / h[1];
let gz = x[2] / h[2];
let (i0, j0, k0) = (gx.floor() as i64, gy.floor() as i64, gz.floor() as i64);
let (fx, fy, fz) = (gx - i0 as f64, gy - j0 as f64, gz - k0 as f64);
// Trilinear value and its partial derivatives.
let c = |di: i64, dj: i64, dk: i64| node(i0 + di, j0 + dj, k0 + dk);
let lerp = |a: f64, b: f64, f: f64| a + f * (b - a);
let c00 = lerp(c(0, 0, 0), c(1, 0, 0), fx);
let c10 = lerp(c(0, 1, 0), c(1, 1, 0), fx);
let c01 = lerp(c(0, 0, 1), c(1, 0, 1), fx);
let c11 = lerp(c(0, 1, 1), c(1, 1, 1), fx);
let c0 = lerp(c00, c10, fy);
let c1 = lerp(c01, c11, fy);
let s = lerp(c0, c1, fz);
let dx0 = lerp(c(1, 0, 0) - c(0, 0, 0), c(1, 1, 0) - c(0, 1, 0), fy);
let dx1 = lerp(c(1, 0, 1) - c(0, 0, 1), c(1, 1, 1) - c(0, 1, 1), fy);
let dphi_dx = lerp(dx0, dx1, fz) / h[0];
let dy0 = lerp(c(0, 1, 0) - c(0, 0, 0), c(1, 1, 0) - c(1, 0, 0), fx);
let dy1 = lerp(c(0, 1, 1) - c(0, 0, 1), c(1, 1, 1) - c(1, 0, 1), fx);
let dphi_dy = lerp(dy0, dy1, fz) / h[1];
let dz0 = lerp(c(0, 0, 1) - c(0, 0, 0), c(1, 0, 1) - c(1, 0, 0), fx);
let dz1 = lerp(c(0, 1, 1) - c(0, 1, 0), c(1, 1, 1) - c(1, 1, 0), fx);
let dphi_dz = lerp(dz0, dz1, fy) / h[2];
let norm = (dphi_dx * dphi_dx + dphi_dy * dphi_dy + dphi_dz * dphi_dz).sqrt();
if norm > 0.0 {
(s, [dphi_dx / norm, dphi_dy / norm, dphi_dz / norm])
} else {
(s, [1.0, 0.0, 0.0])
}
}
/// 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 moving rigid body's wall fluxes by the discrete geometric
/// conservation law: `(V_c^{n+1} V_c^n)/dt` per active cell (a dying
/// cell's remaining volume leaves through its step-averaged apertures),
/// the net (the cut geometry's closure defect) redistributed over the
/// wall cells by wall area.
pub fn gcl_flux_table(&self, old: &Mask, dt: f64) -> (Vec<f64>, f64) {
let mut table = vec![0.0; self.grid.cells()];
let (Some(cut), Some(old_cut)) = (self.cut.as_ref(), old.cut.as_ref()) else {
return (table, 0.0);
};
let g = self.grid;
let dv = g.dx * g.dy * g.dz;
let (mut net, mut area) = (0.0, 0.0);
for (idx, entry) in table.iter_mut().enumerate() {
if !self.cell_active(idx) {
continue;
}
*entry = (cut.vol[idx] - old_cut.vol[idx]) * dv / dt;
net += *entry;
let w = cut.wall[idx];
area += (w[0] * w[0] + w[1] * w[1] + w[2] * w[2]).sqrt();
}
if std::env::var_os("RTX_E3_DEBUG").is_some() {
let dead = (0..table.len())
.filter(|&i| !self.cell_fluid[i] && old.cell_fluid[i])
.count();
let fresh = (0..table.len())
.filter(|&i| self.cell_fluid[i] && !old.cell_fluid[i])
.count();
let (vn, vn1): (f64, f64) = (old_cut.vol.iter().sum(), cut.vol.iter().sum());
eprintln!(
" gcl: dead {dead} fresh {fresh} net {net:.3e} area {area:.3e} ΣV old {vn:.6} new {vn1:.6}{:.3e})",
vn1 - vn
);
}
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 (p, s) = self.cut_wall_force_parts(body, f, mu, t)?;
let x = self.cut_wall_exchange_force(body, f, mu, self.density, t, None)?;
Some([p[0] + s[0] + x[0], p[1] + s[1] + x[1], p[2] + s[2] + x[2]])
}
/// The reconstructed wall route (S2-1 remedy): on every wall polygon
/// (the cell's closure `W_c`, its centroid taken as the cell centre's
/// foot on the interpolant surface) the traction from two probes at
/// `h` and `2h` along the interpolant normal — the wall pressure by
/// linear extrapolation, the wall shear from the quadratic fit of the
/// tangential velocity through the probes and the wall velocity (the
/// binary wall's validated sampler, on the cut geometry's own
/// polygons; the probes are a cell away, past the merged slivers).
/// Force on the body: `Σ_c (p_w W_c + μ ∂ₙu_t |W_c| t)` over the
/// planes `k0..k1`; `None` without a cut geometry.
pub fn cut_wall_force_reconstructed(
&self,
body: &Body,
f: &Field,
mu: f64,
t: f64,
planes: Option<(usize, usize)>,
) -> Option<[f64; 3]> {
let (p, s) = self.cut_wall_force_reconstructed_parts(body, f, mu, t, planes)?;
Some([p[0] + s[0], p[1] + s[1], p[2] + s[2]])
}
/// The reconstructed route split into its pressure and shear parts.
pub fn cut_wall_force_reconstructed_parts(
&self,
body: &Body,
f: &Field,
mu: f64,
t: f64,
planes: Option<(usize, usize)>,
) -> Option<([f64; 3], [f64; 3])> {
let cut = self.cut.as_ref()?;
let g = self.grid;
let (k0, k1) = planes.unwrap_or((0, g.nz));
let h = g.dx.min(g.dy).min(g.dz);
let (d1, d2) = (h, 2.0 * h);
let mut force = [0.0; 3];
let mut shear = [0.0; 3];
for (idx, w) in cut.wall.iter().enumerate() {
let area = (w[0] * w[0] + w[1] * w[1] + w[2] * w[2]).sqrt();
if area == 0.0 || !self.cell_fluid[idx] {
continue;
}
let (k, j, i) = g.kji(idx);
if k < k0 || k >= k1 {
continue;
}
let xc = [
(i as f64 + 0.5) * g.dx,
(j as f64 + 0.5) * g.dy,
(k as f64 + 0.5) * g.dz,
];
let (s, n) = self.interpolant_distance_and_normal(cut, xc);
// The wall point and the outward (into the fluid) normal.
let x = [xc[0] - s * n[0], xc[1] - s * n[1], xc[2] - s * n[2]];
let at = |d: f64| [x[0] + d * n[0], x[1] + d * n[1], x[2] + d * n[2]];
let (x1, x2) = (at(d1), at(d2));
let (Some(p1), Some(p2)) = (
self.pressure_at(&f.p, x1[0], x1[1], x1[2]),
self.pressure_at(&f.p, x2[0], x2[1], x2[2]),
) else {
// No fit: the operator's own pressure on this polygon.
for c in 0..3 {
force[c] += f.p[idx] * w[c];
}
continue;
};
let p_wall = p1 + (p1 - p2) * d1 / (d2 - d1);
for c in 0..3 {
force[c] += p_wall * w[c];
}
let (Some(u1), Some(u2)) = (
self.velocity_at(body, f, x1[0], x1[1], x1[2], t),
self.velocity_at(body, f, x2[0], x2[1], x2[2], t),
) else {
continue;
};
let us = body.surface_velocity(x[0], x[1], x[2], t);
let us = [us.0, us.1, us.2];
// Tangential components (the normal removed) and the wall
// gradient of the quadratic through 0, d1, d2.
let tang = |v: [f64; 3]| {
let vn = v[0] * n[0] + v[1] * n[1] + v[2] * n[2];
[v[0] - vn * n[0], v[1] - vn * n[1], v[2] - vn * n[2]]
};
let (t1, t2, ts) = (tang(u1), tang(u2), tang(us));
let wall_gradient =
|f1: f64, f2: f64| (f1 * d2 * d2 - f2 * d1 * d1) / (d1 * d2 * (d2 - d1));
for c in 0..3 {
let dn = wall_gradient(t1[c] - ts[c], t2[c] - ts[c]);
// Traction on the body = (fluid stress on the fluid side):
// the shear the fluid exerts on the wall along +t.
shear[c] += mu * dn * area;
}
}
Some((force, shear))
}
/// The cut-cell load route restricted to the cells (and faces) of the
/// planes `k0..k1`, divided by the slab's thickness: the load per unit
/// span on a body's mid-section.
pub fn cut_wall_force_per_span(
&self,
body: &Body,
f: &Field,
mu: f64,
t: f64,
(k0, k1): (usize, usize),
) -> Option<[f64; 3]> {
let (p, s) = self.cut_wall_force_parts_in(body, f, mu, t, Some((k0, k1)))?;
let x = self.cut_wall_exchange_force(body, f, mu, self.density, t, Some((k0, k1)))?;
let lz = (k1 - k0) as f64 * self.grid.dz;
Some([
(p[0] + s[0] + x[0]) / lz,
(p[1] + s[1] + x[1]) / lz,
(p[2] + s[2] + x[2]) / lz,
])
}
/// The cut-cell load route split into its pressure and shear parts.
pub fn cut_wall_force_parts(
&self,
body: &Body,
f: &Field,
mu: f64,
t: f64,
) -> Option<([f64; 3], [f64; 3])> {
self.cut_wall_force_parts_in(body, f, mu, t, None)
}
fn cut_wall_force_parts_in(
&self,
body: &Body,
f: &Field,
mu: f64,
t: f64,
planes: Option<(usize, usize)>,
) -> Option<([f64; 3], [f64; 3])> {
let cut = self.cut.as_ref()?;
let g = self.grid;
let (nx, ny, nz) = (g.nx, g.ny, g.nz);
let (k0, k1) = planes.unwrap_or((0, nz));
let mut pressure = [0.0; 3];
let mut force = [0.0; 3];
for (idx, w) in cut.wall.iter().enumerate() {
let k = g.kji(idx).0;
if self.cell_fluid[idx] && k >= k0 && k < k1 {
for c in 0..3 {
pressure[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, k0..k1),
1 => (0..nx, 1..ny, k0..k1),
_ => (0..nx, 0..ny, w_range.start.max(k0)..w_range.end.min(k1)),
};
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((pressure, force))
}
}