include/pops/numerics/elliptic/polar/polar_poisson_solver.hpp Source FileΒΆ

adc_cpp: include/pops/numerics/elliptic/polar/polar_poisson_solver.hpp Source File
adc_cpp 0.3.0
Model-free C++23 core for coupled hyperbolic-elliptic systems on adaptive (AMR) meshes, with MPI and GPU (Kokkos) backends
polar_poisson_solver.hpp
Go to the documentation of this file.
1#pragma once
2
7#include <pops/mesh/geometry/geometry.hpp> // PolarGeometry
10#include <pops/numerics/elliptic/poisson/poisson_fft.hpp> // fft1d (1D DFT/FFT reused verbatim)
12
13#include <cmath>
14#include <complex>
15#include <concepts>
16#include <stdexcept>
17#include <vector>
18
80
81namespace pops {
82
83// Contract of POLAR elliptic solvers: same shape as EllipticSolver (elliptic_solver.hpp)
84// but geom() returns a PolarGeometry (annulus (r, theta)) instead of a Cartesian Geometry. Polar
85// counterpart of the Cartesian concept: a future polar coupler (Phase 2b) would depend on this CONCEPT, not
86// on PolarPoissonSolver directly, exactly as Coupler depends on EllipticSolver. Non-intrusive
87// sibling: no change to elliptic_solver.hpp nor to the Cartesian path.
88template <class S>
89concept PolarEllipticSolver = requires(S s) {
90 { s.rhs() } -> std::same_as<MultiFab&>;
91 { s.phi() } -> std::same_as<MultiFab&>;
92 s.solve();
93 { s.residual() } -> std::convertible_to<Real>;
94 { s.geom() } -> std::convertible_to<const PolarGeometry&>;
95};
96
98 public:
103 PolarPoissonSolver(const PolarGeometry& geom, const BoxArray& ba, const BCRec& bc = BCRec{})
104 : geom_(geom), bc_(bc), dm_(ba.size(), n_ranks()), phi_(ba, dm_, 1, 0), rhs_(ba, dm_, 1, 0) {
105 if (n_ranks() != 1)
106 throw std::runtime_error(
107 "PolarPoissonSolver: unsupported under MPI (n_ranks>1); FFT-in-theta + tridiag-in-r "
108 "requires the full theta row + radial column on one rank (parallel transpose = out of "
109 "scope for Phase 2a)");
110 if (ba.size() != 1)
111 throw std::runtime_error(
112 "PolarPoissonSolver: single box required (ba.size()==1) covering the whole annulus");
113 }
114
115 MultiFab& rhs() { return rhs_; }
116 MultiFab& phi() { return phi_; }
117 const PolarGeometry& geom() const { return geom_; }
118
121 void solve() {
122 if (phi_.local_size() == 0)
123 return; // rank with no local box (MPI): nothing to do (cf. guard)
124 // HOST/DEVICE COHERENCE: solve() is a HOST algorithm (std::vector / std::complex / fft1d /
125 // Thomas) that reads the RHS via host pointers (f = rhs_.fab(0).const_array() below). The RHS
126 // may have been filled by a device kernel (for_each_cell) possibly still in flight. We make the
127 // host residency of the RHS valid BEFORE any host read, exactly like the other host readers
128 // of the repo (cf. the sync_host()/device_fence() seam of for_each.hpp / MultiFab). Under Kokkos
129 // Cuda = a targeted device_fence() (otherwise: stale data -> cudaErrorIllegalInstruction). Under
130 // Kokkos Serial/OpenMP (host execution, unified memory) = a fence with no observable effect (no device
131 // kernel in flight to drain).
132 rhs_.sync_host();
133 const int nr = geom_.domain.nx();
134 const int nth = geom_.domain.ny();
135 const Real dr = geom_.dr(); // theta is SPECTRAL (eigenvalue -k^2): dtheta does not appear
136 const Box2D v = rhs_.box(0); // valid cells: i in [v.lo[0]..], j in [v.lo[1]..]
137 const ConstArray4 f = rhs_.fab(0).const_array();
138 Array4 p = phi_.fab(0).array();
139
140 // --- 1) FFT in theta, radial row by radial row: f(i, .) -> f_hat(i, m) ---
141 // We store per radial row i a vector of ntheta complex values (the azimuthal spectrum of the row).
142 std::vector<std::vector<cplx>> fhat(static_cast<std::size_t>(nr));
143 for (int i = 0; i < nr; ++i) {
144 std::vector<cplx>& row = fhat[static_cast<std::size_t>(i)];
145 row.resize(static_cast<std::size_t>(nth));
146 for (int j = 0; j < nth; ++j)
147 row[static_cast<std::size_t>(j)] = cplx(f(v.lo[0] + i, v.lo[1] + j), 0.0);
148 fft1d(row.data(), nth, /*inv=*/false);
149 }
150
151 // --- 2) Radial coefficients INDEPENDENT of the mode (pure geometry) ---
152 // a_i (sub-diag), c_i (super-diag), and the radial part of the diagonal d_rad_i = -(a_i + c_i).
153 std::vector<Real> a(static_cast<std::size_t>(nr)), c(static_cast<std::size_t>(nr)),
154 d_rad(static_cast<std::size_t>(nr)), inv_r2(static_cast<std::size_t>(nr));
155 for (int i = 0; i < nr; ++i) {
156 const Real ri = geom_.r_cell(i); // r at the center of cell i (> 0)
157 const Real rm = geom_.r_face(i); // r_{i-1/2} (low face)
158 const Real rp = geom_.r_face(i + 1); // r_{i+1/2} (high face)
159 const Real inv_r_dr2 = Real(1) / (ri * dr * dr);
160 a[static_cast<std::size_t>(i)] = rm * inv_r_dr2;
161 c[static_cast<std::size_t>(i)] = rp * inv_r_dr2;
162 d_rad[static_cast<std::size_t>(i)] =
163 -(a[static_cast<std::size_t>(i)] + c[static_cast<std::size_t>(i)]);
164 inv_r2[static_cast<std::size_t>(i)] = Real(1) / (ri * ri);
165 }
166
167 // Radial BC: Dirichlet (face) -> reflection ghost = 2 v - interior; Foextrap -> homogeneous Neumann
168 // (ghost = interior). We fold the boundary coefficient into the diagonal and, for Dirichlet, we
169 // inject 2 a/c * value into the right-hand side (mode m = 0 ONLY: the BC is real and constant
170 // in theta, so it only contributes to the mean azimuthal mode).
171 const bool dir_lo = bc_.xlo == BCType::Dirichlet;
172 const bool dir_hi = bc_.xhi == BCType::Dirichlet;
173 const Real vlo = bc_.xlo_val, vhi = bc_.xhi_val;
174 const bool neumann_both = !dir_lo && !dir_hi; // gauge to fix for mode m = 0
175
176 // --- 3) One tridiagonal solve (Thomas) per mode m, on the complex vector phi_hat(., m) ---
177 // The system is REAL (a, b, c real); we solve the real and imaginary parts with the SAME
178 // matrix (Thomas on complex values: a/b/c real, complex right-hand side). phat[i][m] receives the
179 // radial spectrum of mode m. We loop m on the outside (matrix fixed for this m), i on the inside.
180 std::vector<std::vector<cplx>> phat(static_cast<std::size_t>(nr));
181 for (int i = 0; i < nr; ++i)
182 phat[static_cast<std::size_t>(i)].resize(static_cast<std::size_t>(nth));
183
184 std::vector<cplx> rhs_m(static_cast<std::size_t>(nr)), sol_m(static_cast<std::size_t>(nr));
185 std::vector<Real> bdiag(static_cast<std::size_t>(nr));
186 for (int m = 0; m < nth; ++m) {
187 // SIGNED wavenumber (DFT aliasing): k = m for m <= nth/2, otherwise m - nth. SPECTRAL
188 // eigenvalue of d_theta^2 = -k^2 (exact, not the 2-point stencil). dtheta does not appear here.
189 const int kw = (m <= nth / 2) ? m : m - nth;
190 const Real lam_th = -static_cast<Real>(kw) * static_cast<Real>(kw);
191 // Full diagonal of mode m: radial + azimuthal (lam_th/r_i^2 = -k^2/r_i^2).
192 for (int i = 0; i < nr; ++i)
193 bdiag[static_cast<std::size_t>(i)] =
194 d_rad[static_cast<std::size_t>(i)] + lam_th * inv_r2[static_cast<std::size_t>(i)];
195 for (int i = 0; i < nr; ++i)
196 rhs_m[static_cast<std::size_t>(i)] =
197 fhat[static_cast<std::size_t>(i)][static_cast<std::size_t>(m)];
198
199 // Fold the radial BCs into the diagonal / right-hand side (of mode m).
200 // r_min (i = 0): ghost phi_{-1}. Dirichlet -> phi_{-1} = 2 vlo - phi_0: b_0 -= a_0,
201 // rhs_0 -= 2 a_0 vlo (mode 0). Neumann -> phi_{-1} = phi_0: b_0 += a_0.
202 if (dir_lo) {
203 bdiag[0] -= a[0];
204 if (m == 0)
205 rhs_m[0] -= cplx(Real(2) * a[0] * vlo * static_cast<Real>(nth), 0.0);
206 } else {
207 bdiag[0] += a[0];
208 }
209 // r_max (i = nr-1): ghost phi_{nr}. Dirichlet -> phi_{nr} = 2 vhi - phi_{nr-1}, Neumann -> = phi_{nr-1}.
210 const std::size_t last = static_cast<std::size_t>(nr - 1);
211 if (dir_hi) {
212 bdiag[last] -= c[last];
213 if (m == 0)
214 rhs_m[last] -= cplx(Real(2) * c[last] * vhi * static_cast<Real>(nth), 0.0);
215 } else {
216 bdiag[last] += c[last];
217 }
218
219 // Mode m = 0 with TWO Neumann boundaries: singular radial operator (kernel = constant). We pin
220 // phi_hat(0, 0) = 0 (gauge: zero radial mean of the constant mode) by forcing the first row to
221 // the identity (diag 1, super-diag 0, rhs 0). Without this Thomas divides by a null pivot.
222 const bool pin0 = neumann_both && m == 0;
223
224 thomas_solve(a, bdiag, c, rhs_m, sol_m, nr, pin0);
225 for (int i = 0; i < nr; ++i)
226 phat[static_cast<std::size_t>(i)][static_cast<std::size_t>(m)] =
227 sol_m[static_cast<std::size_t>(i)];
228 }
229
230 // --- 4) Inverse FFT in theta: phi_hat(i, m) -> phi(i, theta) (the real part, the imaginary part ~ round-off) ---
231 for (int i = 0; i < nr; ++i) {
232 std::vector<cplx>& row = phat[static_cast<std::size_t>(i)];
233 fft1d(row.data(), nth, /*inv=*/true);
234 for (int j = 0; j < nth; ++j)
235 p(v.lo[0] + i, v.lo[1] + j) = row[static_cast<std::size_t>(j)].real();
236 }
237 }
238
247 if (phi_.local_size() == 0)
248 return static_cast<Real>(all_reduce_max(0.0));
249 // Like solve(): HOST evaluation (FFT + stencil on host pointers). We make the host residency
250 // of the RHS valid before reading (device kernel possibly in flight). phi_ is written host-side by
251 // solve(), but we also fence for symmetry/robustness if residual() is called independently. Under
252 // Kokkos Serial/OpenMP (host execution) = a fence with no observable effect; under Kokkos Cuda = targeted device_fence().
253 rhs_.sync_host();
254 phi_.sync_host();
255 const int nr = geom_.domain.nx();
256 const int nth = geom_.domain.ny();
257 const Real dr = geom_.dr();
258 const Box2D v = rhs_.box(0);
259 const ConstArray4 p = phi_.fab(0).const_array();
260 const ConstArray4 f = rhs_.fab(0).const_array();
261
262 const bool dir_lo = bc_.xlo == BCType::Dirichlet;
263 const bool dir_hi = bc_.xhi == BCType::Dirichlet;
264 const bool neumann_both = !dir_lo && !dir_hi;
265
266 // Radial coefficients (same as solve()).
267 std::vector<Real> a(static_cast<std::size_t>(nr)), c(static_cast<std::size_t>(nr)),
268 d_rad(static_cast<std::size_t>(nr)), inv_r2(static_cast<std::size_t>(nr));
269 for (int i = 0; i < nr; ++i) {
270 const Real ri = geom_.r_cell(i);
271 const Real inv_r_dr2 = Real(1) / (ri * dr * dr);
272 a[static_cast<std::size_t>(i)] = geom_.r_face(i) * inv_r_dr2;
273 c[static_cast<std::size_t>(i)] = geom_.r_face(i + 1) * inv_r_dr2;
274 d_rad[static_cast<std::size_t>(i)] =
275 -(a[static_cast<std::size_t>(i)] + c[static_cast<std::size_t>(i)]);
276 inv_r2[static_cast<std::size_t>(i)] = Real(1) / (ri * ri);
277 }
278
279 // FFT of phi and of f, radial row by radial row.
280 std::vector<std::vector<cplx>> ph(static_cast<std::size_t>(nr)),
281 fh(static_cast<std::size_t>(nr));
282 for (int i = 0; i < nr; ++i) {
283 ph[static_cast<std::size_t>(i)].resize(static_cast<std::size_t>(nth));
284 fh[static_cast<std::size_t>(i)].resize(static_cast<std::size_t>(nth));
285 for (int j = 0; j < nth; ++j) {
286 ph[static_cast<std::size_t>(i)][static_cast<std::size_t>(j)] =
287 cplx(p(v.lo[0] + i, v.lo[1] + j), 0.0);
288 fh[static_cast<std::size_t>(i)][static_cast<std::size_t>(j)] =
289 cplx(f(v.lo[0] + i, v.lo[1] + j), 0.0);
290 }
291 fft1d(ph[static_cast<std::size_t>(i)].data(), nth, false);
292 fft1d(fh[static_cast<std::size_t>(i)].data(), nth, false);
293 }
294
295 Real rmax = 0;
296 for (int m = 0; m < nth; ++m) {
297 const int kw = (m <= nth / 2) ? m : m - nth;
298 const Real lam_th = -static_cast<Real>(kw) * static_cast<Real>(kw);
299 const bool pin0 =
300 neumann_both && m == 0; // gauge mode: row 0 pinned (excluded from the residual)
301 for (int i = 0; i < nr; ++i) {
302 if (pin0 && i == 0)
303 continue; // pinned identity row: not a physical equation
304 const std::size_t I = static_cast<std::size_t>(i);
305 Real bdiag = d_rad[I] + lam_th * inv_r2[I];
306 cplx lhs(0.0, 0.0);
307 cplx rm = fh[I][static_cast<std::size_t>(m)];
308 // Low boundary (i==0): fold a_0 (Dirichlet: b-=a, rhs-=2 a vlo*nth at m=0; Neumann: b+=a).
309 if (i == 0) {
310 if (dir_lo) {
311 bdiag -= a[0];
312 if (m == 0)
313 rm -= cplx(Real(2) * a[0] * bc_.xlo_val * static_cast<Real>(nth), 0.0);
314 } else {
315 bdiag += a[0];
316 }
317 } else {
318 lhs += cplx(a[I], 0.0) * ph[static_cast<std::size_t>(i - 1)][static_cast<std::size_t>(m)];
319 }
320 // High boundary (i==nr-1): fold c.
321 if (i == nr - 1) {
322 if (dir_hi) {
323 bdiag -= c[I];
324 if (m == 0)
325 rm -= cplx(Real(2) * c[I] * bc_.xhi_val * static_cast<Real>(nth), 0.0);
326 } else {
327 bdiag += c[I];
328 }
329 } else {
330 lhs += cplx(c[I], 0.0) * ph[static_cast<std::size_t>(i + 1)][static_cast<std::size_t>(m)];
331 }
332 lhs += cplx(bdiag, 0.0) * ph[I][static_cast<std::size_t>(m)];
333 // Residual of this mode (normalized by nth: the unnormalized DFT scales f_hat by nth; we
334 // compare at the SAME scale, so the ratio is independent of the convention).
335 const cplx r_m = (lhs - rm) / static_cast<double>(nth);
336 rmax = std::max(rmax, static_cast<Real>(std::abs(r_m)));
337 }
338 }
339 return static_cast<Real>(all_reduce_max(static_cast<double>(rmax)));
340 }
341
342 private:
343 // Thomas algorithm (tridiagonal elimination) on matrices with REAL coefficients a/b/c and a
344 // COMPLEX right-hand side. a[i] = sub-diag (couples i-1), b[i] = diag, c[i] = super-diag (couples i+1).
345 // a[0] and c[n-1] are NOT used (boundaries). @p pin0: if true, pins x[0] = 0 (gauge for mode 0,
346 // double Neumann) by replacing the first row with the identity (b_0 = 1, c_0 = 0, rhs_0 = 0). We
347 // work on LOCAL copies of the diagonal / super-diagonal / rhs so as not to alter the caller's
348 // arrays. No pivoting: the matrix is diagonally dominant (azimuthal term <= 0,
349 // folded BCs) -> Thomas stable without permutation.
350 void thomas_solve(const std::vector<Real>& a, const std::vector<Real>& b,
351 const std::vector<Real>& c, const std::vector<cplx>& rhs, std::vector<cplx>& x,
352 int n, bool pin0) const {
353 const std::size_t N = static_cast<std::size_t>(n);
354 bloc_.assign(N, 0.0); // working diagonal
355 cloc_.assign(N, 0.0); // working super-diagonal
356 rloc_.assign(N, cplx(0.0, 0.0)); // working right-hand side
357 for (std::size_t i = 0; i < N; ++i) {
358 bloc_[i] = b[i];
359 cloc_[i] = c[i];
360 rloc_[i] = rhs[i];
361 }
362 if (pin0) { // row 0 -> identity: x[0] = 0, decoupled from the rest
363 bloc_[0] = Real(1);
364 cloc_[0] = Real(0);
365 rloc_[0] = cplx(0.0, 0.0);
366 }
367 cgamma_.assign(N, cplx(0.0, 0.0));
368 Real beta = bloc_[0];
369 x[0] = rloc_[0] / cplx(beta, 0.0);
370 for (std::size_t i = 1; i < N; ++i) {
371 cgamma_[i] = cplx(cloc_[i - 1] / beta, 0.0);
372 beta = bloc_[i] - a[i] * cloc_[i - 1] / beta;
373 x[i] = (rloc_[i] - cplx(a[i], 0.0) * x[i - 1]) / cplx(beta, 0.0);
374 }
375 for (int i = n - 2; i >= 0; --i)
376 x[static_cast<std::size_t>(i)] -=
377 cgamma_[static_cast<std::size_t>(i + 1)] * x[static_cast<std::size_t>(i + 1)];
378 }
379
380 PolarGeometry geom_;
381 BCRec bc_;
382 DistributionMapping dm_;
383 MultiFab phi_, rhs_;
384 // Buffers reused by Thomas (avoid per-mode allocations). mutable: thomas_solve is const.
385 mutable std::vector<cplx> cgamma_, rloc_;
386 mutable std::vector<Real> bloc_, cloc_;
387};
388
389static_assert(PolarEllipticSolver<PolarPoissonSolver>,
390 "PolarPoissonSolver must model PolarEllipticSolver");
391
392} // namespace pops
BoxArray: the set of boxes tiling a level (disjoint, covering).
Ordered list of boxes tiling a level.
Definition box_array.hpp:22
int size() const
Number of boxes in the tiling.
Definition box_array.hpp:42
ConstArray4 const_array() const
READ handle (POD device-copyable) over this Fab. Valid as long as the Fab lives.
Definition fab2d.hpp:96
Array4 array()
WRITE handle (POD device-copyable) over this Fab. Valid as long as the Fab lives.
Definition fab2d.hpp:91
Field distributed over a level: decomposition (BoxArray) + distribution (DistributionMapping) + ncomp...
Definition multifab.hpp:33
Fab2D & fab(int li)
Local fab at index li (0 <= li < local_size()), for writing.
Definition multifab.hpp:67
void sync_host()
Makes the HOST residence valid (before a host access: operator(), loop, set_val).
Definition multifab.hpp:79
const Box2D & box(int li) const
VALID box of local fab li.
Definition multifab.hpp:71
int local_size() const
Number of fabs OWNED by this rank (bound on local indices).
Definition multifab.hpp:65
Definition polar_poisson_solver.hpp:97
Real residual()
Discrete residual ||L_h phi - rhs|| (inf norm), reduced over the ranks.
Definition polar_poisson_solver.hpp:246
MultiFab & rhs()
Definition polar_poisson_solver.hpp:115
const PolarGeometry & geom() const
Definition polar_poisson_solver.hpp:117
MultiFab & phi()
Definition polar_poisson_solver.hpp:116
void solve()
Solves (1/r) d_r(r d_r phi) + (1/r^2) d_theta^2 phi = rhs in place, by FFT-in-theta then a tridiagona...
Definition polar_poisson_solver.hpp:121
PolarPoissonSolver(const PolarGeometry &geom, const BoxArray &ba, const BCRec &bc=BCRec{})
Definition polar_poisson_solver.hpp:103
Parallel seam: minimal MPI abstraction (rank/size + collectives) with serial fallback.
Definition polar_poisson_solver.hpp:89
DistributionMapping: maps each box (by global index) to its owning MPI rank.
Fab2D: single-grid data on a Box2D (in-house equivalent of AMReX's FArrayBox); Array4 / ConstArray4: ...
Geometry: index-space (Box2D) <-> Cartesian physical-space mapping; PolarGeometry: SIBLING for a glob...
MultiFab: a field DISTRIBUTED over a level (equivalent of AMReX's MultiFab).
Definition amr_hierarchy.hpp:29
std::complex< double > cplx
Definition poisson_fft.hpp:40
double Real
Definition types.hpp:30
int n_ranks()
Definition comm.hpp:139
void fft1d(cplx *a, int n, bool inv)
Definition poisson_fft.hpp:70
double all_reduce_max(double x)
Definition comm.hpp:146
PHYSICAL boundary conditions at the domain edge (BCType, BCRec, fill_physical_bc, fill_ghosts).
Low-level FFT brick: PoissonFFT (spectral periodic Poisson solver, slab-distributed) plus 1D radix-2 ...
WRITE POD handle (raw pointer + strides) over a Fab2D buffer, indexed by (i, j, c) IN GLOBAL INDICES ...
Definition fab2d.hpp:29
Boundary conditions for the FOUR faces of the domain (type + associated Dirichlet value).
Definition physical_bc.hpp:29
BCType xlo
Definition physical_bc.hpp:30
Real xlo_val
Definition physical_bc.hpp:32
Real xhi_val
Definition physical_bc.hpp:32
BCType xhi
Definition physical_bc.hpp:30
2D integer index space, cell-centered.
Definition box2d.hpp:37
POPS_HD int nx() const
Width (direction 0). POPS_HD (called from Geometry::dx() in a device kernel).
Definition box2d.hpp:50
POPS_HD int ny() const
Height (direction 1). POPS_HD (called from Geometry::dy() in a device kernel).
Definition box2d.hpp:52
int lo[2]
Definition box2d.hpp:38
READ-only handle (const counterpart of Array4): same layout and same contract (POD device-copyable,...
Definition fab2d.hpp:44
Definition geometry.hpp:59
POPS_HD Real r_face(int i) const
Radius at radial FACE i (face between cells i-1 and i; i = 0 -> r_min, i = nr -> r_max).
Definition geometry.hpp:75
POPS_HD Real r_cell(int i) const
Radius at the CENTER of radial cell i (i = 0 -> r_min + dr/2).
Definition geometry.hpp:73
POPS_HD Real dr() const
Definition geometry.hpp:70
Box2D domain
nx() = nr (radial cells), ny() = ntheta (azimuthal cells)
Definition geometry.hpp:60
Base scalar types and the POPS_HD macro (host+device portability).