diff --git a/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/cutwall.rs b/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/cutwall.rs index 4713db4..33674e9 100644 --- a/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/cutwall.rs +++ b/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/cutwall.rs @@ -25,6 +25,9 @@ use super::wall::{FaceKind, Mask}; 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 @@ -175,7 +178,7 @@ impl Mask { } } } - Ok(Self { + let mut mask = Self { grid: g, periodic_z: periodic, cell_fluid, @@ -190,7 +193,53 @@ impl Mask { cut: Some(cut), step_apertures: 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 = (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 @@ -214,6 +263,7 @@ impl Mask { .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 { diff --git a/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/poisson/hierarchy.rs b/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/poisson/hierarchy.rs index d31bcbd..814085b 100644 --- a/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/poisson/hierarchy.rs +++ b/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/poisson/hierarchy.rs @@ -27,6 +27,8 @@ pub(crate) struct Level { pub(crate) top: Vec, pub(crate) bot: Vec, pub(crate) coarse_of: Vec, + /// Off-stencil links per cell (finest level only; empty elsewhere). + pub(crate) links: Vec>, } struct Work { @@ -97,6 +99,15 @@ impl Level { .filter(|&idx| parity(idx) == 1) .collect(); let cast = |v: &[f64]| v.iter().map(|&x| T::from_f64(x)).collect::>(); + let links: Vec> = 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 { ae: cast(&problem.ae), aw: cast(&problem.aw), @@ -113,6 +124,7 @@ impl Level { top, bot, coarse_of: Vec::new(), + links, } } @@ -145,6 +157,11 @@ impl Level { if ab != T::ZERO { s += ab * x[self.bot[idx]]; } + if !self.links.is_empty() { + for &(other, c) in &self.links[idx] { + s += c * x[other]; + } + } s } @@ -356,6 +373,7 @@ impl Components { let mut members = Vec::new(); let mut singular = Vec::new(); let mut stack = Vec::new(); + let links = problem.link_lists(); for &seed in cells { if id[seed] != usize::MAX { continue; @@ -395,6 +413,9 @@ impl Components { if let Some(b) = problem.bottom(idx, k) { visit(b, problem.ab[idx]); } + for &(other, c) in &links[idx] { + visit(other, c); + } } members.push(list); singular.push(!has_dirichlet); diff --git a/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/poisson/pcg.rs b/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/poisson/pcg.rs index 204a499..825f302 100644 --- a/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/poisson/pcg.rs +++ b/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/poisson/pcg.rs @@ -17,6 +17,7 @@ pub(crate) struct OperatorKey { periodic_z: bool, active: Vec, coefficients: Vec, + links: Vec<(usize, usize, u64)>, smoother_sweeps: usize, coarsest_cells: usize, smoother: MgSmoother, @@ -44,6 +45,11 @@ impl OperatorKey { periodic_z: problem.periodic_z, active: problem.active.clone(), coefficients: Self::bits(problem).collect(), + links: problem + .links + .iter() + .map(|&(a, b, c)| (a, b, c.to_bits())) + .collect(), smoother_sweeps: params.smoother_sweeps, coarsest_cells: params.coarsest_cells, smoother: params.smoother, @@ -59,6 +65,12 @@ impl OperatorKey { && self.coarsest_cells == params.coarsest_cells && self.smoother == params.smoother && 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)) } } diff --git a/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/poisson/problem.rs b/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/poisson/problem.rs index 749301c..e342096 100644 --- a/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/poisson/problem.rs +++ b/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/poisson/problem.rs @@ -21,6 +21,12 @@ pub struct Problem { pub ab: Vec, pub extra_diag: Vec, pub rhs: Vec, + /// 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 { @@ -42,9 +48,31 @@ impl Problem { ab: vec![0.0; n], extra_diag: 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> { + 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] #[must_use] pub fn index(&self, k: usize, j: usize, i: usize) -> usize { @@ -86,13 +114,18 @@ impl Problem { #[inline] #[must_use] pub fn diagonal(&self, idx: usize) -> f64 { - self.ae[idx] + let stencil = self.ae[idx] + self.aw[idx] + self.an[idx] + self.as_[idx] + self.at[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. @@ -109,6 +142,7 @@ impl Problem { #[must_use] pub fn residual_l1(&self, p: &[f64]) -> f64 { let (nx, ny, nz) = (self.nx, self.ny, self.nz); + let links = self.link_lists(); let mut sum = 0.0; for k in 0..nz { for j in 0..ny { @@ -144,6 +178,9 @@ impl Problem { 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(); } } @@ -170,6 +207,11 @@ impl Problem { 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 symmetric = |a: f64, b: f64| (a - b).abs() <= 1e-12 * a.abs().max(b.abs()); for k in 0..nz { diff --git a/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/step/projection.rs b/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/step/projection.rs index 9ab868d..71d653c 100644 --- a/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/step/projection.rs +++ b/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/step/projection.rs @@ -80,13 +80,98 @@ impl Solver { } } } + self.merge_small_cells(&mut 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. pub(crate) fn poisson_problem(&self, field: &Field, dt: f64) -> Problem { let mut problem = self.poisson_operator(field.grid, dt); problem.rhs.copy_from_slice(&field.sp); + self.merge_rhs(&mut problem); problem } @@ -94,9 +179,10 @@ impl Solver { /// the 2D `(1, 1)` at `k = 0`), or `None` with an outlet. pub(crate) fn anchor_cell(&self, g: Grid) -> Option { (!self.params.boundaries.any_outlet()).then(|| { - self.mask - .as_ref() - .map_or(g.cell(0, 1, 1), super::super::wall::Mask::anchor) + self.mask.as_ref().map_or(g.cell(0, 1, 1), |m| { + let a = m.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); + // 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()]; if warm_start { for k in 0..nz { @@ -310,6 +408,13 @@ impl Solver { c0 + 1, 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); 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 j in 0..ny { for i in 0..nx { @@ -422,10 +527,17 @@ impl Solver { if cut { 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); if reference_flux > 0.0 { mass_imbalance / reference_flux diff --git a/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/wall.rs b/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/wall.rs index 723f1d4..3d5eff6 100644 --- a/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/wall.rs +++ b/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/wall.rs @@ -88,6 +88,10 @@ pub struct Mask { /// step (a dying cell empties through the apertures it had); `None` = /// the instantaneous kinds. pub(super) step_open: Option<(Vec, Vec, Vec, Vec)>, + /// 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, } /// The z lattice position of a query: the lower plane index, the upper @@ -497,9 +501,27 @@ impl Mask { cut: None, step_apertures: 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 { + 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). #[inline] #[must_use] diff --git a/crates/specialized/rtx-cfd/tests/embedded3_sphere/mod.rs b/crates/specialized/rtx-cfd/tests/embedded3_sphere/mod.rs index 72177e4..433d593 100644 --- a/crates/specialized/rtx-cfd/tests/embedded3_sphere/mod.rs +++ b/crates/specialized/rtx-cfd/tests/embedded3_sphere/mod.rs @@ -197,11 +197,14 @@ pub fn measure(n: usize, scheme: WallScheme, c: (f64, f64, f64)) -> Measurement let body = solver.body().expect("body"); let t = solver.time(); // 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 at_vol = 1.0; let mut sum_flux = 0.0; let (wall_fluxes, _) = mask.wall_flux_table(body, t); + let mut cell_flux = vec![0.0; g.cells()]; for k in 0..n { for j 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 + wall_fluxes[idx]; - sum_flux += flux.abs(); - if (flux / (h * h * h)).abs() > max_div { - max_div = (flux / (h * h * h)).abs(); - at_vol = mask.vol(idx); - } + cell_flux[mask.master(idx).unwrap_or(idx)] += flux; } } } } + for (idx, &flux) in cell_flux.iter().enumerate() { + sum_flux += flux.abs(); + if (flux / (h * h * h)).abs() > max_div { + max_div = (flux / (h * h * h)).abs(); + at_vol = mask.vol(idx); + } + } 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}", last.final_residual