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
Co-Authored-By: Claude Fable 5.1 <[email protected]>
764 lines
30 KiB
Rust
764 lines
30 KiB
Rust
//! 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))
|
||
}
|
||
}
|