embedded3 item 11: moving bodies (end-of-step mask, fresh-cell refill, space-time cut cell: step-averaged apertures, GCL wall flux, Reynolds-transport momentum), the 3D fresh-cell falsifier (plate / circle / stadium, wall + control-volume routes) and the Lipschitz sweep; ghost wall reproduces the 2D falsifier to the digit; cut wall 5–14× smoother on the circle, gates not met (fresh cell's first step); wall.rs split (impose.rs)
CI / Clippy Check (push) Failing after 3s
CI / Build (ubuntu-latest) (push) Failing after 4s
CI / Format Check (push) Failing after 4s
Performance Benchmarks / Run Benchmarks (push) Failing after 5s
Documentation / Build User Guide (push) Successful in 6s
CI / Build CPU-Only (Explicit) (push) Failing after 1m6s
Documentation / Build API Documentation (push) Failing after 1m9s
CI / Build (macos-latest) (push) Canceled after 0s
CI / Test (macos-latest) (push) Canceled after 0s
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Python Bindings (maturin) (macos-latest) (push) Canceled after 0s
CI / Python Bindings (maturin) (ubuntu-latest) (push) Canceled after 0s
CI / WASM Build + Size Check (push) Canceled after 0s
CI / Distributed Training Tests (push) Canceled after 0s
CI / CI Success (push) Canceled after 0s

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
Omar Sobh
2026-09-17 16:20:35 -05:00
co-authored by Claude Fable 5.1
parent 0e4c97ed24
commit 5b1621e6ad
12 changed files with 1406 additions and 419 deletions
@@ -4,8 +4,9 @@
//! the wall; its faces carry the mass fluxes averaged from the two adjacent
//! cells (the 2D face velocities when every aperture is 1), upwind plus the
//! TVD correction as the 2D predictor, apertured diffusion, the pressure
//! force `(p₊ p₋) α A` (the projection's gradient), the wall's momentum
//! flux `m_w U_b` with `m_w = −Σ m_f` (so a uniform field stays uniform),
//! force `(p₊ p₋) α A` (the projection's gradient), the net mass flux
//! times the face's own value (Reynolds transport; a uniform field stays
//! uniform on any wall motion),
//! and the implicit wall shear `μ A_w (u U_b)/d_f`; the time derivative
//! carries the inertia floor.
@@ -195,8 +196,12 @@ impl Solver {
}
};
}
// The wall's momentum flux closes the mass balance exactly.
conv -= mass_out * ub;
// Reynolds transport over a volume whose wall moves with the fluid
// on it: `ρV du/dt = −Σ m (u_face u)` — the net mass flux of the
// control volume (zero for a body at rest, the swept rate
// otherwise) multiplies the face's own value, so a uniform field
// stays uniform on any wall motion.
conv -= mass_out * u0;
let p_plus = lat.cell(cell_plus).map_or(0.0, |ci| field.p[ci]);
let p_minus = lat.cell(cell_minus).map_or(0.0, |ci| field.p[ci]);
let pressure = -(p_plus - p_minus) * cv.alpha * area[c];
@@ -550,6 +550,7 @@ impl DeviceStep {
tm.cg_iterations += cg_iterations as u64;
}
StepResult {
fresh_cells: 0,
converged: final_residual < self.solver.params.tolerance,
corrector_steps_performed: total,
final_residual,
@@ -99,6 +99,8 @@ pub struct StepResult {
pub final_residual: f64,
/// CG iterations summed over the step's projections.
pub poisson_iterations: usize,
/// Cells that became fluid on this step (a moving body).
pub fresh_cells: usize,
}
type Vec3Fn = Box<dyn Fn(f64, f64, f64, f64) -> (f64, f64, f64) + Send + Sync>;
@@ -109,6 +111,8 @@ pub struct Solver {
pub(super) momentum_source: Option<Vec3Fn>,
boundary_velocity: Option<Vec3Fn>,
body: Option<Body>,
/// The body moves: the mask is rebuilt at every step's new time.
moving: bool,
mask: Option<Mask>,
last_ghost_correction: f64,
wall_fluxes: Vec<f64>,
@@ -137,6 +141,7 @@ impl Solver {
momentum_source: None,
boundary_velocity: None,
body: None,
moving: false,
mask: None,
last_ghost_correction: 0.0,
wall_fluxes: Vec::new(),
@@ -164,9 +169,27 @@ impl Solver {
/// A static embedded body (the mask is built at initialisation).
pub fn set_body(&mut self, body: Body) {
self.body = Some(body);
self.moving = false;
self.mask = None;
}
/// A moving embedded body: the mask is rebuilt at every step's
/// end-of-step geometry (the 2D solver's order — predictor on the old
/// mask, projection on the new one).
pub fn set_moving_body(&mut self, body: Body) {
self.body = Some(body);
self.moving = true;
self.mask = None;
}
fn build_mask(&self, body: &Body, g: Grid, t: f64) -> Mask {
match self.params.wall_scheme {
WallScheme::GhostBinary => Mask::build(body, g, t, self.params.boundaries),
WallScheme::CutCell => Mask::build_cut(body, g, t, self.params.boundaries),
}
.expect("embedded mask")
}
#[must_use]
pub fn body(&self) -> Option<&Body> {
self.body.as_ref()
@@ -230,24 +253,52 @@ impl Solver {
.is_none_or(|m| m.is_fluid_cell(m.grid().cell(k, j, i)))
}
// The apertures (1 without a cut geometry).
// The projection's unknowns and equations (space-time on a moving cut
// wall, the fluid predicates otherwise).
#[inline]
pub(super) fn u_is_unknown(&self, k: usize, j: usize, i: usize) -> bool {
self.mask
.as_ref()
.is_none_or(|m| m.u_open(m.grid().uface(k, j, i)))
}
#[inline]
pub(super) fn v_is_unknown(&self, k: usize, j: usize, i: usize) -> bool {
self.mask
.as_ref()
.is_none_or(|m| m.v_open(m.grid().vface(k, j, i)))
}
#[inline]
pub(super) fn w_is_unknown(&self, k: usize, j: usize, i: usize) -> bool {
self.mask
.as_ref()
.is_none_or(|m| m.w_open(m.grid().wface(k, j, i)))
}
#[inline]
pub(super) fn cell_is_active(&self, k: usize, j: usize, i: usize) -> bool {
self.mask
.as_ref()
.is_none_or(|m| m.cell_active(m.grid().cell(k, j, i)))
}
// The projection's apertures: step-averaged on a moving cut wall
// (1 without a cut geometry).
#[inline]
pub(super) fn au(&self, k: usize, j: usize, i: usize) -> f64 {
self.mask
.as_ref()
.map_or(1.0, |m| m.a_u(m.grid().uface(k, j, i)))
.map_or(1.0, |m| m.au_step(m.grid().uface(k, j, i)))
}
#[inline]
pub(super) fn av(&self, k: usize, j: usize, i: usize) -> f64 {
self.mask
.as_ref()
.map_or(1.0, |m| m.a_v(m.grid().vface(k, j, i)))
.map_or(1.0, |m| m.av_step(m.grid().vface(k, j, i)))
}
#[inline]
pub(super) fn aw(&self, k: usize, j: usize, i: usize) -> f64 {
self.mask
.as_ref()
.map_or(1.0, |m| m.a_w(m.grid().wface(k, j, i)))
.map_or(1.0, |m| m.aw_step(m.grid().wface(k, j, i)))
}
/// The surface velocity's compatible flux through the cell's wall at
/// the step's new time (cut wall only; the table is rebuilt per step).
@@ -402,15 +453,7 @@ impl Solver {
let t = self.time;
if let Some(body) = &self.body {
if self.mask.is_none() {
let mask = match self.params.wall_scheme {
WallScheme::GhostBinary => {
Mask::build(body, field.grid, t, self.params.boundaries)
}
WallScheme::CutCell => {
Mask::build_cut(body, field.grid, t, self.params.boundaries)
}
};
self.mask = Some(mask.expect("embedded mask"));
self.mask = Some(self.build_mask(body, field.grid, t));
}
}
self.apply_boundary_normals(field, t);
@@ -434,13 +477,50 @@ impl Solver {
field.update_old_values();
self.momentum_predictor(field, dt, t_old);
self.apply_boundary_normals(field, t_new);
// A moving body: the mask at the end-of-step geometry, the pressure
// of the cells that just became fluid refilled from their
// neighbours (fluid in both masks), the new mask's prescribed and
// ghost values imposed from the previous corrected field.
let mut fresh_cells = 0;
if self.moving {
if let Some(body) = &self.body {
let mut new_mask = self.build_mask(body, field.grid, t_new);
if let Some(old_mask) = &self.mask {
fresh_cells = refill_fresh_cells(old_mask, &new_mask, field);
new_mask.set_step_apertures(old_mask);
}
new_mask.impose_from(
body,
&field.u_old,
&field.v_old,
&field.w_old,
&mut field.u,
&mut field.v,
&mut field.w,
t_new,
);
if new_mask.cut().is_some() {
let (table, correction) = match &self.mask {
Some(old_mask) => new_mask.gcl_flux_table(old_mask, dt),
None => new_mask.wall_flux_table(body, t_new),
};
self.wall_fluxes = table;
self.last_ghost_correction = correction;
}
self.mask = Some(new_mask);
}
}
field.copy_to_starred();
let mut cut_correction = None;
if let (Some(body), Some(mask)) = (&self.body, &self.mask) {
if mask.cut().is_some() {
let (table, correction) = mask.wall_flux_table(body, t_new);
self.wall_fluxes = table;
cut_correction = Some(correction);
if self.moving {
cut_correction = Some(self.last_ghost_correction);
} else {
let (table, correction) = mask.wall_flux_table(body, t_new);
self.wall_fluxes = table;
cut_correction = Some(correction);
}
}
}
let mut total = 0;
@@ -450,6 +530,12 @@ impl Solver {
let sol = self.solve_correction(field, dt, corrector == 0);
poisson_iterations += sol.iterations;
let mass_residual = self.apply_correction(field, dt);
if std::env::var_os("RTX_E3_DEBUG").is_some() {
eprintln!(
" corrector {corrector}: CG {} it (converged {}), residual {:.3e}, mass {:.3e}",
sol.iterations, sol.converged, sol.residual, mass_residual
);
}
final_residual = mass_residual;
total += 1;
if mass_residual < self.params.tolerance {
@@ -468,6 +554,65 @@ impl Solver {
corrector_steps_performed: total,
final_residual,
poisson_iterations,
fresh_cells,
}
}
}
/// Refill the pressure of the cells fluid in `new` and not in `old` from
/// their face neighbours fluid in both; returns their count.
fn refill_fresh_cells(old: &Mask, new: &Mask, field: &mut Field) -> usize {
let g = field.grid;
let (nx, ny, nz) = (g.nx, g.ny, g.nz);
let periodic = new.periodic_z();
let mut fresh = 0;
let mut refills = Vec::new();
for k in 0..nz {
for j in 0..ny {
for i in 0..nx {
let idx = g.cell(k, j, i);
if !(new.is_fluid_cell(idx) && !old.is_fluid_cell(idx)) {
continue;
}
fresh += 1;
let mut sum = 0.0;
let mut count = 0usize;
let mut visit = |nb: usize| {
if new.is_fluid_cell(nb) && old.is_fluid_cell(nb) {
sum += field.p[nb];
count += 1;
}
};
if i + 1 < nx {
visit(g.cell(k, j, i + 1));
}
if i > 0 {
visit(g.cell(k, j, i - 1));
}
if j + 1 < ny {
visit(g.cell(k, j + 1, i));
}
if j > 0 {
visit(g.cell(k, j - 1, i));
}
if k + 1 < nz {
visit(g.cell(k + 1, j, i));
} else if periodic && nz > 1 {
visit(g.cell(0, j, i));
}
if k > 0 {
visit(g.cell(k - 1, j, i));
} else if periodic && nz > 1 {
visit(g.cell(nz - 1, j, i));
}
if count > 0 {
refills.push((idx, sum / count as f64));
}
}
}
}
for (idx, p) in refills {
field.p[idx] = p;
}
fresh
}
@@ -29,7 +29,7 @@ impl Solver {
for j in 0..ny {
for i in 0..nx {
let idx = g.cell(k, j, i);
if !self.cell_is_fluid(k, j, i) {
if !self.cell_is_active(k, j, i) {
problem.active[idx] = false;
continue;
}
@@ -38,42 +38,42 @@ impl Solver {
if b.x1 == outlet {
extra += ae_outlet;
}
} else if self.u_is_fluid(k, j, i + 1) {
} else if self.u_is_unknown(k, j, i + 1) {
problem.ae[idx] = ae_interior * self.au(k, j, i + 1);
}
if i == 0 {
if b.x0 == outlet {
extra += ae_outlet;
}
} else if self.u_is_fluid(k, j, i) {
} else if self.u_is_unknown(k, j, i) {
problem.aw[idx] = ae_interior * self.au(k, j, i);
}
if j + 1 == ny {
if b.y1 == outlet {
extra += an_outlet;
}
} else if self.v_is_fluid(k, j + 1, i) {
} else if self.v_is_unknown(k, j + 1, i) {
problem.an[idx] = an_interior * self.av(k, j + 1, i);
}
if j == 0 {
if b.y0 == outlet {
extra += an_outlet;
}
} else if self.v_is_fluid(k, j, i) {
} else if self.v_is_unknown(k, j, i) {
problem.as_[idx] = an_interior * self.av(k, j, i);
}
if k + 1 == nz && !periodic {
if b.z1 == outlet {
extra += at_outlet;
}
} else if self.w_is_fluid((k + 1) % nz, j, i) {
} else if self.w_is_unknown((k + 1) % nz, j, i) {
problem.at[idx] = at_interior * self.aw((k + 1) % nz, j, i);
}
if k == 0 && !periodic {
if b.z0 == outlet {
extra += at_outlet;
}
} else if self.w_is_fluid(k, j, i) {
} else if self.w_is_unknown(k, j, i) {
problem.ab[idx] = at_interior * self.aw(k, j, i);
}
problem.extra_diag[idx] = extra;
@@ -252,7 +252,7 @@ impl Solver {
for j in 0..ny {
for i in 0..nx {
let idx = g.cell(k, j, i);
if !self.cell_is_fluid(k, j, i) {
if !self.cell_is_active(k, j, i) {
field.sp[idx] = 0.0;
continue;
}
@@ -281,7 +281,7 @@ impl Solver {
for k in 0..nz {
for j in 0..ny {
for i in 0..nx {
if self.cell_is_fluid(k, j, i) {
if self.cell_is_active(k, j, i) {
let idx = g.cell(k, j, i);
p_prime[idx] = field.p_prime[idx];
}
@@ -328,7 +328,7 @@ impl Solver {
for k in 0..nz {
for j in 0..ny {
for i in 1..nx {
if self.u_is_fluid(k, j, i) {
if self.u_is_unknown(k, j, i) {
let dp_dx = (pp[g.cell(k, j, i)] - pp[g.cell(k, j, i - 1)]) / dx;
let f = g.uface(k, j, i);
field.u[f] = field.u_star[f] - (dt / rho) * dp_dx;
@@ -347,7 +347,7 @@ impl Solver {
}
for i in 0..nx {
for j in 1..ny {
if self.v_is_fluid(k, j, i) {
if self.v_is_unknown(k, j, i) {
let dp_dy = (pp[g.cell(k, j, i)] - pp[g.cell(k, j - 1, i)]) / dy;
let f = g.vface(k, j, i);
field.v[f] = field.v_star[f] - (dt / rho) * dp_dy;
@@ -369,7 +369,7 @@ impl Solver {
for i in 0..nx {
let k_range = if periodic { 0..nz } else { 1..nz };
for k in k_range {
if self.w_is_fluid(k, j, i) {
if self.w_is_unknown(k, j, i) {
let below = if k > 0 { k - 1 } else { nz - 1 };
let dp_dz = (pp[g.cell(k, j, i)] - pp[g.cell(below, j, i)]) / dz;
let f = g.wface(k, j, i);
@@ -394,7 +394,7 @@ impl Solver {
for k in 0..nz {
for j in 0..ny {
for i in 0..nx {
if self.cell_is_fluid(k, j, i) {
if self.cell_is_active(k, j, i) {
let idx = g.cell(k, j, i);
field.p[idx] += pp[idx];
}
@@ -405,7 +405,7 @@ impl Solver {
for k in 0..nz {
for j in 0..ny {
for i in 0..nx {
if !self.cell_is_fluid(k, j, i) {
if !self.cell_is_active(k, j, i) {
continue;
}
let idx = g.cell(k, j, i);