embedded3 item 10b: virtual merging of small cells in the projection (fraction < 0.1 → master = largest active face neighbour; off-stencil links on the fine Poisson level; merged rhs, anchor, mass residual and source scale); static sphere rows within 0.02 %, loads 9.4/10.4 %; stadium falsifier spikes 39–54× below the binary wall (circle 6–8×), energy per event 6–9× lower
CI / Distributed Training Tests (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 / Test (macos-latest) (push) Blocked by required conditions
CI / Test (ubuntu-latest) (push) Blocked by required conditions
CI / Build (macos-latest) (push) Waiting to run
CI / CI Success (push) Blocked by required conditions
Performance Benchmarks / Run Benchmarks (push) Failing after 4s
CI / Clippy Check (push) Failing after 3s
CI / Build (ubuntu-latest) (push) Failing after 3s
CI / Format Check (push) Failing after 4s
Documentation / Build User Guide (push) Successful in 6s
Documentation / Build API Documentation (push) Failing after 19s
CI / Build CPU-Only (Explicit) (push) Failing after 1m21s

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
Omar Sobh
2026-09-17 16:41:29 -05:00
co-authored by Claude Fable 5.1
parent 5b1621e6ad
commit 0fa05f2056
7 changed files with 281 additions and 16 deletions
@@ -25,6 +25,9 @@ use super::wall::{FaceKind, Mask};
pub(super) const INERTIA_FLOOR: f64 = 0.1; pub(super) const INERTIA_FLOOR: f64 = 0.1;
/// The wall-distance floor of a face, in units of the smallest spacing. /// The wall-distance floor of a face, in units of the smallest spacing.
pub(super) const DISTANCE_FLOOR: f64 = 0.05; 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 /// 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 /// data: a face of component `c` at `p = [i, j, k]` (its own coordinate is
@@ -175,7 +178,7 @@ impl Mask {
} }
} }
} }
Ok(Self { let mut mask = Self {
grid: g, grid: g,
periodic_z: periodic, periodic_z: periodic,
cell_fluid, cell_fluid,
@@ -190,7 +193,53 @@ impl Mask {
cut: Some(cut), cut: Some(cut),
step_apertures: None, step_apertures: None,
step_open: None, step_open: None,
}) merge_master: Vec::new(),
};
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]))
};
let small: Vec<bool> = (0..n)
.map(|idx| self.cell_active(idx) && frac(idx) < MERGE_FRACTION)
.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 /// Set the step-averaged apertures and the space-time classification
@@ -214,6 +263,7 @@ impl Mask {
.collect(); .collect();
self.step_open = Some((open(&au), open(&av), open(&aw), active)); self.step_open = Some((open(&au), open(&av), open(&aw), active));
self.step_apertures = Some((au, av, aw)); self.step_apertures = Some((au, av, aw));
self.compute_merging(Some(old));
} }
pub(super) fn lattice(&self) -> Lattice { pub(super) fn lattice(&self) -> Lattice {
@@ -27,6 +27,8 @@ pub(crate) struct Level<T: MgScalar> {
pub(crate) top: Vec<usize>, pub(crate) top: Vec<usize>,
pub(crate) bot: Vec<usize>, pub(crate) bot: Vec<usize>,
pub(crate) coarse_of: Vec<usize>, pub(crate) coarse_of: Vec<usize>,
/// Off-stencil links per cell (finest level only; empty elsewhere).
pub(crate) links: Vec<Vec<(usize, T)>>,
} }
struct Work<T: MgScalar> { struct Work<T: MgScalar> {
@@ -97,6 +99,15 @@ impl<T: MgScalar> Level<T> {
.filter(|&idx| parity(idx) == 1) .filter(|&idx| parity(idx) == 1)
.collect(); .collect();
let cast = |v: &[f64]| v.iter().map(|&x| T::from_f64(x)).collect::<Vec<T>>(); let cast = |v: &[f64]| v.iter().map(|&x| T::from_f64(x)).collect::<Vec<T>>();
let links: Vec<Vec<(usize, T)>> = if problem.links.is_empty() {
Vec::new()
} else {
problem
.link_lists()
.into_iter()
.map(|l| l.into_iter().map(|(o, c)| (o, T::from_f64(c))).collect())
.collect()
};
Self { Self {
ae: cast(&problem.ae), ae: cast(&problem.ae),
aw: cast(&problem.aw), aw: cast(&problem.aw),
@@ -113,6 +124,7 @@ impl<T: MgScalar> Level<T> {
top, top,
bot, bot,
coarse_of: Vec::new(), coarse_of: Vec::new(),
links,
} }
} }
@@ -145,6 +157,11 @@ impl<T: MgScalar> Level<T> {
if ab != T::ZERO { if ab != T::ZERO {
s += ab * x[self.bot[idx]]; s += ab * x[self.bot[idx]];
} }
if !self.links.is_empty() {
for &(other, c) in &self.links[idx] {
s += c * x[other];
}
}
s s
} }
@@ -356,6 +373,7 @@ impl Components {
let mut members = Vec::new(); let mut members = Vec::new();
let mut singular = Vec::new(); let mut singular = Vec::new();
let mut stack = Vec::new(); let mut stack = Vec::new();
let links = problem.link_lists();
for &seed in cells { for &seed in cells {
if id[seed] != usize::MAX { if id[seed] != usize::MAX {
continue; continue;
@@ -395,6 +413,9 @@ impl Components {
if let Some(b) = problem.bottom(idx, k) { if let Some(b) = problem.bottom(idx, k) {
visit(b, problem.ab[idx]); visit(b, problem.ab[idx]);
} }
for &(other, c) in &links[idx] {
visit(other, c);
}
} }
members.push(list); members.push(list);
singular.push(!has_dirichlet); singular.push(!has_dirichlet);
@@ -17,6 +17,7 @@ pub(crate) struct OperatorKey {
periodic_z: bool, periodic_z: bool,
active: Vec<bool>, active: Vec<bool>,
coefficients: Vec<u64>, coefficients: Vec<u64>,
links: Vec<(usize, usize, u64)>,
smoother_sweeps: usize, smoother_sweeps: usize,
coarsest_cells: usize, coarsest_cells: usize,
smoother: MgSmoother, smoother: MgSmoother,
@@ -44,6 +45,11 @@ impl OperatorKey {
periodic_z: problem.periodic_z, periodic_z: problem.periodic_z,
active: problem.active.clone(), active: problem.active.clone(),
coefficients: Self::bits(problem).collect(), coefficients: Self::bits(problem).collect(),
links: problem
.links
.iter()
.map(|&(a, b, c)| (a, b, c.to_bits()))
.collect(),
smoother_sweeps: params.smoother_sweeps, smoother_sweeps: params.smoother_sweeps,
coarsest_cells: params.coarsest_cells, coarsest_cells: params.coarsest_cells,
smoother: params.smoother, smoother: params.smoother,
@@ -59,6 +65,12 @@ impl OperatorKey {
&& self.coarsest_cells == params.coarsest_cells && self.coarsest_cells == params.coarsest_cells
&& self.smoother == params.smoother && self.smoother == params.smoother
&& self.active == problem.active && self.active == problem.active
&& self.links.len() == problem.links.len()
&& self
.links
.iter()
.zip(&problem.links)
.all(|(&(a, b, c), &(pa, pb, pc))| a == pa && b == pb && c == pc.to_bits())
&& self.coefficients.iter().copied().eq(Self::bits(problem)) && self.coefficients.iter().copied().eq(Self::bits(problem))
} }
} }
@@ -21,6 +21,12 @@ pub struct Problem {
pub ab: Vec<f64>, pub ab: Vec<f64>,
pub extra_diag: Vec<f64>, pub extra_diag: Vec<f64>,
pub rhs: Vec<f64>, pub rhs: Vec<f64>,
/// Off-stencil symmetric links `(a, b, coefficient)` between two active
/// cells (a virtually merged small cell's master to the small cell's
/// other neighbours); each adds `coefficient` to both diagonals and
/// `coefficient` off-diagonal both ways. Carried by the finest level
/// only (the coarse levels stay seven-point: preconditioner quality).
pub links: Vec<(usize, usize, f64)>,
} }
impl Problem { impl Problem {
@@ -42,9 +48,31 @@ impl Problem {
ab: vec![0.0; n], ab: vec![0.0; n],
extra_diag: vec![0.0; n], extra_diag: vec![0.0; n],
rhs: vec![0.0; n], rhs: vec![0.0; n],
links: Vec::new(),
} }
} }
/// The link coefficients per cell (`(other, coefficient)` lists).
#[must_use]
pub fn link_lists(&self) -> Vec<Vec<(usize, f64)>> {
let mut out = vec![Vec::new(); self.nx * self.ny * self.nz];
for &(a, b, c) in &self.links {
out[a].push((b, c));
out[b].push((a, c));
}
out
}
/// The sum of the link coefficients on a cell.
#[must_use]
pub fn link_diagonal(&self, idx: usize) -> f64 {
self.links
.iter()
.filter(|&&(a, b, _)| a == idx || b == idx)
.map(|&(_, _, c)| c)
.sum()
}
#[inline] #[inline]
#[must_use] #[must_use]
pub fn index(&self, k: usize, j: usize, i: usize) -> usize { pub fn index(&self, k: usize, j: usize, i: usize) -> usize {
@@ -86,13 +114,18 @@ impl Problem {
#[inline] #[inline]
#[must_use] #[must_use]
pub fn diagonal(&self, idx: usize) -> f64 { pub fn diagonal(&self, idx: usize) -> f64 {
self.ae[idx] let stencil = self.ae[idx]
+ self.aw[idx] + self.aw[idx]
+ self.an[idx] + self.an[idx]
+ self.as_[idx] + self.as_[idx]
+ self.at[idx] + self.at[idx]
+ self.ab[idx] + self.ab[idx]
+ self.extra_diag[idx] + self.extra_diag[idx];
if self.links.is_empty() {
stencil
} else {
stencil + self.link_diagonal(idx)
}
} }
/// Pure Neumann: no active cell has a Dirichlet contribution. /// Pure Neumann: no active cell has a Dirichlet contribution.
@@ -109,6 +142,7 @@ impl Problem {
#[must_use] #[must_use]
pub fn residual_l1(&self, p: &[f64]) -> f64 { pub fn residual_l1(&self, p: &[f64]) -> f64 {
let (nx, ny, nz) = (self.nx, self.ny, self.nz); let (nx, ny, nz) = (self.nx, self.ny, self.nz);
let links = self.link_lists();
let mut sum = 0.0; let mut sum = 0.0;
for k in 0..nz { for k in 0..nz {
for j in 0..ny { for j in 0..ny {
@@ -144,6 +178,9 @@ impl Problem {
nb += self.ab[idx] * p[b]; nb += self.ab[idx] * p[b];
} }
} }
for &(other, c) in &links[idx] {
nb += c * p[other];
}
sum += (self.rhs[idx] - (ap * p[idx] - nb)).abs(); sum += (self.rhs[idx] - (ap * p[idx] - nb)).abs();
} }
} }
@@ -170,6 +207,11 @@ impl Problem {
return Err(format!("{name}: length {len}, expected nx*ny*nz = {n}")); return Err(format!("{name}: length {len}, expected nx*ny*nz = {n}"));
} }
} }
for &(a, b, c) in &self.links {
if a >= n || b >= n || a == b || !self.active[a] || !self.active[b] || c <= 0.0 || c.is_nan() {
return Err(format!("invalid link ({a}, {b}, {c})"));
}
}
let (nx, ny, nz) = (self.nx, self.ny, self.nz); let (nx, ny, nz) = (self.nx, self.ny, self.nz);
let symmetric = |a: f64, b: f64| (a - b).abs() <= 1e-12 * a.abs().max(b.abs()); let symmetric = |a: f64, b: f64| (a - b).abs() <= 1e-12 * a.abs().max(b.abs());
for k in 0..nz { for k in 0..nz {
@@ -80,13 +80,98 @@ impl Solver {
} }
} }
} }
self.merge_small_cells(&mut problem);
problem problem
} }
/// Virtual merging (item 10b): every small cell's row is folded into
/// its master's — the small cell becomes inactive, its faces to other
/// cells become links from the master (to the neighbour, or to that
/// neighbour's master), its Dirichlet contribution moves to the
/// master; the face between the two is internal to the merged cell.
fn merge_small_cells(&self, problem: &mut Problem) {
let Some(mask) = self.mask.as_ref() else {
return;
};
if mask.merged_cells() == 0 {
return;
}
let g = mask.grid();
let (nx, ny, nz) = (g.nx, g.ny, g.nz);
let target = |idx: usize| mask.master(idx).unwrap_or(idx);
for k in 0..nz {
for j in 0..ny {
for i in 0..nx {
let s = g.cell(k, j, i);
let Some(m) = mask.master(s) else { continue };
if !problem.active[s] {
continue;
}
problem.active[s] = false;
// (neighbour, coefficient on s, the mirror coefficient on the neighbour)
let mut faces: Vec<(usize, f64)> = Vec::with_capacity(6);
if i + 1 < nx {
faces.push((s + 1, problem.ae[s]));
problem.aw[s + 1] = 0.0;
}
if i > 0 {
faces.push((s - 1, problem.aw[s]));
problem.ae[s - 1] = 0.0;
}
if j + 1 < ny {
faces.push((s + nx, problem.an[s]));
problem.as_[s + nx] = 0.0;
}
if j > 0 {
faces.push((s - nx, problem.as_[s]));
problem.an[s - nx] = 0.0;
}
if let Some(t) = problem.top(s, k) {
faces.push((t, problem.at[s]));
problem.ab[t] = 0.0;
}
if let Some(b) = problem.bottom(s, k) {
faces.push((b, problem.ab[s]));
problem.at[b] = 0.0;
}
problem.ae[s] = 0.0;
problem.aw[s] = 0.0;
problem.an[s] = 0.0;
problem.as_[s] = 0.0;
problem.at[s] = 0.0;
problem.ab[s] = 0.0;
for (n, c) in faces {
let t = target(n);
if c > 0.0 && t != m {
problem.links.push((m.min(t), m.max(t), c));
}
}
problem.extra_diag[m] += problem.extra_diag[s];
problem.extra_diag[s] = 0.0;
}
}
}
}
/// Fold the small cells' right-hand sides into their masters'.
fn merge_rhs(&self, problem: &mut Problem) {
let Some(mask) = self.mask.as_ref() else {
return;
};
for s in 0..problem.rhs.len() {
if let Some(m) = mask.master(s) {
let v = problem.rhs[s];
problem.rhs[m] += v;
problem.rhs[s] = 0.0;
}
}
}
/// The operator with `field.sp` as the right-hand side. /// The operator with `field.sp` as the right-hand side.
pub(crate) fn poisson_problem(&self, field: &Field, dt: f64) -> Problem { pub(crate) fn poisson_problem(&self, field: &Field, dt: f64) -> Problem {
let mut problem = self.poisson_operator(field.grid, dt); let mut problem = self.poisson_operator(field.grid, dt);
problem.rhs.copy_from_slice(&field.sp); problem.rhs.copy_from_slice(&field.sp);
self.merge_rhs(&mut problem);
problem problem
} }
@@ -94,9 +179,10 @@ impl Solver {
/// the 2D `(1, 1)` at `k = 0`), or `None` with an outlet. /// the 2D `(1, 1)` at `k = 0`), or `None` with an outlet.
pub(crate) fn anchor_cell(&self, g: Grid) -> Option<usize> { pub(crate) fn anchor_cell(&self, g: Grid) -> Option<usize> {
(!self.params.boundaries.any_outlet()).then(|| { (!self.params.boundaries.any_outlet()).then(|| {
self.mask self.mask.as_ref().map_or(g.cell(0, 1, 1), |m| {
.as_ref() let a = m.anchor();
.map_or(g.cell(0, 1, 1), super::super::wall::Mask::anchor) m.master(a).unwrap_or(a)
})
}) })
} }
@@ -274,8 +360,20 @@ impl Solver {
} }
} }
} }
let inner_stop = self.inner_stop(g, source_scale);
let problem = self.poisson_problem(field, dt); let problem = self.poisson_problem(field, dt);
// The source scale reads the merged right-hand side (a merged small
// cell's own divergence is not zero, its pair's is); identical to
// the per-cell sum when nothing is merged.
if self.mask.as_ref().is_some_and(|m| m.merged_cells() > 0) {
source_scale = problem
.rhs
.iter()
.zip(&problem.active)
.filter(|&(_, &a)| a)
.map(|(r, _)| r.abs())
.sum();
}
let inner_stop = self.inner_stop(g, source_scale);
let mut p_prime = vec![0.0; g.cells()]; let mut p_prime = vec![0.0; g.cells()];
if warm_start { if warm_start {
for k in 0..nz { for k in 0..nz {
@@ -310,6 +408,13 @@ impl Solver {
c0 + 1, c0 + 1,
k0 + solution.iterations as u64, k0 + solution.iterations as u64,
); );
if let Some(mask) = self.mask.as_ref() {
for s in 0..p_prime.len() {
if let Some(m) = mask.master(s) {
p_prime[s] = p_prime[m];
}
}
}
field.p_prime.copy_from_slice(&p_prime); field.p_prime.copy_from_slice(&p_prime);
solution solution
} }
@@ -401,7 +506,7 @@ impl Solver {
} }
} }
} }
let mut mass_imbalance = 0.0; let mut cell_flux = vec![0.0; g.cells()];
for k in 0..nz { for k in 0..nz {
for j in 0..ny { for j in 0..ny {
for i in 0..nx { for i in 0..nx {
@@ -422,10 +527,17 @@ impl Solver {
if cut { if cut {
divergence_flux += rho * self.wall_flux(idx); divergence_flux += rho * self.wall_flux(idx);
} }
mass_imbalance += divergence_flux.abs(); // A merged small cell's flux counts with its master's.
let owner = self
.mask
.as_ref()
.and_then(|m| m.master(idx))
.unwrap_or(idx);
cell_flux[owner] += divergence_flux;
} }
} }
} }
let mass_imbalance: f64 = cell_flux.iter().map(|f| f.abs()).sum();
let reference_flux = self.reference_flux(g); let reference_flux = self.reference_flux(g);
if reference_flux > 0.0 { if reference_flux > 0.0 {
mass_imbalance / reference_flux mass_imbalance / reference_flux
@@ -88,6 +88,10 @@ pub struct Mask {
/// step (a dying cell empties through the apertures it had); `None` = /// step (a dying cell empties through the apertures it had); `None` =
/// the instantaneous kinds. /// the instantaneous kinds.
pub(super) step_open: Option<(Vec<bool>, Vec<bool>, Vec<bool>, Vec<bool>)>, pub(super) step_open: Option<(Vec<bool>, Vec<bool>, Vec<bool>, Vec<bool>)>,
/// Virtual merging (item 10b): the master cell of every small cell
/// (`usize::MAX` = its own row) — a small cell shares its pressure
/// unknown with its largest active face neighbour in the projection.
pub(super) merge_master: Vec<usize>,
} }
/// The z lattice position of a query: the lower plane index, the upper /// The z lattice position of a query: the lower plane index, the upper
@@ -497,9 +501,27 @@ impl Mask {
cut: None, cut: None,
step_apertures: None, step_apertures: None,
step_open: None, step_open: None,
merge_master: Vec::new(),
}) })
} }
/// The master of a virtually merged small cell.
#[inline]
#[must_use]
pub fn master(&self, idx: usize) -> Option<usize> {
match self.merge_master.get(idx) {
Some(&m) if m != usize::MAX => Some(m),
_ => None,
}
}
#[must_use]
pub fn merged_cells(&self) -> usize {
self.merge_master
.iter()
.filter(|&&m| m != usize::MAX)
.count()
}
// The projection's unknowns (the instantaneous kinds at rest). // The projection's unknowns (the instantaneous kinds at rest).
#[inline] #[inline]
#[must_use] #[must_use]
@@ -197,11 +197,14 @@ pub fn measure(n: usize, scheme: WallScheme, c: (f64, f64, f64)) -> Measurement
let body = solver.body().expect("body"); let body = solver.body().expect("body");
let t = solver.time(); let t = solver.time();
// The apertured divergence per unit volume, the porous surface's flux // The apertured divergence per unit volume, the porous surface's flux
// through the wall included (the plain divergence on the binary wall). // through the wall included (the plain divergence on the binary wall);
// a virtually merged small cell's flux counts with its master's (only
// the pair's continuity holds).
let mut max_div = 0.0_f64; let mut max_div = 0.0_f64;
let mut at_vol = 1.0; let mut at_vol = 1.0;
let mut sum_flux = 0.0; let mut sum_flux = 0.0;
let (wall_fluxes, _) = mask.wall_flux_table(body, t); let (wall_fluxes, _) = mask.wall_flux_table(body, t);
let mut cell_flux = vec![0.0; g.cells()];
for k in 0..n { for k in 0..n {
for j in 0..n { for j in 0..n {
for i in 0..n { for i in 0..n {
@@ -220,15 +223,18 @@ pub fn measure(n: usize, scheme: WallScheme, c: (f64, f64, f64)) -> Measurement
* h * h
* h * h
+ wall_fluxes[idx]; + wall_fluxes[idx];
cell_flux[mask.master(idx).unwrap_or(idx)] += flux;
}
}
}
}
for (idx, &flux) in cell_flux.iter().enumerate() {
sum_flux += flux.abs(); sum_flux += flux.abs();
if (flux / (h * h * h)).abs() > max_div { if (flux / (h * h * h)).abs() > max_div {
max_div = (flux / (h * h * h)).abs(); max_div = (flux / (h * h * h)).abs();
at_vol = mask.vol(idx); at_vol = mask.vol(idx);
} }
} }
}
}
}
println!( println!(
" [{scheme:?} n {n}] max div {max_div:.2e} in a cell of fluid fraction {at_vol:.3e}; Σ|flux| {sum_flux:.2e}; last step residual {:.2e}", " [{scheme:?} n {n}] max div {max_div:.2e} in a cell of fluid fraction {at_vol:.3e}; Σ|flux| {sum_flux:.2e}; last step residual {:.2e}",
last.final_residual last.final_residual