rtx-cfd: multigrid-PCG projection — 30x faster, same answers — and the CFD1 refinement study
Performance Benchmarks / Run Benchmarks (push) Canceled after 0s
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Build CPU-Only (Explicit) (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
Documentation / Build API Documentation (push) Canceled after 0s
Documentation / Build User Guide (push) Canceled after 0s
CI / Format Check (push) Canceled after 0s
CI / Clippy Check (push) Canceled after 0s
CI / Build (macos-latest) (push) Canceled after 0s
CI / Build (ubuntu-latest) (push) Canceled after 0s
CI / Test (macos-latest) (push) Canceled after 0s
Performance Benchmarks / Run Benchmarks (push) Canceled after 0s
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Build CPU-Only (Explicit) (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
Documentation / Build API Documentation (push) Canceled after 0s
Documentation / Build User Guide (push) Canceled after 0s
CI / Format Check (push) Canceled after 0s
CI / Clippy Check (push) Canceled after 0s
CI / Build (macos-latest) (push) Canceled after 0s
CI / Build (ubuntu-latest) (push) Canceled after 0s
CI / Test (macos-latest) (push) Canceled after 0s
Falsifier 4 of the Turek–Hron geometry decision fired (the SOR projection
cost 0.09 s/step at 250x41 and an hour per run at 5 mm); this answers it.
solvers::incompressible::poisson: PoissonProblem (cell-centred five-point
SPD operator as per-cell face coefficients + Dirichlet diagonal extra +
active mask) and solve_multigrid_pcg — conjugate gradient preconditioned
by one V-cycle of geometric multigrid: aggregation by 2 per direction (odd
sizes absorbed, coarse cell active iff any child is), the Galerkin coarse
operator for piecewise-constant prolongation / summation restriction,
symmetric Gauss–Seidel smoothing, coarse correction scaled by 2 (Braess's
under-correction of unsmoothed aggregation; scalar, so the preconditioner
stays symmetric and positive on range(A)), L1 TRUE-residual stop with a
stagnation guard. Singular systems are handled per connected component of
the active cells (mean projection and level per pure-Neumann component;
the anchor's component to p[anchor] = 0). PoissonSolverKind::{Sor,
Multigrid} on PisoParameters / EmbeddedParameters; Sor is the default and
its code is byte-for-byte untouched; an unconverged multigrid solve falls
back to the SOR sweeps for that projection.
Verified (poisson/tests.rs, tests/poisson_equivalence.rs):
- PCG iterations to cut the residual 1e-8 on the closed Neumann box at
32^2..256^2: 4, 4, 4, 4; ragged masked domains 8/8/8;
- manufactured recoveries to ~1e-14; Galerkin identity A_c v = R A P v to
7e-15 on every level (masked, outlet column, non-uniform conductances);
V-cycle symmetric to 1e-14; NaN-poisoned inactive cells untouched;
- two Neumann components with opposite imbalances, and a Dirichlet
component beside an imbalanced Neumann one (review scenarios): converge,
each component right up to its own constant;
- speed vs plain SOR at the same stop: 22.7x (128^2), 41x (256^2);
- same answers as SOR: PISO MMS 4.6e-8 relative, Taylor–Green divergence
1.4e-9 every step, embedded-circle MMS 7e-8, no-body bit-identity with MG
on both solvers, channel+outlet+circle 1.4e-10; CFD1 loads identical to
four digits at 0.003 s/step vs 0.094 (30x).
CFD1 refinement study (tests/turek_hron_cfd.rs, three grids, 257 s):
h = 10 / 6.6 / 5 mm -> control-volume drag 15.6156 / 15.2829 / 15.0988 vs
14.2929 (+9.25 / +6.93 / +5.64%), apparent order 0.71, Richardson
extrapolate 14.04; surface route and lift not monotone (flag 2/3/4 cells
thick) — the test asserts the measured band at the finest grid.
Built with a 4-agent workflow (core, integration, refinement study,
adversarial review); the review found no defects and four risks, three
fixed here (per-component projection, one symmetric smoother-sweep
parameter, acting on `converged` with an SOR fallback) and one recorded
(isotropic aggregation loses grid-independence on anisotropic cells).
rtx-cfd 301 -> 318 green.
Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
c25f15b3c4
commit
327da7ff47
@@ -38,6 +38,7 @@
|
||||
|
||||
use super::ale::{AleBoundaries, SideBoundary};
|
||||
use super::embedded_body::{EmbeddedBody, EmbeddedMask, FaceKind};
|
||||
use super::poisson::{MultigridParameters, PoissonProblem, PoissonSolverKind, solve_multigrid_pcg};
|
||||
use super::{FlowField, SolverResult};
|
||||
use crate::{CfdConfig, CfdError, CfdResult};
|
||||
|
||||
@@ -55,6 +56,10 @@ pub struct EmbeddedParameters {
|
||||
/// Boundary type per domain side (all prescribed velocity by default).
|
||||
/// The struct is the ALE solver's; the semantics are identical.
|
||||
pub boundaries: AleBoundaries,
|
||||
/// Inner solver of the pressure-correction system (default
|
||||
/// [`PoissonSolverKind::Sor`]). Both solve the same system to the same
|
||||
/// true-residual stop; multigrid's cost is mesh-independent.
|
||||
pub poisson_solver: PoissonSolverKind,
|
||||
}
|
||||
|
||||
impl Default for EmbeddedParameters {
|
||||
@@ -63,6 +68,7 @@ impl Default for EmbeddedParameters {
|
||||
corrector_steps: 2,
|
||||
tolerance: 1e-6,
|
||||
boundaries: AleBoundaries::default(),
|
||||
poisson_solver: PoissonSolverKind::Sor,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -453,6 +459,66 @@ impl EmbeddedPisoSolver {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The pressure-correction system of one projection as a
|
||||
/// [`PoissonProblem`]: active = fluid cells, coefficient `dt A / delta`
|
||||
/// across every fluid interior face and zero across every prescribed
|
||||
/// one (domain Velocity / SlipWall sides, non-fluid interior faces),
|
||||
/// the outlet's Dirichlet `p' = 0` half a cell away as a diagonal-only
|
||||
/// `extra_diag`, right-hand side the mass imbalance `sp`. Arm for arm
|
||||
/// the coefficients the SOR loop of [`Self::project`] forms in place.
|
||||
fn poisson_problem(&self, field: &FlowField, dt: f64) -> PoissonProblem {
|
||||
let (nx, ny, dx, dy) = field.grid_info();
|
||||
let b = self.parameters.boundaries;
|
||||
let outlet = SideBoundary::PressureOutlet;
|
||||
let ae_interior = dt * dy / dx;
|
||||
let an_interior = dt * dx / dy;
|
||||
let ae_outlet = dt * dy / (0.5 * dx);
|
||||
let an_outlet = dt * dx / (0.5 * dy);
|
||||
|
||||
let mut problem = PoissonProblem::new(nx, ny);
|
||||
for j in 0..ny {
|
||||
for i in 0..nx {
|
||||
let idx = j * nx + i;
|
||||
if !self.cell_is_fluid(j, i) {
|
||||
problem.active[idx] = false;
|
||||
continue;
|
||||
}
|
||||
let mut extra = 0.0;
|
||||
if i + 1 == nx {
|
||||
if b.right == outlet {
|
||||
extra += ae_outlet;
|
||||
}
|
||||
} else if self.u_is_fluid(j, i + 1) {
|
||||
problem.ae[idx] = ae_interior;
|
||||
}
|
||||
if i == 0 {
|
||||
if b.left == outlet {
|
||||
extra += ae_outlet;
|
||||
}
|
||||
} else if self.u_is_fluid(j, i) {
|
||||
problem.aw[idx] = ae_interior;
|
||||
}
|
||||
if j + 1 == ny {
|
||||
if b.top == outlet {
|
||||
extra += an_outlet;
|
||||
}
|
||||
} else if self.v_is_fluid(j + 1, i) {
|
||||
problem.an[idx] = an_interior;
|
||||
}
|
||||
if j == 0 {
|
||||
if b.bottom == outlet {
|
||||
extra += an_outlet;
|
||||
}
|
||||
} else if self.v_is_fluid(j, i) {
|
||||
problem.as_[idx] = an_interior;
|
||||
}
|
||||
problem.extra_diag[idx] = extra;
|
||||
problem.rhs[idx] = field.sp[(j, i)];
|
||||
}
|
||||
}
|
||||
problem
|
||||
}
|
||||
|
||||
/// One projection on the fluid cells: the fixed-grid PISO's, with a
|
||||
/// zero coefficient across every prescribed face (domain Velocity /
|
||||
/// SlipWall sides and every non-fluid interior face), a Dirichlet `p' =
|
||||
@@ -494,86 +560,116 @@ impl EmbeddedPisoSolver {
|
||||
let reference_flux = rho * self.config.reference_velocity * self.config.reference_length;
|
||||
let inner_stop =
|
||||
(1e-2 * source_scale).max(0.1 * self.parameters.tolerance * reference_flux) + 1e-14;
|
||||
let omega = 2.0 / (1.0 + (std::f64::consts::PI / nx.max(ny) as f64).sin());
|
||||
for _sweep in 0..2000 {
|
||||
let mut residual = 0.0;
|
||||
for j in 0..ny {
|
||||
for i in 0..nx {
|
||||
if !self.cell_is_fluid(j, i) {
|
||||
continue;
|
||||
let mut multigrid_converged = false;
|
||||
if self.parameters.poisson_solver == PoissonSolverKind::Multigrid {
|
||||
// The same system the SOR loop below sweeps, handed to the
|
||||
// multigrid-preconditioned CG solver: anchored on the first
|
||||
// fluid cell when there is no outlet (the SOR loop pins it to
|
||||
// zero), level-free otherwise. Non-fluid cells and isolated
|
||||
// fluid cells are never written and keep `p' = 0`.
|
||||
let problem = self.poisson_problem(field, dt);
|
||||
let mut p_prime = vec![0.0; nx * ny];
|
||||
let anchor_cell = (!any_outlet).then_some(anchor.0 * nx + anchor.1);
|
||||
let solution = solve_multigrid_pcg(
|
||||
&problem,
|
||||
&mut p_prime,
|
||||
&MultigridParameters::default(),
|
||||
inner_stop,
|
||||
anchor_cell,
|
||||
);
|
||||
// Unconverged: fall back to the SOR sweeps for this projection
|
||||
// rather than apply a correction that did not reach the stop.
|
||||
multigrid_converged = solution.converged;
|
||||
if multigrid_converged {
|
||||
for j in 0..ny {
|
||||
for i in 0..nx {
|
||||
field.p_prime[(j, i)] = p_prime[j * nx + i];
|
||||
}
|
||||
if !any_outlet && (j, i) == anchor {
|
||||
field.p_prime[(j, i)] = 0.0;
|
||||
continue;
|
||||
}
|
||||
|
||||
// A coefficient is zero exactly when the face is
|
||||
// prescribed: a domain side with velocity data, or a
|
||||
// non-fluid interior face.
|
||||
let ae = if i + 1 == nx {
|
||||
if b.right == outlet { ae_outlet } else { 0.0 }
|
||||
} else if self.u_is_fluid(j, i + 1) {
|
||||
ae_interior
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let aw = if i == 0 {
|
||||
if b.left == outlet { ae_outlet } else { 0.0 }
|
||||
} else if self.u_is_fluid(j, i) {
|
||||
ae_interior
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let an = if j + 1 == ny {
|
||||
if b.top == outlet { an_outlet } else { 0.0 }
|
||||
} else if self.v_is_fluid(j + 1, i) {
|
||||
an_interior
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let as_ = if j == 0 {
|
||||
if b.bottom == outlet { an_outlet } else { 0.0 }
|
||||
} else if self.v_is_fluid(j, i) {
|
||||
an_interior
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let ap = ae + aw + an + as_;
|
||||
if ap == 0.0 {
|
||||
// An isolated fluid cell enclosed by prescribed
|
||||
// faces has no equation; leave p' = 0 there.
|
||||
continue;
|
||||
}
|
||||
|
||||
let east = if i + 1 < nx {
|
||||
ae * field.p_prime[(j, i + 1)]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let west = if i > 0 {
|
||||
aw * field.p_prime[(j, i - 1)]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let north = if j + 1 < ny {
|
||||
an * field.p_prime[(j + 1, i)]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let south = if j > 0 {
|
||||
as_ * field.p_prime[(j - 1, i)]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let rhs = field.sp[(j, i)] + east + west + north + south;
|
||||
let p_old = field.p_prime[(j, i)];
|
||||
residual += (rhs - ap * p_old).abs();
|
||||
field.p_prime[(j, i)] = (1.0 - omega) * p_old + omega * rhs / ap;
|
||||
}
|
||||
}
|
||||
if residual < inner_stop {
|
||||
break;
|
||||
}
|
||||
if !multigrid_converged {
|
||||
let omega = 2.0 / (1.0 + (std::f64::consts::PI / nx.max(ny) as f64).sin());
|
||||
for _sweep in 0..2000 {
|
||||
let mut residual = 0.0;
|
||||
for j in 0..ny {
|
||||
for i in 0..nx {
|
||||
if !self.cell_is_fluid(j, i) {
|
||||
continue;
|
||||
}
|
||||
if !any_outlet && (j, i) == anchor {
|
||||
field.p_prime[(j, i)] = 0.0;
|
||||
continue;
|
||||
}
|
||||
|
||||
// A coefficient is zero exactly when the face is
|
||||
// prescribed: a domain side with velocity data, or a
|
||||
// non-fluid interior face.
|
||||
let ae = if i + 1 == nx {
|
||||
if b.right == outlet { ae_outlet } else { 0.0 }
|
||||
} else if self.u_is_fluid(j, i + 1) {
|
||||
ae_interior
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let aw = if i == 0 {
|
||||
if b.left == outlet { ae_outlet } else { 0.0 }
|
||||
} else if self.u_is_fluid(j, i) {
|
||||
ae_interior
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let an = if j + 1 == ny {
|
||||
if b.top == outlet { an_outlet } else { 0.0 }
|
||||
} else if self.v_is_fluid(j + 1, i) {
|
||||
an_interior
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let as_ = if j == 0 {
|
||||
if b.bottom == outlet { an_outlet } else { 0.0 }
|
||||
} else if self.v_is_fluid(j, i) {
|
||||
an_interior
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let ap = ae + aw + an + as_;
|
||||
if ap == 0.0 {
|
||||
// An isolated fluid cell enclosed by prescribed
|
||||
// faces has no equation; leave p' = 0 there.
|
||||
continue;
|
||||
}
|
||||
|
||||
let east = if i + 1 < nx {
|
||||
ae * field.p_prime[(j, i + 1)]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let west = if i > 0 {
|
||||
aw * field.p_prime[(j, i - 1)]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let north = if j + 1 < ny {
|
||||
an * field.p_prime[(j + 1, i)]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let south = if j > 0 {
|
||||
as_ * field.p_prime[(j - 1, i)]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let rhs = field.sp[(j, i)] + east + west + north + south;
|
||||
let p_old = field.p_prime[(j, i)];
|
||||
residual += (rhs - ap * p_old).abs();
|
||||
field.p_prime[(j, i)] = (1.0 - omega) * p_old + omega * rhs / ap;
|
||||
}
|
||||
}
|
||||
if residual < inner_stop {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ pub mod piso;
|
||||
/// GPU-accelerated PISO algorithm implementation
|
||||
#[cfg(feature = "cuda")]
|
||||
pub mod piso_gpu;
|
||||
/// Five-point Poisson problems and the multigrid-preconditioned CG solver
|
||||
pub mod poisson;
|
||||
/// SIMPLE algorithm implementation
|
||||
pub mod simple;
|
||||
/// GPU-accelerated SIMPLE algorithm implementation
|
||||
@@ -43,6 +45,7 @@ pub use flow_field::FlowField;
|
||||
pub use piso::{PisoParameters, PisoResult, PisoSolver};
|
||||
#[cfg(feature = "cuda")]
|
||||
pub use piso_gpu::PisoGpuSolver;
|
||||
pub use poisson::{MultigridParameters, PoissonProblem, PoissonSolution, PoissonSolverKind};
|
||||
pub use simple::{ConvectionScheme, SimpleParameters, SimpleResult, SimpleSolver};
|
||||
#[cfg(feature = "cuda")]
|
||||
pub use simple_gpu::SimpleGpuSolver;
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
//! - Convective face fluxes fell back to the centre value at the sweep edges
|
||||
//! instead of using the prescribed boundary faces that exist there.
|
||||
|
||||
use super::poisson::{MultigridParameters, PoissonProblem, PoissonSolverKind, solve_multigrid_pcg};
|
||||
use super::{BoundaryConditions, FlowField, IncompressibleSolver, SolverResult};
|
||||
use crate::{CfdConfig, CfdResult};
|
||||
use async_trait::async_trait;
|
||||
@@ -54,6 +55,10 @@ pub struct PisoParameters {
|
||||
/// Convergence tolerance on the normalised mass imbalance after
|
||||
/// correction.
|
||||
pub tolerance: f64,
|
||||
/// Inner solver of the pressure-correction system (default
|
||||
/// [`PoissonSolverKind::Sor`]). Both solve the same system to the same
|
||||
/// true-residual stop; multigrid's cost is mesh-independent.
|
||||
pub poisson_solver: PoissonSolverKind,
|
||||
}
|
||||
|
||||
impl Default for PisoParameters {
|
||||
@@ -62,6 +67,7 @@ impl Default for PisoParameters {
|
||||
corrector_steps: 2,
|
||||
time_step: 0.001,
|
||||
tolerance: 1e-6,
|
||||
poisson_solver: PoissonSolverKind::Sor,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -368,51 +374,94 @@ impl PisoSolver {
|
||||
let reference_flux = rho * self.config.reference_velocity * self.config.reference_length;
|
||||
let inner_stop =
|
||||
(1e-2 * source_scale).max(0.1 * self.parameters.tolerance * reference_flux) + 1e-14;
|
||||
let omega = 2.0 / (1.0 + (std::f64::consts::PI / nx.max(ny) as f64).sin());
|
||||
for _sweep in 0..2000 {
|
||||
let mut residual = 0.0;
|
||||
let mut multigrid_converged = false;
|
||||
if self.parameters.poisson_solver == PoissonSolverKind::Multigrid {
|
||||
// The same five-point system the SOR loop below sweeps — the
|
||||
// same coefficients, right-hand side, anchor cell and stop —
|
||||
// handed to the multigrid-preconditioned CG solver. The SOR
|
||||
// loop pins `p'(1, 1) = 0` and solves the remaining equations;
|
||||
// on the compatible (closed-box) source that is the singular
|
||||
// system's solution shifted to `p'(1, 1) = 0`, which is what
|
||||
// `anchor` requests.
|
||||
let mut problem = PoissonProblem::new(nx, ny);
|
||||
for j in 0..ny {
|
||||
for i in 0..nx {
|
||||
if i == 1 && j == 1 {
|
||||
flow_field.p_prime[(j, i)] = 0.0;
|
||||
continue;
|
||||
}
|
||||
|
||||
let ae = if i + 1 == nx { 0.0 } else { ae_interior };
|
||||
let aw = if i == 0 { 0.0 } else { ae_interior };
|
||||
let an = if j + 1 == ny { 0.0 } else { an_interior };
|
||||
let as_ = if j == 0 { 0.0 } else { an_interior };
|
||||
let ap = ae + aw + an + as_;
|
||||
|
||||
let east = if i + 1 < nx {
|
||||
ae * flow_field.p_prime[(j, i + 1)]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let west = if i > 0 {
|
||||
aw * flow_field.p_prime[(j, i - 1)]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let north = if j + 1 < ny {
|
||||
an * flow_field.p_prime[(j + 1, i)]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let south = if j > 0 {
|
||||
as_ * flow_field.p_prime[(j - 1, i)]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let rhs = flow_field.sp[(j, i)] + east + west + north + south;
|
||||
let p_old = flow_field.p_prime[(j, i)];
|
||||
residual += (rhs - ap * p_old).abs();
|
||||
flow_field.p_prime[(j, i)] = (1.0 - omega) * p_old + omega * rhs / ap;
|
||||
let idx = j * nx + i;
|
||||
problem.ae[idx] = if i + 1 == nx { 0.0 } else { ae_interior };
|
||||
problem.aw[idx] = if i == 0 { 0.0 } else { ae_interior };
|
||||
problem.an[idx] = if j + 1 == ny { 0.0 } else { an_interior };
|
||||
problem.as_[idx] = if j == 0 { 0.0 } else { an_interior };
|
||||
problem.rhs[idx] = flow_field.sp[(j, i)];
|
||||
}
|
||||
}
|
||||
if residual < inner_stop {
|
||||
break;
|
||||
let mut p_prime = vec![0.0; nx * ny];
|
||||
let solution = solve_multigrid_pcg(
|
||||
&problem,
|
||||
&mut p_prime,
|
||||
&MultigridParameters::default(),
|
||||
inner_stop,
|
||||
Some(nx + 1),
|
||||
);
|
||||
// An unconverged multigrid solve (iteration cap, rounding floor,
|
||||
// inconsistent system) is not applied: the SOR sweeps below take
|
||||
// over for this projection, so the worst case is the old cost,
|
||||
// never a silently wrong correction.
|
||||
multigrid_converged = solution.converged;
|
||||
if multigrid_converged {
|
||||
for j in 0..ny {
|
||||
for i in 0..nx {
|
||||
flow_field.p_prime[(j, i)] = p_prime[j * nx + i];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !multigrid_converged {
|
||||
let omega = 2.0 / (1.0 + (std::f64::consts::PI / nx.max(ny) as f64).sin());
|
||||
for _sweep in 0..2000 {
|
||||
let mut residual = 0.0;
|
||||
for j in 0..ny {
|
||||
for i in 0..nx {
|
||||
if i == 1 && j == 1 {
|
||||
flow_field.p_prime[(j, i)] = 0.0;
|
||||
continue;
|
||||
}
|
||||
|
||||
let ae = if i + 1 == nx { 0.0 } else { ae_interior };
|
||||
let aw = if i == 0 { 0.0 } else { ae_interior };
|
||||
let an = if j + 1 == ny { 0.0 } else { an_interior };
|
||||
let as_ = if j == 0 { 0.0 } else { an_interior };
|
||||
let ap = ae + aw + an + as_;
|
||||
|
||||
let east = if i + 1 < nx {
|
||||
ae * flow_field.p_prime[(j, i + 1)]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let west = if i > 0 {
|
||||
aw * flow_field.p_prime[(j, i - 1)]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let north = if j + 1 < ny {
|
||||
an * flow_field.p_prime[(j + 1, i)]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let south = if j > 0 {
|
||||
as_ * flow_field.p_prime[(j - 1, i)]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let rhs = flow_field.sp[(j, i)] + east + west + north + south;
|
||||
let p_old = flow_field.p_prime[(j, i)];
|
||||
residual += (rhs - ap * p_old).abs();
|
||||
flow_field.p_prime[(j, i)] = (1.0 - omega) * p_old + omega * rhs / ap;
|
||||
}
|
||||
}
|
||||
if residual < inner_stop {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,865 @@
|
||||
//! Cell-centred five-point Poisson problems on an active-cell mask, solved
|
||||
//! by conjugate gradient preconditioned with one V-cycle of geometric
|
||||
//! (aggregation) multigrid.
|
||||
//!
|
||||
//! The pressure-correction systems of the fixed-grid PISO (`piso.rs`) and
|
||||
//! the embedded-body PISO (`embedded.rs`) are both instances of
|
||||
//! [`PoissonProblem`]: a symmetric positive (semi-)definite M-matrix whose
|
||||
//! off-diagonal coefficients are face conductances (`dt dy/dx`, `dt dx/dy`),
|
||||
//! zero across every prescribed face, with an optional diagonal-only
|
||||
//! Dirichlet contribution (`extra_diag`) on cells next to a pressure outlet.
|
||||
//! Both solvers used point SOR with the true-residual stop; SOR contracts
|
||||
//! the smooth modes by `1 - O(h)` per sweep, so the sweep count grows with
|
||||
//! the mesh and the 256² projection of a long march dominates the step.
|
||||
//! [`solve_multigrid_pcg`] is the mesh-independent replacement.
|
||||
//!
|
||||
//! Design:
|
||||
//! - The hierarchy is a `Vec` of levels, each holding its own
|
||||
//! [`PoissonProblem`]. Coarsening is by 2 in each direction (odd sizes:
|
||||
//! the last coarse cell aggregates what remains), a coarse cell is active
|
||||
//! iff any child is. Prolongation is injection of the coarse value into
|
||||
//! every child, restriction is the sum over children, and the coarse
|
||||
//! operator is the Galerkin product `R A P` — which for constant
|
||||
//! prolongation is exactly "sum the fine face coefficients across each
|
||||
//! coarse face, sum the `extra_diag` of the children"; internal fine faces
|
||||
//! cancel. The coarse problems therefore have the same structure as the
|
||||
//! fine one, and the symmetry of the coarse operator is inherited.
|
||||
//! - The prolongated coarse correction is scaled by [`COARSE_CORRECTION`]
|
||||
//! `= 2`: constant prolongation under-corrects the smooth modes by exactly
|
||||
//! that factor (see the constant's documentation), and without the scaling
|
||||
//! the CG iteration count grows by ~10 per level.
|
||||
//! - Singular blocks are handled per connected component of the active
|
||||
//! cells: a component without Dirichlet data has its right-hand-side mean
|
||||
//! projected out and its level fixed on exit (anchor or mean zero) on its
|
||||
//! own, so enclosed fluid pockets or a body that splits the domain cannot
|
||||
//! make the iteration diverge on a globally-compatible source.
|
||||
//! - Limit: the coarsening is isotropic point-aggregation, so the
|
||||
//! grid-independence holds for cells of aspect ratio near one. Measured
|
||||
//! (review): aspect 2 → 6 iterations at every size, aspect 4 → 15–22,
|
||||
//! aspect 10 → 37–91 (32² → 256²), aspect 100 → 405 at 256². Strongly
|
||||
//! anisotropic grids need semi-coarsening or line smoothing; the flow
|
||||
//! solvers here use square cells.
|
||||
//! - The smoother is symmetric Gauss–Seidel (forward then backward sweep),
|
||||
//! so each level's smoother is self-adjoint in the `A` inner product and
|
||||
//! the V-cycle with `R = Pᵀ` is a symmetric preconditioner, which keeps
|
||||
//! CG valid. The coarsest level is solved by 50 symmetric GS sweeps.
|
||||
//! - On a pure-Neumann problem the mean over active cells is projected out
|
||||
//! of the right-hand side and of every preconditioned residual, so CG runs
|
||||
//! on the range of `A` and the level of `p` is fixed on exit (anchor cell
|
||||
//! or mean zero).
|
||||
//! - An active cell whose row is empty (`ap == 0`: an isolated cell with no
|
||||
//! correctable face and no Dirichlet contribution) has no equation: the
|
||||
//! solver never reads or writes it and it is excluded from the residual
|
||||
//! — the same rule the embedded SOR applied.
|
||||
//!
|
||||
//! The convergence stop is the L1 norm of the TRUE residual `b - A p` over
|
||||
//! the active cells: the CG recurrence residual is checked every iteration
|
||||
//! and, when it passes the tolerance, the true residual is recomputed and
|
||||
//! must pass too before the solve reports convergence.
|
||||
|
||||
/// Cell-centred five-point symmetric positive (semi-)definite problem
|
||||
///
|
||||
/// ```text
|
||||
/// ap_i p_i - ae_i p_E - aw_i p_W - an_i p_N - as_i p_S = rhs_i on active cells,
|
||||
/// ```
|
||||
///
|
||||
/// with `ap_i = ae_i + aw_i + an_i + as_i + extra_diag_i`. Row-major index
|
||||
/// `j * nx + i`. The coefficient to a neighbour is 0 when that face is
|
||||
/// prescribed or the neighbour is inactive. Symmetry is required:
|
||||
/// `ae[j*nx+i] == aw[j*nx+i+1]`, `an[j*nx+i] == as_[(j+1)*nx+i]`.
|
||||
/// `extra_diag` holds Dirichlet (outlet) contributions that have no
|
||||
/// neighbour.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PoissonProblem {
|
||||
/// Cells in x.
|
||||
pub nx: usize,
|
||||
/// Cells in y.
|
||||
pub ny: usize,
|
||||
/// Active (fluid) cells; inactive cells carry no equation and are never
|
||||
/// read or written by the solver.
|
||||
pub active: Vec<bool>,
|
||||
/// Coefficient to the east neighbour `(j, i+1)`.
|
||||
pub ae: Vec<f64>,
|
||||
/// Coefficient to the west neighbour `(j, i-1)`.
|
||||
pub aw: Vec<f64>,
|
||||
/// Coefficient to the north neighbour `(j+1, i)`.
|
||||
pub an: Vec<f64>,
|
||||
/// Coefficient to the south neighbour `(j-1, i)`.
|
||||
pub as_: Vec<f64>,
|
||||
/// Diagonal-only (Dirichlet) contribution.
|
||||
pub extra_diag: Vec<f64>,
|
||||
/// Right-hand side.
|
||||
pub rhs: Vec<f64>,
|
||||
}
|
||||
|
||||
impl PoissonProblem {
|
||||
/// All cells active, every coefficient and the right-hand side zero.
|
||||
#[must_use]
|
||||
pub fn new(nx: usize, ny: usize) -> Self {
|
||||
let n = nx * ny;
|
||||
Self {
|
||||
nx,
|
||||
ny,
|
||||
active: vec![true; n],
|
||||
ae: vec![0.0; n],
|
||||
aw: vec![0.0; n],
|
||||
an: vec![0.0; n],
|
||||
as_: vec![0.0; n],
|
||||
extra_diag: vec![0.0; n],
|
||||
rhs: vec![0.0; n],
|
||||
}
|
||||
}
|
||||
|
||||
/// Row-major index of cell `(j, i)`.
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn index(&self, j: usize, i: usize) -> usize {
|
||||
j * self.nx + i
|
||||
}
|
||||
|
||||
/// Diagonal coefficient `ap` of cell `idx`.
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn diagonal(&self, idx: usize) -> f64 {
|
||||
self.ae[idx] + self.aw[idx] + self.an[idx] + self.as_[idx] + self.extra_diag[idx]
|
||||
}
|
||||
|
||||
/// Pure Neumann: no active cell has a Dirichlet contribution.
|
||||
#[must_use]
|
||||
pub fn is_singular(&self) -> bool {
|
||||
!self
|
||||
.active
|
||||
.iter()
|
||||
.zip(&self.extra_diag)
|
||||
.any(|(&a, &d)| a && d > 0.0)
|
||||
}
|
||||
|
||||
/// Sum over the active cells that carry an equation (`ap > 0`) of
|
||||
/// `|rhs - (ap p - Σ a_nb p_nb)|`. Neighbour values are read only from
|
||||
/// active in-range neighbours.
|
||||
#[must_use]
|
||||
pub fn residual_l1(&self, p: &[f64]) -> f64 {
|
||||
let (nx, ny) = (self.nx, self.ny);
|
||||
let mut sum = 0.0;
|
||||
for j in 0..ny {
|
||||
for i in 0..nx {
|
||||
let idx = j * nx + i;
|
||||
if !self.active[idx] {
|
||||
continue;
|
||||
}
|
||||
let ap = self.diagonal(idx);
|
||||
if ap <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let mut nb = 0.0;
|
||||
if i + 1 < nx && self.active[idx + 1] {
|
||||
nb += self.ae[idx] * p[idx + 1];
|
||||
}
|
||||
if i > 0 && self.active[idx - 1] {
|
||||
nb += self.aw[idx] * p[idx - 1];
|
||||
}
|
||||
if j + 1 < ny && self.active[idx + nx] {
|
||||
nb += self.an[idx] * p[idx + nx];
|
||||
}
|
||||
if j > 0 && self.active[idx - nx] {
|
||||
nb += self.as_[idx] * p[idx - nx];
|
||||
}
|
||||
sum += (self.rhs[idx] - (ap * p[idx] - nb)).abs();
|
||||
}
|
||||
}
|
||||
sum
|
||||
}
|
||||
|
||||
/// Checks array lengths, non-negativity of every coefficient, zero
|
||||
/// coefficients across domain edges and towards inactive cells, and
|
||||
/// symmetry to `1e-12` relative.
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
let n = self.nx * self.ny;
|
||||
for (name, len) in [
|
||||
("active", self.active.len()),
|
||||
("ae", self.ae.len()),
|
||||
("aw", self.aw.len()),
|
||||
("an", self.an.len()),
|
||||
("as_", self.as_.len()),
|
||||
("extra_diag", self.extra_diag.len()),
|
||||
("rhs", self.rhs.len()),
|
||||
] {
|
||||
if len != n {
|
||||
return Err(format!("{name}: length {len}, expected nx*ny = {n}"));
|
||||
}
|
||||
}
|
||||
let (nx, ny) = (self.nx, self.ny);
|
||||
for j in 0..ny {
|
||||
for i in 0..nx {
|
||||
let idx = j * nx + i;
|
||||
for (name, v) in [
|
||||
("ae", self.ae[idx]),
|
||||
("aw", self.aw[idx]),
|
||||
("an", self.an[idx]),
|
||||
("as_", self.as_[idx]),
|
||||
("extra_diag", self.extra_diag[idx]),
|
||||
] {
|
||||
if v.is_nan() || v < 0.0 {
|
||||
return Err(format!("{name}[{idx}] = {v} is negative or NaN"));
|
||||
}
|
||||
}
|
||||
let active = self.active[idx];
|
||||
let check = |name: &str, coef: f64, nb_ok: bool| -> Result<(), String> {
|
||||
if !nb_ok && coef != 0.0 {
|
||||
return Err(format!(
|
||||
"{name}[{idx}] = {coef} across a domain edge or towards an inactive \
|
||||
cell (cell active = {active})"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
check(
|
||||
"ae",
|
||||
self.ae[idx],
|
||||
active && i + 1 < nx && self.active[idx + 1],
|
||||
)?;
|
||||
check("aw", self.aw[idx], active && i > 0 && self.active[idx - 1])?;
|
||||
check(
|
||||
"an",
|
||||
self.an[idx],
|
||||
active && j + 1 < ny && self.active[idx + nx],
|
||||
)?;
|
||||
check(
|
||||
"as_",
|
||||
self.as_[idx],
|
||||
active && j > 0 && self.active[idx - nx],
|
||||
)?;
|
||||
let symmetric = |a: f64, b: f64| (a - b).abs() <= 1e-12 * a.abs().max(b.abs());
|
||||
if i + 1 < nx && !symmetric(self.ae[idx], self.aw[idx + 1]) {
|
||||
return Err(format!(
|
||||
"asymmetric x face: ae[{idx}] = {} vs aw[{}] = {}",
|
||||
self.ae[idx],
|
||||
idx + 1,
|
||||
self.aw[idx + 1]
|
||||
));
|
||||
}
|
||||
if j + 1 < ny && !symmetric(self.an[idx], self.as_[idx + nx]) {
|
||||
return Err(format!(
|
||||
"asymmetric y face: an[{idx}] = {} vs as_[{}] = {}",
|
||||
self.an[idx],
|
||||
idx + nx,
|
||||
self.as_[idx + nx]
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Multigrid preconditioner parameters.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MultigridParameters {
|
||||
/// Symmetric Gauss–Seidel sweeps before AND after the coarse correction
|
||||
/// (default 2; a value of 0 is treated as 1). One count for both on
|
||||
/// purpose: unequal pre/post counts make the V-cycle non-symmetric and
|
||||
/// conjugate gradient invalid — measured symmetry defect 0.9 for (1, 0)
|
||||
/// against 1e-14 for (1, 1) — so the API does not let it happen.
|
||||
pub smoother_sweeps: usize,
|
||||
/// Stop coarsening when the active cells are at most this many; that
|
||||
/// level is solved by 50 symmetric GS sweeps (default 32).
|
||||
pub coarsest_cells: usize,
|
||||
/// CG iteration cap (default 500).
|
||||
pub max_iterations: usize,
|
||||
}
|
||||
|
||||
impl Default for MultigridParameters {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
smoother_sweeps: 2,
|
||||
coarsest_cells: 32,
|
||||
max_iterations: 500,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of a [`solve_multigrid_pcg`] call. `converged == false` means
|
||||
/// the returned `p` did not reach the tolerance (iteration cap, rounding
|
||||
/// floor, or an inconsistent system) — callers must act on it, which is
|
||||
/// why the type is `#[must_use]`.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[must_use]
|
||||
pub struct PoissonSolution {
|
||||
/// CG iterations performed.
|
||||
pub iterations: usize,
|
||||
/// L1 true residual `Σ |b - A p|` over the active cells at exit.
|
||||
pub residual: f64,
|
||||
/// `residual < tolerance` at exit.
|
||||
pub converged: bool,
|
||||
}
|
||||
|
||||
/// Which inner solver a projection uses for its pressure-correction system.
|
||||
///
|
||||
/// `Sor` is the historical point successive over-relaxation at the optimal
|
||||
/// Poisson factor; `Multigrid` is [`solve_multigrid_pcg`] on the same
|
||||
/// coefficients, the same right-hand side and the same true-residual stop.
|
||||
/// The two land on the same discrete pressure correction (to the inner
|
||||
/// tolerance); they differ only in cost, which is mesh-independent for
|
||||
/// multigrid and grows with the mesh for SOR.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum PoissonSolverKind {
|
||||
/// Point SOR, `omega = 2 / (1 + sin(pi / max(nx, ny)))`, 2000-sweep cap.
|
||||
#[default]
|
||||
Sor,
|
||||
/// Conjugate gradient preconditioned by one geometric multigrid V-cycle.
|
||||
Multigrid,
|
||||
}
|
||||
|
||||
/// Symmetric GS sweeps on the coarsest level.
|
||||
const COARSEST_SWEEPS: usize = 50;
|
||||
/// Over-correction factor applied to the prolongated coarse correction.
|
||||
///
|
||||
/// With piecewise-constant prolongation and summation restriction the
|
||||
/// Galerkin coarse operator of an aggregate-by-2 hierarchy is exactly twice
|
||||
/// the geometric `2h` operator (a coarse face collects `2^(d-1)` fine faces
|
||||
/// whose conductance is `h^(d-2)` each, against `(2h)^(d-2)` for the `2h`
|
||||
/// cell), while the restricted residual of a smooth error is the full
|
||||
/// `(2h)^d f`: the plain Galerkin correction is therefore half the geometric
|
||||
/// one for the smooth modes, in any dimension and for any uniform face
|
||||
/// conductance — the well-known under-correction of unsmoothed aggregation
|
||||
/// (Braess 1995). Scaling the correction by 2 restores the geometric
|
||||
/// correction for the smooth modes. The preconditioner stays symmetric
|
||||
/// (a scalar factor), and stays positive definite: the coarse correction
|
||||
/// operator `ω P B_c R A` is `A`-self-adjoint and positive, so the V-cycle
|
||||
/// error propagator `S (I − ω P B_c R A) S` has `A`-spectrum below 1 and
|
||||
/// `M⁻¹A = I − E` is positive on the range of `A`. Measured: the PCG
|
||||
/// iteration count for a 1e-8 residual reduction on the Neumann box goes
|
||||
/// from `[14, 20, 29, 40]` (32²…256², growing a level at a time) to
|
||||
/// `[4, 4, 4, 4]`, and on the ragged mask from `[20, 28, 39]` to `[8, 8, 8]`.
|
||||
const COARSE_CORRECTION: f64 = 2.0;
|
||||
/// Hard cap on the number of levels (a 1×1 coarsest is reached long before).
|
||||
const MAX_LEVELS: usize = 64;
|
||||
|
||||
/// One level of the hierarchy: the problem, its diagonal, the list of
|
||||
/// active cells that carry an equation (row-major), the parent map into the
|
||||
/// next coarser level, and the V-cycle work vectors.
|
||||
struct Level {
|
||||
problem: PoissonProblem,
|
||||
/// `problem.active && ap > 0`: cells with an equation.
|
||||
active: Vec<bool>,
|
||||
ap: Vec<f64>,
|
||||
/// Row-major indices of the active cells.
|
||||
cells: Vec<usize>,
|
||||
/// Fine index → coarse index (empty on the coarsest level).
|
||||
coarse_of: Vec<usize>,
|
||||
}
|
||||
|
||||
/// V-cycle work vectors of one level.
|
||||
struct Work {
|
||||
/// Right-hand side of the residual equation on this level.
|
||||
b: Vec<f64>,
|
||||
/// Correction on this level.
|
||||
x: Vec<f64>,
|
||||
/// Residual.
|
||||
r: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Work {
|
||||
fn new(n: usize) -> Self {
|
||||
Self {
|
||||
b: vec![0.0; n],
|
||||
x: vec![0.0; n],
|
||||
r: vec![0.0; n],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Level {
|
||||
fn new(mut problem: PoissonProblem) -> Self {
|
||||
let (nx, ny) = (problem.nx, problem.ny);
|
||||
let n = nx * ny;
|
||||
let ap: Vec<f64> = (0..n).map(|idx| problem.diagonal(idx)).collect();
|
||||
let active: Vec<bool> = (0..n)
|
||||
.map(|idx| problem.active[idx] && ap[idx] > 0.0)
|
||||
.collect();
|
||||
// Sanitise: a coefficient towards an inactive or out-of-range
|
||||
// neighbour is never used, so zero it — the stencil can then branch
|
||||
// on the coefficient alone and never touches an inactive value.
|
||||
for j in 0..ny {
|
||||
for i in 0..nx {
|
||||
let idx = j * nx + i;
|
||||
if !active[idx] {
|
||||
problem.ae[idx] = 0.0;
|
||||
problem.aw[idx] = 0.0;
|
||||
problem.an[idx] = 0.0;
|
||||
problem.as_[idx] = 0.0;
|
||||
continue;
|
||||
}
|
||||
if !(i + 1 < nx && active[idx + 1]) {
|
||||
problem.ae[idx] = 0.0;
|
||||
}
|
||||
if !(i > 0 && active[idx - 1]) {
|
||||
problem.aw[idx] = 0.0;
|
||||
}
|
||||
if !(j + 1 < ny && active[idx + nx]) {
|
||||
problem.an[idx] = 0.0;
|
||||
}
|
||||
if !(j > 0 && active[idx - nx]) {
|
||||
problem.as_[idx] = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
let cells: Vec<usize> = (0..n).filter(|&idx| active[idx]).collect();
|
||||
Self {
|
||||
problem,
|
||||
active,
|
||||
ap,
|
||||
cells,
|
||||
coarse_of: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `Σ a_nb x_nb` for cell `idx`; only active in-range neighbours are
|
||||
/// read (their coefficients are the only non-zero ones after
|
||||
/// sanitising).
|
||||
#[inline]
|
||||
fn neighbour_sum(&self, x: &[f64], idx: usize) -> f64 {
|
||||
let nx = self.problem.nx;
|
||||
let mut s = 0.0;
|
||||
let ae = self.problem.ae[idx];
|
||||
if ae != 0.0 {
|
||||
s += ae * x[idx + 1];
|
||||
}
|
||||
let aw = self.problem.aw[idx];
|
||||
if aw != 0.0 {
|
||||
s += aw * x[idx - 1];
|
||||
}
|
||||
let an = self.problem.an[idx];
|
||||
if an != 0.0 {
|
||||
s += an * x[idx + nx];
|
||||
}
|
||||
let as_ = self.problem.as_[idx];
|
||||
if as_ != 0.0 {
|
||||
s += as_ * x[idx - nx];
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// `y = A x` on the active cells.
|
||||
fn apply(&self, x: &[f64], y: &mut [f64]) {
|
||||
for &idx in &self.cells {
|
||||
y[idx] = self.ap[idx] * x[idx] - self.neighbour_sum(x, idx);
|
||||
}
|
||||
}
|
||||
|
||||
/// `r = b - A x` on the active cells; returns its L1 norm.
|
||||
fn residual(&self, b: &[f64], x: &[f64], r: &mut [f64]) -> f64 {
|
||||
let mut l1 = 0.0;
|
||||
for &idx in &self.cells {
|
||||
let v = b[idx] - (self.ap[idx] * x[idx] - self.neighbour_sum(x, idx));
|
||||
r[idx] = v;
|
||||
l1 += v.abs();
|
||||
}
|
||||
l1
|
||||
}
|
||||
|
||||
/// One symmetric Gauss–Seidel sweep (forward then backward) on `A x = b`.
|
||||
fn symmetric_gs(&self, b: &[f64], x: &mut [f64]) {
|
||||
for &idx in &self.cells {
|
||||
x[idx] = (b[idx] + self.neighbour_sum(x, idx)) / self.ap[idx];
|
||||
}
|
||||
for &idx in self.cells.iter().rev() {
|
||||
x[idx] = (b[idx] + self.neighbour_sum(x, idx)) / self.ap[idx];
|
||||
}
|
||||
}
|
||||
|
||||
/// Galerkin coarsening: coarse face coefficient = sum of the fine
|
||||
/// coefficients across that coarse face, coarse `extra_diag` = sum of
|
||||
/// the children's. Returns the coarse problem and the parent map.
|
||||
fn coarsen(&self) -> (PoissonProblem, Vec<usize>) {
|
||||
let (nx, ny) = (self.problem.nx, self.problem.ny);
|
||||
let nxc = (nx / 2).max(1);
|
||||
let nyc = (ny / 2).max(1);
|
||||
let cx = |i: usize| (i / 2).min(nxc - 1);
|
||||
let cy = |j: usize| (j / 2).min(nyc - 1);
|
||||
let mut coarse = PoissonProblem::new(nxc, nyc);
|
||||
coarse.active.fill(false);
|
||||
let mut coarse_of = vec![usize::MAX; nx * ny];
|
||||
for &idx in &self.cells {
|
||||
let (i, j) = (idx % nx, idx / nx);
|
||||
let (ic, jc) = (cx(i), cy(j));
|
||||
let c = jc * nxc + ic;
|
||||
coarse_of[idx] = c;
|
||||
coarse.active[c] = true;
|
||||
coarse.extra_diag[c] += self.problem.extra_diag[idx];
|
||||
// Coefficients towards inactive neighbours are already zero.
|
||||
if self.problem.ae[idx] != 0.0 && cx(i + 1) != ic {
|
||||
coarse.ae[c] += self.problem.ae[idx];
|
||||
}
|
||||
if self.problem.aw[idx] != 0.0 && cx(i - 1) != ic {
|
||||
coarse.aw[c] += self.problem.aw[idx];
|
||||
}
|
||||
if self.problem.an[idx] != 0.0 && cy(j + 1) != jc {
|
||||
coarse.an[c] += self.problem.an[idx];
|
||||
}
|
||||
if self.problem.as_[idx] != 0.0 && cy(j - 1) != jc {
|
||||
coarse.as_[c] += self.problem.as_[idx];
|
||||
}
|
||||
}
|
||||
(coarse, coarse_of)
|
||||
}
|
||||
}
|
||||
|
||||
/// The multigrid hierarchy: level 0 is the fine problem.
|
||||
pub(crate) struct Hierarchy {
|
||||
levels: Vec<Level>,
|
||||
work: Vec<Work>,
|
||||
sweeps: usize,
|
||||
}
|
||||
|
||||
impl Hierarchy {
|
||||
/// Builds the hierarchy down to `coarsest_cells` active cells (or until
|
||||
/// coarsening stops reducing the count).
|
||||
pub(crate) fn build(problem: &PoissonProblem, params: &MultigridParameters) -> Self {
|
||||
let mut levels = vec![Level::new(problem.clone())];
|
||||
while levels.len() < MAX_LEVELS {
|
||||
let fine = levels.last().expect("at least one level");
|
||||
if fine.cells.len() <= params.coarsest_cells.max(1) {
|
||||
break;
|
||||
}
|
||||
let (coarse, coarse_of) = fine.coarsen();
|
||||
let coarse = Level::new(coarse);
|
||||
if coarse.cells.len() >= fine.cells.len() {
|
||||
break;
|
||||
}
|
||||
let last = levels.len() - 1;
|
||||
levels[last].coarse_of = coarse_of;
|
||||
levels.push(coarse);
|
||||
}
|
||||
let work = levels
|
||||
.iter()
|
||||
.map(|l| Work::new(l.problem.nx * l.problem.ny))
|
||||
.collect();
|
||||
Self {
|
||||
levels,
|
||||
work,
|
||||
sweeps: params.smoother_sweeps.max(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of levels.
|
||||
pub(crate) fn depth(&self) -> usize {
|
||||
self.levels.len()
|
||||
}
|
||||
|
||||
/// The problem on level `l` (level 0 is the fine problem, with the
|
||||
/// coefficients towards inactive cells sanitised to zero).
|
||||
pub(crate) fn problem(&self, l: usize) -> &PoissonProblem {
|
||||
&self.levels[l].problem
|
||||
}
|
||||
|
||||
/// Parent map from level `l` to level `l + 1` (`usize::MAX` on cells
|
||||
/// without an equation).
|
||||
pub(crate) fn coarse_of(&self, l: usize) -> &[usize] {
|
||||
&self.levels[l].coarse_of
|
||||
}
|
||||
|
||||
/// Row-major indices of the active cells of level `l`.
|
||||
pub(crate) fn cells(&self, l: usize) -> &[usize] {
|
||||
&self.levels[l].cells
|
||||
}
|
||||
|
||||
/// `z = M⁻¹ r`: one V-cycle on `A z = r` from `z = 0`. Only active
|
||||
/// entries of `r` are read and only active entries of `z` are written.
|
||||
pub(crate) fn apply_preconditioner(&mut self, r: &[f64], z: &mut [f64]) {
|
||||
let depth = self.levels.len();
|
||||
let levels = &self.levels;
|
||||
for &idx in &levels[0].cells {
|
||||
self.work[0].b[idx] = r[idx];
|
||||
}
|
||||
// Down: smooth from zero, restrict the residual.
|
||||
for l in 0..depth - 1 {
|
||||
let (fine, coarse) = (&levels[l], &levels[l + 1]);
|
||||
let (head, tail) = self.work.split_at_mut(l + 1);
|
||||
let (wf, wc) = (&mut head[l], &mut tail[0]);
|
||||
for &idx in &fine.cells {
|
||||
wf.x[idx] = 0.0;
|
||||
}
|
||||
for _ in 0..self.sweeps {
|
||||
fine.symmetric_gs(&wf.b, &mut wf.x);
|
||||
}
|
||||
fine.residual(&wf.b, &wf.x, &mut wf.r);
|
||||
for &idx in &coarse.cells {
|
||||
wc.b[idx] = 0.0;
|
||||
}
|
||||
for &idx in &fine.cells {
|
||||
let c = fine.coarse_of[idx];
|
||||
// A coarse cell without an equation (a whole component
|
||||
// inside one aggregate) receives the component's zero sum.
|
||||
if coarse.active[c] {
|
||||
wc.b[c] += wf.r[idx];
|
||||
}
|
||||
}
|
||||
}
|
||||
// Coarsest: a fixed number of symmetric sweeps from zero.
|
||||
{
|
||||
let bottom = &levels[depth - 1];
|
||||
let wb = &mut self.work[depth - 1];
|
||||
for &idx in &bottom.cells {
|
||||
wb.x[idx] = 0.0;
|
||||
}
|
||||
for _ in 0..COARSEST_SWEEPS {
|
||||
bottom.symmetric_gs(&wb.b, &mut wb.x);
|
||||
}
|
||||
}
|
||||
// Up: prolongate, smooth.
|
||||
for l in (0..depth - 1).rev() {
|
||||
let fine = &levels[l];
|
||||
let (head, tail) = self.work.split_at_mut(l + 1);
|
||||
let (wf, wc) = (&mut head[l], &tail[0]);
|
||||
for &idx in &fine.cells {
|
||||
wf.x[idx] += COARSE_CORRECTION * wc.x[fine.coarse_of[idx]];
|
||||
}
|
||||
for _ in 0..self.sweeps {
|
||||
fine.symmetric_gs(&wf.b, &mut wf.x);
|
||||
}
|
||||
}
|
||||
for &idx in &levels[0].cells {
|
||||
z[idx] = self.work[0].x[idx];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Conjugate gradient on the active cells preconditioned by one V-cycle of
|
||||
/// geometric multigrid (see the module documentation for the hierarchy).
|
||||
///
|
||||
/// Stops when the L1 true residual `Σ |b - A p|` over the active cells is
|
||||
/// below `tolerance` (absolute — the caller passes a scale-relative value)
|
||||
/// or after `params.max_iterations` iterations.
|
||||
///
|
||||
/// Singular (pure Neumann) problems: CG runs on the consistent system (the
|
||||
/// caller guarantees a compatible rhs to rounding; the mean over active
|
||||
/// cells is projected out of the rhs to be safe, and the residual reported
|
||||
/// and tested is against that projected rhs — an incompatible rhs shows up
|
||||
/// as the gap between it and `problem.residual_l1(p)`), and on exit `p` is shifted
|
||||
/// so `p[anchor] == 0` when `anchor` is `Some` and names an active cell,
|
||||
/// else to mean zero over the active cells. On a non-singular problem the
|
||||
/// level is determined by the equations and `anchor` is ignored.
|
||||
///
|
||||
/// `p` is the initial guess and the result; inactive entries are neither
|
||||
/// read nor written.
|
||||
pub fn solve_multigrid_pcg(
|
||||
problem: &PoissonProblem,
|
||||
p: &mut [f64],
|
||||
params: &MultigridParameters,
|
||||
tolerance: f64,
|
||||
anchor: Option<usize>,
|
||||
) -> PoissonSolution {
|
||||
let n = problem.nx * problem.ny;
|
||||
assert_eq!(p.len(), n, "p must have nx*ny entries");
|
||||
debug_assert!(
|
||||
problem.validate().is_ok(),
|
||||
"invalid PoissonProblem: {:?}",
|
||||
problem.validate()
|
||||
);
|
||||
|
||||
let mut hier = Hierarchy::build(problem, params);
|
||||
let cells: Vec<usize> = hier.levels[0].cells.clone();
|
||||
let active_n = cells.len();
|
||||
if active_n == 0 {
|
||||
return PoissonSolution {
|
||||
iterations: 0,
|
||||
residual: 0.0,
|
||||
converged: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Connected components of the active cells (through faces with a
|
||||
// non-zero coefficient). A component with no Dirichlet contribution is
|
||||
// a pure-Neumann block of the system and singular ON ITS OWN: its
|
||||
// right-hand side must have zero mean for the block to be consistent,
|
||||
// whatever the other components carry. A single global mean projection
|
||||
// is not enough — two enclosed pockets with opposite imbalances sum to
|
||||
// zero globally and still make CG diverge, and the divergence pollutes
|
||||
// even a well-posed Dirichlet component (found in review, 1e-8
|
||||
// relative was enough). So the mean is projected per singular
|
||||
// component, and the exit shift is applied per singular component.
|
||||
let components = Components::find(problem, &cells);
|
||||
let singular_any = components.singular.iter().any(|&s| s);
|
||||
|
||||
let project_mean = |v: &mut [f64]| {
|
||||
for (c, members) in components.members.iter().enumerate() {
|
||||
if !components.singular[c] {
|
||||
continue;
|
||||
}
|
||||
let mean = members.iter().map(|&idx| v[idx]).sum::<f64>() / members.len() as f64;
|
||||
for &idx in members {
|
||||
v[idx] -= mean;
|
||||
}
|
||||
}
|
||||
};
|
||||
let dot = |a: &[f64], b: &[f64]| cells.iter().map(|&idx| a[idx] * b[idx]).sum::<f64>();
|
||||
let l1 = |a: &[f64]| cells.iter().map(|&idx| a[idx].abs()).sum::<f64>();
|
||||
|
||||
// Right-hand side (per-component mean projected out where singular).
|
||||
let mut b = vec![0.0; n];
|
||||
for &idx in &cells {
|
||||
b[idx] = problem.rhs[idx];
|
||||
}
|
||||
project_mean(&mut b);
|
||||
let singular = singular_any;
|
||||
|
||||
let mut r = vec![0.0; n];
|
||||
let mut z = vec![0.0; n];
|
||||
let mut d = vec![0.0; n];
|
||||
let mut q = vec![0.0; n];
|
||||
|
||||
let true_residual =
|
||||
|p: &[f64], r: &mut [f64], hier: &Hierarchy| -> f64 { hier.levels[0].residual(&b, p, r) };
|
||||
|
||||
let anchor = anchor.filter(|&a| a < n && hier.levels[0].active[a]);
|
||||
let finish = |p: &mut [f64], iterations: usize, residual: f64| {
|
||||
// Level of each singular component: the anchor's component is
|
||||
// shifted so p[anchor] == 0, every other singular component to mean
|
||||
// zero. Non-singular components have their level fixed by their
|
||||
// Dirichlet data and are left alone.
|
||||
for (c, members) in components.members.iter().enumerate() {
|
||||
if !components.singular[c] {
|
||||
continue;
|
||||
}
|
||||
let shift = match anchor {
|
||||
Some(a) if components.id[a] == c => p[a],
|
||||
_ => members.iter().map(|&idx| p[idx]).sum::<f64>() / members.len() as f64,
|
||||
};
|
||||
for &idx in members {
|
||||
p[idx] -= shift;
|
||||
}
|
||||
}
|
||||
PoissonSolution {
|
||||
iterations,
|
||||
residual,
|
||||
converged: residual < tolerance,
|
||||
}
|
||||
};
|
||||
|
||||
let mut res = true_residual(p, &mut r, &hier);
|
||||
if res < tolerance {
|
||||
return finish(p, 0, res);
|
||||
}
|
||||
|
||||
hier.apply_preconditioner(&r, &mut z);
|
||||
if singular {
|
||||
project_mean(&mut z);
|
||||
}
|
||||
for &idx in &cells {
|
||||
d[idx] = z[idx];
|
||||
}
|
||||
let mut rz = dot(&r, &z);
|
||||
|
||||
let mut iterations = 0;
|
||||
// True residual at the last resynchronisation: when a resynchronised
|
||||
// true residual no longer improves on the previous one, the recurrence
|
||||
// has hit the rounding floor of `b - A p` and further iterations cannot
|
||||
// reach the tolerance — stop, honestly unconverged.
|
||||
let mut last_true = res;
|
||||
while iterations < params.max_iterations {
|
||||
iterations += 1;
|
||||
hier.levels[0].apply(&d, &mut q);
|
||||
let dq = dot(&d, &q);
|
||||
if !dq.is_finite() || dq <= 0.0 || !rz.is_finite() || rz <= 0.0 {
|
||||
// Breakdown (r = 0 to rounding, or a non-positive curvature
|
||||
// from rounding in the null space): stop on the true residual.
|
||||
res = true_residual(p, &mut r, &hier);
|
||||
return finish(p, iterations, res);
|
||||
}
|
||||
let alpha = rz / dq;
|
||||
for &idx in &cells {
|
||||
p[idx] += alpha * d[idx];
|
||||
r[idx] -= alpha * q[idx];
|
||||
}
|
||||
if l1(&r) < tolerance {
|
||||
// The recurrence residual passed: confirm against the TRUE
|
||||
// residual, and resynchronise if rounding has let them drift.
|
||||
res = true_residual(p, &mut r, &hier);
|
||||
if res < tolerance || res > 0.9 * last_true {
|
||||
return finish(p, iterations, res);
|
||||
}
|
||||
last_true = res;
|
||||
}
|
||||
hier.apply_preconditioner(&r, &mut z);
|
||||
if singular {
|
||||
project_mean(&mut z);
|
||||
}
|
||||
let rz_new = dot(&r, &z);
|
||||
let beta = rz_new / rz;
|
||||
rz = rz_new;
|
||||
for &idx in &cells {
|
||||
d[idx] = z[idx] + beta * d[idx];
|
||||
}
|
||||
}
|
||||
res = true_residual(p, &mut r, &hier);
|
||||
finish(p, iterations, res)
|
||||
}
|
||||
|
||||
/// Connected components of the active cells of a [`PoissonProblem`],
|
||||
/// connected through faces carrying a non-zero coefficient, and whether
|
||||
/// each component is singular (carries no Dirichlet contribution).
|
||||
struct Components {
|
||||
/// Component id per cell (`usize::MAX` for inactive cells).
|
||||
id: Vec<usize>,
|
||||
/// Member cells per component.
|
||||
members: Vec<Vec<usize>>,
|
||||
/// Per component: no active member has `extra_diag > 0`.
|
||||
singular: Vec<bool>,
|
||||
}
|
||||
|
||||
impl Components {
|
||||
fn find(problem: &PoissonProblem, cells: &[usize]) -> Self {
|
||||
let (nx, ny) = (problem.nx, problem.ny);
|
||||
let n = nx * ny;
|
||||
let mut id = vec![usize::MAX; n];
|
||||
let mut members = Vec::new();
|
||||
let mut singular = Vec::new();
|
||||
let mut stack = Vec::new();
|
||||
for &seed in cells {
|
||||
if id[seed] != usize::MAX {
|
||||
continue;
|
||||
}
|
||||
let c = members.len();
|
||||
let mut list = Vec::new();
|
||||
let mut has_dirichlet = false;
|
||||
id[seed] = c;
|
||||
stack.push(seed);
|
||||
while let Some(idx) = stack.pop() {
|
||||
list.push(idx);
|
||||
if problem.extra_diag[idx] > 0.0 {
|
||||
has_dirichlet = true;
|
||||
}
|
||||
let (j, i) = (idx / nx, idx % nx);
|
||||
let mut visit = |nb: usize, coefficient: f64| {
|
||||
if coefficient > 0.0 && problem.active[nb] && id[nb] == usize::MAX {
|
||||
id[nb] = c;
|
||||
stack.push(nb);
|
||||
}
|
||||
};
|
||||
if i + 1 < nx {
|
||||
visit(idx + 1, problem.ae[idx]);
|
||||
}
|
||||
if i > 0 {
|
||||
visit(idx - 1, problem.aw[idx]);
|
||||
}
|
||||
if j + 1 < ny {
|
||||
visit(idx + nx, problem.an[idx]);
|
||||
}
|
||||
if j > 0 {
|
||||
visit(idx - nx, problem.as_[idx]);
|
||||
}
|
||||
}
|
||||
members.push(list);
|
||||
singular.push(!has_dirichlet);
|
||||
}
|
||||
Self {
|
||||
id,
|
||||
members,
|
||||
singular,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,797 @@
|
||||
//! Unit tests of the multigrid-preconditioned CG Poisson solver — each one
|
||||
//! a claim a wrong solver fails: manufactured solutions recovered to
|
||||
//! rounding, anchor semantics exact, inactive cells untouched, the Galerkin
|
||||
//! coarse operator identical to `R A P`, the V-cycle symmetric, the
|
||||
//! iteration count grid-independent, and the timing against plain SOR.
|
||||
|
||||
use super::*;
|
||||
use std::f64::consts::PI;
|
||||
|
||||
/// Deterministic pseudo-random numbers in `[-1, 1)` (no crate version
|
||||
/// dependence in the tests).
|
||||
struct Lcg(u64);
|
||||
impl Lcg {
|
||||
fn next(&mut self) -> f64 {
|
||||
self.0 = self
|
||||
.0
|
||||
.wrapping_mul(6_364_136_223_846_793_005)
|
||||
.wrapping_add(1_442_695_040_888_963_407);
|
||||
((self.0 >> 11) as f64 / (1u64 << 53) as f64).mul_add(2.0, -1.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Unit-conductance five-point problem on an `nx × ny` grid: coefficient
|
||||
/// 1 across every face between two active cells, zero across a domain
|
||||
/// edge or a non-active face. `dirichlet_sides`: `[left, right, bottom,
|
||||
/// top]` edges carry `p = 0` half a cell outside (`extra_diag += 2`).
|
||||
fn assemble(
|
||||
nx: usize,
|
||||
ny: usize,
|
||||
active: impl Fn(usize, usize) -> bool,
|
||||
dirichlet_sides: [bool; 4],
|
||||
) -> PoissonProblem {
|
||||
let mut pr = PoissonProblem::new(nx, ny);
|
||||
for j in 0..ny {
|
||||
for i in 0..nx {
|
||||
pr.active[j * nx + i] = active(j, i);
|
||||
}
|
||||
}
|
||||
for j in 0..ny {
|
||||
for i in 0..nx {
|
||||
let idx = j * nx + i;
|
||||
if !pr.active[idx] {
|
||||
continue;
|
||||
}
|
||||
if i + 1 < nx {
|
||||
if pr.active[idx + 1] {
|
||||
pr.ae[idx] = 1.0;
|
||||
}
|
||||
} else if dirichlet_sides[1] {
|
||||
pr.extra_diag[idx] += 2.0;
|
||||
}
|
||||
if i > 0 {
|
||||
if pr.active[idx - 1] {
|
||||
pr.aw[idx] = 1.0;
|
||||
}
|
||||
} else if dirichlet_sides[0] {
|
||||
pr.extra_diag[idx] += 2.0;
|
||||
}
|
||||
if j + 1 < ny {
|
||||
if pr.active[idx + nx] {
|
||||
pr.an[idx] = 1.0;
|
||||
}
|
||||
} else if dirichlet_sides[3] {
|
||||
pr.extra_diag[idx] += 2.0;
|
||||
}
|
||||
if j > 0 {
|
||||
if pr.active[idx - nx] {
|
||||
pr.as_[idx] = 1.0;
|
||||
}
|
||||
} else if dirichlet_sides[2] {
|
||||
pr.extra_diag[idx] += 2.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
pr.validate().expect("assembled problem is valid");
|
||||
pr
|
||||
}
|
||||
|
||||
/// `A p` written out directly from the definition (independent of the
|
||||
/// solver's stencil code); zero on inactive cells.
|
||||
fn apply_operator(pr: &PoissonProblem, p: &[f64]) -> Vec<f64> {
|
||||
let (nx, ny) = (pr.nx, pr.ny);
|
||||
let mut out = vec![0.0; nx * ny];
|
||||
for j in 0..ny {
|
||||
for i in 0..nx {
|
||||
let idx = j * nx + i;
|
||||
if !pr.active[idx] {
|
||||
continue;
|
||||
}
|
||||
let mut v = pr.diagonal(idx) * p[idx];
|
||||
if i + 1 < nx && pr.active[idx + 1] {
|
||||
v -= pr.ae[idx] * p[idx + 1];
|
||||
}
|
||||
if i > 0 && pr.active[idx - 1] {
|
||||
v -= pr.aw[idx] * p[idx - 1];
|
||||
}
|
||||
if j + 1 < ny && pr.active[idx + nx] {
|
||||
v -= pr.an[idx] * p[idx + nx];
|
||||
}
|
||||
if j > 0 && pr.active[idx - nx] {
|
||||
v -= pr.as_[idx] * p[idx - nx];
|
||||
}
|
||||
out[idx] = v;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn active_indices(pr: &PoissonProblem) -> Vec<usize> {
|
||||
(0..pr.nx * pr.ny).filter(|&i| pr.active[i]).collect()
|
||||
}
|
||||
|
||||
fn remove_mean(pr: &PoissonProblem, v: &mut [f64]) {
|
||||
let cells = active_indices(pr);
|
||||
let mean = cells.iter().map(|&i| v[i]).sum::<f64>() / cells.len() as f64;
|
||||
for &i in &cells {
|
||||
v[i] -= mean;
|
||||
}
|
||||
}
|
||||
|
||||
fn l1_active(pr: &PoissonProblem, v: &[f64]) -> f64 {
|
||||
active_indices(pr).iter().map(|&i| v[i].abs()).sum()
|
||||
}
|
||||
|
||||
/// Smooth field on cell centres of the unit square.
|
||||
fn smooth_field(nx: usize, ny: usize) -> Vec<f64> {
|
||||
let (hx, hy) = (1.0 / nx as f64, 1.0 / ny as f64);
|
||||
let mut f = vec![0.0; nx * ny];
|
||||
for j in 0..ny {
|
||||
for i in 0..nx {
|
||||
let (x, y) = ((i as f64 + 0.5) * hx, (j as f64 + 0.5) * hy);
|
||||
f[j * nx + i] = (PI * x).cos() * (2.0 * PI * y).cos() + 0.3 * (3.0 * PI * x).sin();
|
||||
}
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// Zero-mean pseudo-random rhs on the active cells.
|
||||
fn random_rhs(pr: &mut PoissonProblem, seed: u64) {
|
||||
let mut g = Lcg(seed);
|
||||
for idx in active_indices(pr) {
|
||||
pr.rhs[idx] = g.next();
|
||||
}
|
||||
let mut rhs = std::mem::take(&mut pr.rhs);
|
||||
remove_mean(pr, &mut rhs);
|
||||
pr.rhs = rhs;
|
||||
}
|
||||
|
||||
/// Point SOR at the optimal Poisson factor with the true-residual stop —
|
||||
/// the reference the multigrid PCG is timed against. `anchor = Some`
|
||||
/// pins that cell to zero as the production projections do (on a pure
|
||||
/// Neumann problem that pins the level but slows the near-null mode);
|
||||
/// `None` runs SOR on the singular consistent system, whose iterate
|
||||
/// converges with a floating level.
|
||||
fn sor_reference(
|
||||
pr: &PoissonProblem,
|
||||
p: &mut [f64],
|
||||
tolerance: f64,
|
||||
max_sweeps: usize,
|
||||
anchor: Option<usize>,
|
||||
) -> (usize, f64) {
|
||||
let (nx, ny) = (pr.nx, pr.ny);
|
||||
let omega = 2.0 / (1.0 + (PI / nx.max(ny) as f64).sin());
|
||||
let singular = pr.is_singular();
|
||||
let anchor = anchor.filter(|_| singular);
|
||||
for sweep in 1..=max_sweeps {
|
||||
let mut residual = 0.0;
|
||||
for j in 0..ny {
|
||||
for i in 0..nx {
|
||||
let idx = j * nx + i;
|
||||
if !pr.active[idx] {
|
||||
continue;
|
||||
}
|
||||
if anchor == Some(idx) {
|
||||
p[idx] = 0.0;
|
||||
continue;
|
||||
}
|
||||
let ap = pr.diagonal(idx);
|
||||
let mut nb = 0.0;
|
||||
if i + 1 < nx && pr.active[idx + 1] {
|
||||
nb += pr.ae[idx] * p[idx + 1];
|
||||
}
|
||||
if i > 0 && pr.active[idx - 1] {
|
||||
nb += pr.aw[idx] * p[idx - 1];
|
||||
}
|
||||
if j + 1 < ny && pr.active[idx + nx] {
|
||||
nb += pr.an[idx] * p[idx + nx];
|
||||
}
|
||||
if j > 0 && pr.active[idx - nx] {
|
||||
nb += pr.as_[idx] * p[idx - nx];
|
||||
}
|
||||
let rhs = pr.rhs[idx] + nb;
|
||||
let old = p[idx];
|
||||
residual += (rhs - ap * old).abs();
|
||||
p[idx] = (1.0 - omega) * old + omega * rhs / ap;
|
||||
}
|
||||
}
|
||||
// The in-sweep (half-sweep lagged) sum over-estimates the residual
|
||||
// after the sweep at omega ~ 2, so the stop is the TRUE residual,
|
||||
// checked every 8 sweeps (the count is honest to within 8).
|
||||
if residual < tolerance || sweep % 8 == 0 {
|
||||
let true_res = pr.residual_l1(p);
|
||||
if true_res < tolerance {
|
||||
return (sweep, true_res);
|
||||
}
|
||||
}
|
||||
}
|
||||
(max_sweeps, pr.residual_l1(p))
|
||||
}
|
||||
|
||||
fn max_abs_diff(pr: &PoissonProblem, a: &[f64], b: &[f64]) -> f64 {
|
||||
active_indices(pr)
|
||||
.iter()
|
||||
.map(|&i| (a[i] - b[i]).abs())
|
||||
.fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
fn max_abs(pr: &PoissonProblem, a: &[f64]) -> f64 {
|
||||
active_indices(pr)
|
||||
.iter()
|
||||
.map(|&i| a[i].abs())
|
||||
.fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dirichlet_manufactured_solution_recovered() {
|
||||
let n = 40;
|
||||
let mut pr = assemble(n, n, |_, _| true, [true; 4]);
|
||||
let h = 1.0 / n as f64;
|
||||
let exact: Vec<f64> = (0..n * n)
|
||||
.map(|idx| {
|
||||
let (i, j) = (idx % n, idx / n);
|
||||
(PI * (i as f64 + 0.5) * h).sin() * (PI * (j as f64 + 0.5) * h).sin()
|
||||
})
|
||||
.collect();
|
||||
pr.rhs = apply_operator(&pr, &exact);
|
||||
assert!(!pr.is_singular());
|
||||
let scale = l1_active(&pr, &pr.rhs);
|
||||
let mut p = vec![0.0; n * n];
|
||||
let sol = solve_multigrid_pcg(
|
||||
&pr,
|
||||
&mut p,
|
||||
&MultigridParameters::default(),
|
||||
1e-12 * scale,
|
||||
None,
|
||||
);
|
||||
let err = max_abs_diff(&pr, &p, &exact);
|
||||
println!(
|
||||
"dirichlet {n}^2: {} iterations, residual {:.3e} (scale {:.3e}), max error {err:.3e}",
|
||||
sol.iterations, sol.residual, scale
|
||||
);
|
||||
assert!(sol.converged, "{sol:?}");
|
||||
assert!(err <= 1e-10 * max_abs(&pr, &exact), "max error {err:.3e}");
|
||||
assert!(sol.iterations < 40, "{} iterations", sol.iterations);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neumann_box_recovered_up_to_constant_with_anchor() {
|
||||
let n = 32;
|
||||
let mut pr = assemble(n, n, |_, _| true, [false; 4]);
|
||||
assert!(pr.is_singular());
|
||||
let field = smooth_field(n, n);
|
||||
let mut rhs = apply_operator(&pr, &field);
|
||||
remove_mean(&pr, &mut rhs);
|
||||
pr.rhs = rhs;
|
||||
let scale = l1_active(&pr, &pr.rhs);
|
||||
let anchor = pr.index(1, 1);
|
||||
// Start from a deliberately shifted guess: the level must be fixed
|
||||
// by the anchor, not by the initial guess.
|
||||
let mut p = vec![3.0; n * n];
|
||||
let sol = solve_multigrid_pcg(
|
||||
&pr,
|
||||
&mut p,
|
||||
&MultigridParameters::default(),
|
||||
1e-13 * scale,
|
||||
Some(anchor),
|
||||
);
|
||||
assert!(sol.converged, "{sol:?}");
|
||||
assert_eq!(p[anchor], 0.0, "anchor semantics must be exact");
|
||||
let shifted: Vec<f64> = field.iter().map(|v| v - field[anchor]).collect();
|
||||
let err = max_abs_diff(&pr, &p, &shifted);
|
||||
println!(
|
||||
"neumann {n}^2: {} iterations, residual {:.3e} (scale {:.3e}), max error {err:.3e}",
|
||||
sol.iterations, sol.residual, scale
|
||||
);
|
||||
assert!(err <= 1e-10 * max_abs(&pr, &shifted), "max error {err:.3e}");
|
||||
|
||||
// Without an anchor: mean zero over the active cells.
|
||||
let mut p2 = vec![-7.0; n * n];
|
||||
let sol2 = solve_multigrid_pcg(
|
||||
&pr,
|
||||
&mut p2,
|
||||
&MultigridParameters::default(),
|
||||
1e-13 * scale,
|
||||
None,
|
||||
);
|
||||
assert!(sol2.converged, "{sol2:?}");
|
||||
let mean = p2.iter().sum::<f64>() / (n * n) as f64;
|
||||
assert!(mean.abs() <= 1e-12, "mean {mean:e}");
|
||||
}
|
||||
|
||||
fn circle_inactive(n: usize) -> impl Fn(usize, usize) -> bool {
|
||||
move |j, i| {
|
||||
let h = 1.0 / n as f64;
|
||||
let (x, y) = ((i as f64 + 0.5) * h, (j as f64 + 0.5) * h);
|
||||
(x - 0.5).powi(2) + (y - 0.5).powi(2) >= 0.04
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masked_circle_neumann_recovered_and_inactive_cells_untouched() {
|
||||
let n = 48;
|
||||
let mut pr = assemble(n, n, circle_inactive(n), [false; 4]);
|
||||
let inactive = (0..n * n).filter(|&i| !pr.active[i]).count();
|
||||
assert!(inactive > 200, "the circle must remove cells ({inactive})");
|
||||
assert!(pr.is_singular());
|
||||
let field = smooth_field(n, n);
|
||||
pr.rhs = apply_operator(&pr, &field);
|
||||
let scale = l1_active(&pr, &pr.rhs);
|
||||
let anchor = active_indices(&pr)[0];
|
||||
let mut p: Vec<f64> = (0..n * n)
|
||||
.map(|i| if pr.active[i] { 0.0 } else { f64::NAN })
|
||||
.collect();
|
||||
let sol = solve_multigrid_pcg(
|
||||
&pr,
|
||||
&mut p,
|
||||
&MultigridParameters::default(),
|
||||
1e-13 * scale,
|
||||
Some(anchor),
|
||||
);
|
||||
println!(
|
||||
"masked {n}^2 ({} active): {} iterations, residual {:.3e} (scale {:.3e})",
|
||||
n * n - inactive,
|
||||
sol.iterations,
|
||||
sol.residual,
|
||||
scale
|
||||
);
|
||||
assert!(sol.converged, "{sol:?}");
|
||||
for i in 0..n * n {
|
||||
if pr.active[i] {
|
||||
assert!(p[i].is_finite(), "active cell {i} is {}", p[i]);
|
||||
} else {
|
||||
assert!(p[i].is_nan(), "inactive cell {i} was written: {}", p[i]);
|
||||
}
|
||||
}
|
||||
assert_eq!(p[anchor], 0.0);
|
||||
let shifted: Vec<f64> = field.iter().map(|v| v - field[anchor]).collect();
|
||||
let err = max_abs_diff(&pr, &p, &shifted);
|
||||
println!("masked max error {err:.3e}");
|
||||
assert!(err <= 1e-10 * max_abs(&pr, &shifted), "max error {err:.3e}");
|
||||
}
|
||||
|
||||
/// Iterations to cut the initial residual by `reduction` on the Neumann
|
||||
/// box with a zero-mean random rhs.
|
||||
fn neumann_box_iterations(n: usize, reduction: f64) -> (usize, f64) {
|
||||
let mut pr = assemble(n, n, |_, _| true, [false; 4]);
|
||||
random_rhs(&mut pr, 17 + n as u64);
|
||||
let scale = l1_active(&pr, &pr.rhs);
|
||||
let mut p = vec![0.0; n * n];
|
||||
let sol = solve_multigrid_pcg(
|
||||
&pr,
|
||||
&mut p,
|
||||
&MultigridParameters::default(),
|
||||
reduction * scale,
|
||||
Some(pr.index(1, 1)),
|
||||
);
|
||||
assert!(sol.converged, "{n}^2: {sol:?}");
|
||||
(sol.iterations, sol.residual / scale)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iteration_count_is_grid_independent() {
|
||||
let mut counts = Vec::new();
|
||||
for &n in &[32usize, 64, 128, 256] {
|
||||
let (it, rel) = neumann_box_iterations(n, 1e-8);
|
||||
println!("neumann {n}^2: {it} PCG iterations (final residual {rel:.2e} of rhs)");
|
||||
counts.push(it);
|
||||
}
|
||||
println!("iteration counts 32..256: {counts:?}");
|
||||
assert!(
|
||||
counts[3] <= 2 * counts[0],
|
||||
"256^2 took {} iterations vs {} at 32^2",
|
||||
counts[3],
|
||||
counts[0]
|
||||
);
|
||||
assert!(counts[3] <= 60, "256^2 took {} iterations", counts[3]);
|
||||
}
|
||||
|
||||
/// Ragged mask: the circle plus scattered single-cell obstacles, so many
|
||||
/// aggregates are partial and the over-corrected coarse correction meets
|
||||
/// cells where the factor-2 argument does not hold.
|
||||
fn ragged_active(n: usize) -> impl Fn(usize, usize) -> bool {
|
||||
let circle = circle_inactive(n);
|
||||
move |j, i| circle(j, i) && !((i + j) % 17 == 0 && i % 3 != 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ragged_mask_iteration_count_stays_bounded() {
|
||||
let mut counts = Vec::new();
|
||||
for &n in &[48usize, 96, 192] {
|
||||
let mut pr = assemble(n, n, ragged_active(n), [false; 4]);
|
||||
random_rhs(&mut pr, 23);
|
||||
let scale = l1_active(&pr, &pr.rhs);
|
||||
let anchor = active_indices(&pr)[0];
|
||||
let mut p: Vec<f64> = (0..n * n)
|
||||
.map(|i| if pr.active[i] { 0.0 } else { f64::NAN })
|
||||
.collect();
|
||||
let sol = solve_multigrid_pcg(
|
||||
&pr,
|
||||
&mut p,
|
||||
&MultigridParameters::default(),
|
||||
1e-8 * scale,
|
||||
Some(anchor),
|
||||
);
|
||||
println!(
|
||||
"ragged {n}^2 ({} active): {} iterations, residual {:.2e} of rhs",
|
||||
active_indices(&pr).len(),
|
||||
sol.iterations,
|
||||
sol.residual / scale
|
||||
);
|
||||
assert!(sol.converged, "{n}^2: {sol:?}");
|
||||
counts.push(sol.iterations);
|
||||
}
|
||||
println!("ragged iteration counts 48..192: {counts:?}");
|
||||
assert!(counts[2] <= 2 * counts[0] && counts[2] <= 60, "{counts:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn galerkin_coarse_operator_matches_r_a_p() {
|
||||
let n = 37;
|
||||
let mut pr = assemble(n, n, circle_inactive(n), [false, true, false, false]);
|
||||
// Add an uneven conductance so a wrong summation cannot hide behind
|
||||
// unit coefficients.
|
||||
for j in 0..n {
|
||||
for i in 0..n {
|
||||
let idx = j * n + i;
|
||||
let w = 1.0 + 0.5 * ((i * 7 + j * 3) % 5) as f64;
|
||||
if pr.ae[idx] != 0.0 {
|
||||
pr.ae[idx] *= w;
|
||||
pr.aw[idx + 1] *= w;
|
||||
}
|
||||
if pr.an[idx] != 0.0 {
|
||||
pr.an[idx] *= w;
|
||||
pr.as_[idx + n] *= w;
|
||||
}
|
||||
}
|
||||
}
|
||||
pr.validate().expect("weighted problem is valid");
|
||||
let hier = Hierarchy::build(&pr, &MultigridParameters::default());
|
||||
assert!(hier.depth() >= 3, "depth {}", hier.depth());
|
||||
let mut g = Lcg(5);
|
||||
for l in 0..hier.depth() - 1 {
|
||||
let fine = hier.problem(l);
|
||||
let coarse = hier.problem(l + 1);
|
||||
coarse.validate().expect("coarse problem is valid");
|
||||
let coarse_of = hier.coarse_of(l);
|
||||
let v: Vec<f64> = (0..coarse.nx * coarse.ny)
|
||||
.map(|i| if coarse.active[i] { g.next() } else { 0.0 })
|
||||
.collect();
|
||||
let direct = apply_operator(coarse, &v);
|
||||
// P v on the fine level, A (P v), then R = summation.
|
||||
let pv: Vec<f64> = (0..fine.nx * fine.ny)
|
||||
.map(|i| {
|
||||
if coarse_of[i] != usize::MAX {
|
||||
v[coarse_of[i]]
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let apv = apply_operator(fine, &pv);
|
||||
let mut rap = vec![0.0; coarse.nx * coarse.ny];
|
||||
for &i in hier.cells(l) {
|
||||
rap[coarse_of[i]] += apv[i];
|
||||
}
|
||||
let scale = max_abs(coarse, &direct);
|
||||
let diff = max_abs_diff(coarse, &direct, &rap);
|
||||
println!(
|
||||
"level {l} -> {}: {}x{} ({} active), |A_c v - R A P v| = {diff:.3e} (scale {scale:.3e})",
|
||||
l + 1,
|
||||
coarse.nx,
|
||||
coarse.ny,
|
||||
hier.cells(l + 1).len()
|
||||
);
|
||||
assert!(scale > 0.0);
|
||||
assert!(diff <= 1e-12 * scale, "level {l}: {diff:e}");
|
||||
// The outlet column's Dirichlet contribution survives coarsening.
|
||||
assert!(!coarse.is_singular());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odd_sizes_and_one_wide_strips_converge() {
|
||||
// 37 x 23 Neumann box.
|
||||
let (nx, ny) = (37, 23);
|
||||
let mut pr = assemble(nx, ny, |_, _| true, [false; 4]);
|
||||
random_rhs(&mut pr, 3);
|
||||
let scale = l1_active(&pr, &pr.rhs);
|
||||
let mut p = vec![0.0; nx * ny];
|
||||
let sol = solve_multigrid_pcg(
|
||||
&pr,
|
||||
&mut p,
|
||||
&MultigridParameters::default(),
|
||||
1e-10 * scale,
|
||||
Some(0),
|
||||
);
|
||||
println!("37x23: {sol:?}");
|
||||
assert!(sol.converged, "{sol:?}");
|
||||
assert_eq!(p[0], 0.0);
|
||||
|
||||
// A single row of 64 cells (ny = 1).
|
||||
let mut strip = assemble(64, 1, |_, _| true, [false; 4]);
|
||||
random_rhs(&mut strip, 4);
|
||||
let scale = l1_active(&strip, &strip.rhs);
|
||||
let mut p = vec![0.0; 64];
|
||||
let sol = solve_multigrid_pcg(
|
||||
&strip,
|
||||
&mut p,
|
||||
&MultigridParameters::default(),
|
||||
1e-10 * scale,
|
||||
None,
|
||||
);
|
||||
println!("64x1 strip: {sol:?}");
|
||||
assert!(sol.converged, "{sol:?}");
|
||||
assert!(strip.residual_l1(&p) < 1e-10 * scale);
|
||||
|
||||
// A 1-wide column of active cells inside a 2-D grid, plus an isolated
|
||||
// active cell with no equation (left untouched).
|
||||
let (nx, ny) = (9, 50);
|
||||
let mut col = assemble(nx, ny, |j, i| i == 3 || (j == 0 && i == 7), [false; 4]);
|
||||
random_rhs(&mut col, 5);
|
||||
col.rhs[7] = 0.0;
|
||||
let scale = l1_active(&col, &col.rhs);
|
||||
let mut p: Vec<f64> = (0..nx * ny)
|
||||
.map(|i| if col.active[i] { 0.0 } else { f64::NAN })
|
||||
.collect();
|
||||
p[7] = 42.0;
|
||||
let sol = solve_multigrid_pcg(
|
||||
&col,
|
||||
&mut p,
|
||||
&MultigridParameters::default(),
|
||||
1e-10 * scale,
|
||||
Some(3),
|
||||
);
|
||||
println!("1-wide column in 9x50: {sol:?}");
|
||||
assert!(sol.converged, "{sol:?}");
|
||||
assert_eq!(p[7], 42.0, "an isolated cell has no equation");
|
||||
assert_eq!(p[3], 0.0);
|
||||
for i in 0..nx * ny {
|
||||
if col.active[i] {
|
||||
assert!(p[i].is_finite());
|
||||
} else {
|
||||
assert!(p[i].is_nan());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v_cycle_preconditioner_is_symmetric() {
|
||||
let n = 30;
|
||||
let params = MultigridParameters::default();
|
||||
for (name, pr) in [
|
||||
(
|
||||
"masked neumann",
|
||||
assemble(n, n, circle_inactive(n), [false; 4]),
|
||||
),
|
||||
(
|
||||
"outlet column",
|
||||
assemble(n, n, circle_inactive(n), [false, true, false, false]),
|
||||
),
|
||||
("dirichlet", assemble(n, n, |_, _| true, [true; 4])),
|
||||
] {
|
||||
let mut hier = Hierarchy::build(&pr, ¶ms);
|
||||
let mut g = Lcg(11);
|
||||
let cells = active_indices(&pr);
|
||||
let mut x = vec![0.0; n * n];
|
||||
let mut y = vec![0.0; n * n];
|
||||
for &i in &cells {
|
||||
x[i] = g.next();
|
||||
y[i] = g.next();
|
||||
}
|
||||
let mut mx = vec![0.0; n * n];
|
||||
let mut my = vec![0.0; n * n];
|
||||
hier.apply_preconditioner(&x, &mut mx);
|
||||
hier.apply_preconditioner(&y, &mut my);
|
||||
let lhs: f64 = cells.iter().map(|&i| x[i] * my[i]).sum();
|
||||
let rhs: f64 = cells.iter().map(|&i| mx[i] * y[i]).sum();
|
||||
println!("{name}: <x, M^-1 y> = {lhs:.15e}, <M^-1 x, y> = {rhs:.15e}");
|
||||
assert!(lhs.abs() > 0.0);
|
||||
assert!(
|
||||
(lhs - rhs).abs() <= 1e-12 * lhs.abs().max(rhs.abs()),
|
||||
"{name}: {lhs:e} vs {rhs:e}"
|
||||
);
|
||||
// And positive on the range: <x, M^-1 x> > 0.
|
||||
let xx: f64 = cells.iter().map(|&i| x[i] * mx[i]).sum();
|
||||
assert!(xx > 0.0, "{name}: <x, M^-1 x> = {xx:e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_asymmetry_and_bad_lengths() {
|
||||
let mut pr = assemble(8, 8, |_, _| true, [false; 4]);
|
||||
pr.ae[0] = 2.0;
|
||||
assert!(pr.validate().unwrap_err().contains("asymmetric"));
|
||||
let mut pr = assemble(8, 8, |_, _| true, [false; 4]);
|
||||
pr.rhs.pop();
|
||||
assert!(pr.validate().unwrap_err().contains("length"));
|
||||
let mut pr = assemble(8, 8, |_, _| true, [false; 4]);
|
||||
pr.an[3] = -1.0;
|
||||
pr.as_[11] = -1.0;
|
||||
assert!(pr.validate().unwrap_err().contains("negative"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timing_against_sor_reference() {
|
||||
for &n in &[128usize, 256] {
|
||||
let mut pr = assemble(n, n, |_, _| true, [false; 4]);
|
||||
random_rhs(&mut pr, 99);
|
||||
let scale = l1_active(&pr, &pr.rhs);
|
||||
let tol = 1e-8 * scale;
|
||||
let anchor = pr.index(1, 1);
|
||||
|
||||
let mut p = vec![0.0; n * n];
|
||||
let t0 = std::time::Instant::now();
|
||||
let sol = solve_multigrid_pcg(
|
||||
&pr,
|
||||
&mut p,
|
||||
&MultigridParameters::default(),
|
||||
tol,
|
||||
Some(anchor),
|
||||
);
|
||||
let t_mg = t0.elapsed().as_secs_f64();
|
||||
assert!(sol.converged, "{sol:?}");
|
||||
|
||||
let mut ps = vec![0.0; n * n];
|
||||
let t0 = std::time::Instant::now();
|
||||
let (sweeps, res_sor) = sor_reference(&pr, &mut ps, tol, 20_000, None);
|
||||
let t_sor = t0.elapsed().as_secs_f64();
|
||||
println!(
|
||||
"{n}^2 Neumann, stop {tol:.2e}: MG-PCG {} it, {:.3e} res, {t_mg:.3} s | free SOR {sweeps} sweeps, {res_sor:.3e} res, {t_sor:.3} s | SOR/MG time ratio {:.1}",
|
||||
sol.iterations,
|
||||
sol.residual,
|
||||
t_sor / t_mg
|
||||
);
|
||||
assert!(
|
||||
res_sor < tol,
|
||||
"free SOR did not reach the stop in {sweeps} sweeps"
|
||||
);
|
||||
// Same residual stop, so the two solutions agree to the stop
|
||||
// level once both are shifted to the anchor.
|
||||
let shift = ps[anchor];
|
||||
for &i in &active_indices(&pr) {
|
||||
ps[i] -= shift;
|
||||
}
|
||||
let diff = max_abs_diff(&pr, &p, &ps);
|
||||
println!("{n}^2: max |p_mg - p_sor| = {diff:.3e}");
|
||||
|
||||
// The production-style anchored SOR, capped: reported, not timed
|
||||
// to the stop (on 128^2 it does not reach it in 20,000 sweeps).
|
||||
let cap = 3_000;
|
||||
let mut pa = vec![0.0; n * n];
|
||||
let t0 = std::time::Instant::now();
|
||||
let (sweeps_a, res_a) = sor_reference(&pr, &mut pa, tol, cap, Some(anchor));
|
||||
let t_a = t0.elapsed().as_secs_f64();
|
||||
println!(
|
||||
"{n}^2: anchored SOR {sweeps_a} sweeps (cap {cap}), residual {res_a:.3e} vs stop {tol:.2e}, {t_a:.3} s"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-component domains (from the adversarial review)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Two pure-Neumann components (a 64² box split by an inactive wall column)
|
||||
/// whose right-hand sides are each slightly incompatible with OPPOSITE
|
||||
/// signs, so the global mean is zero: a single global mean projection
|
||||
/// leaves both blocks inconsistent and CG diverged (review: max |p| 3.8e9
|
||||
/// at 1e-8 relative imbalance). Per-component projection must converge and
|
||||
/// recover the known field in each component up to that component's
|
||||
/// constant.
|
||||
#[test]
|
||||
fn two_neumann_components_with_opposite_imbalances_converge() {
|
||||
let n = 64;
|
||||
let wall = n / 2;
|
||||
let mut pr = assemble(n, n, |_, i| i != wall, [false; 4]);
|
||||
let exact = smooth_field(n, n);
|
||||
let rhs = apply_operator(&pr, &exact);
|
||||
// Per-component imbalance ±eps × scale, zero overall.
|
||||
let scale = l1_active(&pr, &rhs) / active_indices(&pr).len() as f64;
|
||||
for j in 0..n {
|
||||
for i in 0..n {
|
||||
let idx = j * n + i;
|
||||
if !pr.active[idx] {
|
||||
continue;
|
||||
}
|
||||
let sign = if i < wall { 1.0 } else { -1.0 };
|
||||
pr.rhs[idx] = rhs[idx] + sign * 1e-6 * scale;
|
||||
}
|
||||
}
|
||||
let mut p = vec![0.0; n * n];
|
||||
let tolerance = 1e-10 * l1_active(&pr, &pr.rhs);
|
||||
let sol = solve_multigrid_pcg(
|
||||
&pr,
|
||||
&mut p,
|
||||
&MultigridParameters::default(),
|
||||
tolerance,
|
||||
Some(n + 1),
|
||||
);
|
||||
assert!(
|
||||
sol.converged,
|
||||
"PCG did not converge on two imbalanced Neumann components: {sol:?}"
|
||||
);
|
||||
assert!(sol.iterations <= 20, "iterations {}", sol.iterations);
|
||||
// Each component matches the field up to its own constant.
|
||||
for half in 0..2 {
|
||||
let members: Vec<usize> = active_indices(&pr)
|
||||
.into_iter()
|
||||
.filter(|&idx| (idx % n < wall) == (half == 0))
|
||||
.collect();
|
||||
let shift =
|
||||
members.iter().map(|&idx| p[idx] - exact[idx]).sum::<f64>() / members.len() as f64;
|
||||
let err = members
|
||||
.iter()
|
||||
.map(|&idx| (p[idx] - exact[idx] - shift).abs())
|
||||
.fold(0.0, f64::max);
|
||||
let amp = members
|
||||
.iter()
|
||||
.map(|&idx| exact[idx].abs())
|
||||
.fold(0.0, f64::max);
|
||||
assert!(
|
||||
err < 1e-5 * amp,
|
||||
"component {half}: max error {err:.3e} vs amplitude {amp:.3e}"
|
||||
);
|
||||
}
|
||||
// Anchor semantics hold in the anchor's component; the other is mean zero.
|
||||
assert_eq!(p[n + 1], 0.0);
|
||||
let right: Vec<usize> = active_indices(&pr)
|
||||
.into_iter()
|
||||
.filter(|&idx| idx % n > wall)
|
||||
.collect();
|
||||
let right_mean = right.iter().map(|&idx| p[idx]).sum::<f64>() / right.len() as f64;
|
||||
assert!(
|
||||
right_mean.abs() < 1e-12,
|
||||
"right component mean {right_mean:.3e}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A Dirichlet component next to a singular one: an imbalance in the
|
||||
/// Neumann half must not corrupt the Dirichlet half (review: the Dirichlet
|
||||
/// half read 3.3e3 against an exact 10.0 under a global treatment).
|
||||
#[test]
|
||||
fn dirichlet_component_is_untouched_by_an_imbalanced_neumann_neighbour() {
|
||||
let n = 48;
|
||||
let wall = n / 2;
|
||||
// Left side Dirichlet (p = 0 half a cell outside the left edge), wall
|
||||
// column inactive, right half pure Neumann.
|
||||
let mut pr = assemble(n, n, |_, i| i != wall, [true, false, false, false]);
|
||||
// Exact: left half p = 10 + the discrete solution of ap p = rhs with
|
||||
// rhs chosen from a known field; simplest: take a known field on both
|
||||
// halves and build rhs = A field, then perturb the right half only.
|
||||
let exact = smooth_field(n, n);
|
||||
let rhs = apply_operator(&pr, &exact);
|
||||
let scale = l1_active(&pr, &rhs) / active_indices(&pr).len() as f64;
|
||||
for j in 0..n {
|
||||
for i in 0..n {
|
||||
let idx = j * n + i;
|
||||
if pr.active[idx] {
|
||||
pr.rhs[idx] = rhs[idx] + if i > wall { 1e-6 * scale } else { 0.0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut p = vec![0.0; n * n];
|
||||
let tolerance = 1e-10 * l1_active(&pr, &pr.rhs);
|
||||
let sol = solve_multigrid_pcg(
|
||||
&pr,
|
||||
&mut p,
|
||||
&MultigridParameters::default(),
|
||||
tolerance,
|
||||
None,
|
||||
);
|
||||
assert!(sol.converged, "{sol:?}");
|
||||
// Left (Dirichlet) component exact — no constant freedom there.
|
||||
let left: Vec<usize> = active_indices(&pr)
|
||||
.into_iter()
|
||||
.filter(|&idx| idx % n < wall)
|
||||
.collect();
|
||||
let err = left
|
||||
.iter()
|
||||
.map(|&idx| (p[idx] - exact[idx]).abs())
|
||||
.fold(0.0, f64::max);
|
||||
let amp = left.iter().map(|&idx| exact[idx].abs()).fold(0.0, f64::max);
|
||||
assert!(
|
||||
err < 1e-8 * amp,
|
||||
"Dirichlet half corrupted: max error {err:.3e} vs {amp:.3e}"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user