peclet-amr 0.2.0
Block-local-Morton AMR octree + collocated cut-cell Navier–Stokes solver (header-only C++20, Kokkos + MPI)
Loading...
Searching...
No Matches
ghost_projection_sampled.hpp
Go to the documentation of this file.
1// core — SAMPLED ghost-projection overlay: cut cells at MULTIPLE octree levels.
2//
3// The D1 machinery of docs/amr_mixed_level_cut_band_plan.md: the ghost overlay's ±2
4// same-level chain entries become SAMPLE SLOTS — precomputed linear functionals of nearby
5// fluid leaves. A same-level entry is the identity (today's behaviour, bit-identical on a
6// uniform band); an entry across a 2:1 level boundary is a VIRTUAL SAMPLE at the uniform-grid
7// position, reconstructed by DEGREE-2 least squares over fluid leaf point values (the M2
8// verdict, tests/study_amr_seam_sample_order.cpp: degree-1 is an O(1) non-decaying matrix
9// perturbation at 53% of the physical scale; degree-2 decays ~O(h) to 0.4%). Fallback cascade
10// (fluid-only, M1-measured 0.1–0.5%): LS2 -> LS1 -> covering-leaf identity.
11//
12// CLASSIFICATION CONSISTENCY (the invariant that prevents double-counted fluxes): a face is
13// closed in the overlay iff it is closed in the binary operator. Both derive from the SAME
14// primitive — a (sub)face is open iff BOTH adjacent ACTUAL leaf centers are fluid and the
15// float mean of their center samples is >= 0 (`sampledFaceOpen`; use `makeBinaryOpenFnMixed`
16// for AmrPoisson::buildOpenness / AmrMultigrid::setOpenness). A row's own-face state takes
17// its SIGN from this canonical rule (any-sub-face-open for a finer-across face — the closed
18// sub-faces of a mixed face are then Neumann-zero, a counted Design-A-like local defect) and
19// its MAGNITUDE (theta, closure weights) from the row's virtual uniform-position samples (the
20// plan's D2 route, level-independent — identical to the classic builder on a uniform band).
21//
22// Everything is a pure function of (octree, sdf): deterministic under adaptivity (D6).
23//
24// LAYOUT. The builder and the `*Host` appliers are plain host C++ — they are the oracle path
25// (flow_oracle.hpp `setGhostSampled`) and they also BUILD the weights the device consumes, so
26// oracle==device parity holds by construction rather than by a second implementation. The
27// device mirror (`GhostOverlaySampledDev`, `ghostApplyDeltaSampled`, `ghostDivergDeltaSampled`,
28// `GhostGradCsrDev`) lives in the `#ifdef KOKKOS_INLINE_FUNCTION` section at the bottom, so a
29// non-Kokkos TU can include this header and get the host half only. Driven from the device
30// solver by `AmrFlow::setGhostSampled` (flow.hpp), single-rank AND distributed.
31//
32// STATUS (2026-08-30, plans §Phase 2 + amr_march_perf_and_distributed_plan.md): Phase-1 rungs 1–4
33// and Phase 2 are done — momentum ξ-row seam correction, wall-aware C/F gates, the device mirror,
34// the graded refinement policy API (refine.hpp `refineToSdfGraded`), family-free on a seamed mesh,
35// and a seam offset converging at ~1.7–1.9 so seams do not set the order. DISTRIBUTED since rungs
36// D0–D2: the cloud candidates are a deterministic `probeSlot` set (never a search over the local
37// leaf array — that would make a cloud depend on what this rank owns), the builder probes inside
38// prepareDistributed's discovery fixpoint, and the clouds read GHOST slots. Two frames meet here
39// and coincide only single-rank: geometry is sampled at GLOBAL world points, `probeSlot` takes
40// BLOCK-LOCAL fine coords — `frameShift`/`globalFine` carry that, and every default reduces the
41// expressions to their single-rank form bit for bit. Anything reached through a probe is an
42// EXTENDED slot: use `pres.levelOf`/`loOf`, never `t.level`/`t.bounds`, or a ghost reads past the
43// end of the local arrays.
44// KNOWN GAPS, in the plan's risk register: sub-face closures are unimplemented (the closed
45// sub-faces of a mixed face are Neumann-zero — counted by `nMixedFace`, measured non-vanishing)
46// and pocket cells are not excluded from LS clouds.
47#ifndef PECLET_AMR_GHOST_PROJECTION_SAMPLED_HPP
48#define PECLET_AMR_GHOST_PROJECTION_SAMPLED_HPP
49
50#include <algorithm>
51#include <array>
52#include <cmath>
53#include <cstdint>
54#include <cstdio>
55#include <memory>
56#include <stdexcept>
57#include <utility>
58#include <vector>
59
61#include "peclet/amr/cf_scheme.hpp" // CfCsr + detail::{ScalarEnt, compactCsr} (mom seam delta)
62#include "peclet/amr/common.hpp"
65#include "peclet/core/common/host_parallel.hpp"
66#include "peclet/core/common/types.hpp"
67#include "peclet/core/scheme/ghost_closure.hpp"
68
69namespace peclet::amr {
70
76 std::vector<Index> sampStart;
77 std::vector<Index> sampIdx;
78 std::vector<double> sampW;
79 std::vector<int8_t> sampFluid;
80 std::vector<Index> rowOf;
81 // build census (printed by the builder):
82 long nIdentity = 0, nLS2 = 0, nLS1 = 0, nDegraded = 0, nSolidSlot = 0, nMixedFace = 0,
84};
85
86namespace detail {
87
89inline bool gpsSolveDense(int n, double* A, double* b) {
90 for (int k = 0; k < n; ++k) {
91 int piv = k;
92 for (int r = k + 1; r < n; ++r)
93 if (std::fabs(A[r * n + k]) > std::fabs(A[piv * n + k]))
94 piv = r;
95 if (std::fabs(A[piv * n + k]) < 1e-14)
96 return false;
97 if (piv != k) {
98 for (int c = k; c < n; ++c) {
99 const double tmp = A[k * n + c];
100 A[k * n + c] = A[piv * n + c];
101 A[piv * n + c] = tmp;
102 }
103 const double tb = b[k];
104 b[k] = b[piv];
105 b[piv] = tb;
106 }
107 for (int r = k + 1; r < n; ++r) {
108 const double f = A[r * n + k] / A[k * n + k];
109 for (int c = k; c < n; ++c)
110 A[r * n + c] -= f * A[k * n + c];
111 b[r] -= f * b[k];
112 }
113 }
114 for (int k = n - 1; k >= 0; --k) {
115 for (int c = k + 1; c < n; ++c)
116 b[k] -= A[k * n + c] * b[c];
117 b[k] /= A[k * n + k];
118 }
119 return true;
120}
121
147template <unsigned Bits, class WrapFn, class EmitFn>
148inline void forEachCoveringSlot(const AmrPoisson<3, Bits>& pres, const long lo[3], const long hi[3],
149 WrapFn&& wrapLocal, EmitFn&& emit, bool descendPending = false) {
150 struct Box {
151 long lo[3], hi[3];
152 };
153 constexpr int kMaxDepth = 192;
155 int top = 0;
156 for (int d = 0; d < 3; ++d) {
157 stack[0].lo[d] = lo[d];
158 stack[0].hi[d] = hi[d];
159 }
160 ++top;
161 while (top > 0) {
162 const Box r = stack[--top];
163 const std::array<long, 3> q{r.lo[0], r.lo[1], r.lo[2]};
164 const auto [slot, L] = pres.probeSlot(q);
165 if (slot < 0) {
166 emit(slot);
167 if (descendPending) {
168 // Discovery: probe an ALIGNED lattice inside the pending region instead of splitting it.
169 // Stride = a power of two chosen so the lattice is at most ~9 points per axis, floored at
170 // 4: a leaf of size >= stride is stride-aligned (octree) and so is caught this round;
171 // finer leaves surface next round as pending sub-regions a stride smaller — the unknown
172 // shrinks geometrically, so the fixpoint needs O(log span) CHEAP rounds. Both properties
173 // were learnt the hard way on the lmax=6 seam mesh: a recursive blind split's terminal
174 // corners inherit each box's own offset and defeat the miss map's dedup (560184 distinct
175 // pending coords, 8.5 s round 1), and a FIXED stride of 4 turns a coarse background
176 // row's cloud — rho = 2.2·h(L) is domain-sized for a level-6 row — into a lattice over
177 // the whole remote domain (263876 coords). Power-of-two alignment makes coords SHARED
178 // between overlapping boxes; blocks are root-aligned, so local alignment IS global.
179 long ext = 0;
180 for (int d = 0; d < 3; ++d)
181 ext = std::max(ext, r.hi[d] - r.lo[d] + 1);
182 int k = 2;
183 while ((1L << (k + 3)) < ext)
184 ++k; // stride*8 >= ext ⇒ <= 9 lattice points per axis
185 const long st = 1L << k;
186 auto alignUp = [k, st](long v) { return ((v + st - 1) >> k) << k; };
187 for (long z = alignUp(r.lo[2]); z <= r.hi[2]; z += st)
188 for (long y = alignUp(r.lo[1]); y <= r.hi[1]; y += st)
189 for (long x = alignUp(r.lo[0]); x <= r.hi[0]; x += st)
190 (void)pres.probeSlot(std::array<long, 3>{x, y, z});
191 }
192 continue;
193 } else {
194 const std::array<long, 3> wq = wrapLocal(q);
195 const std::array<long, 3> llo = pres.loOf(slot);
196 const long s = static_cast<long>(1) << L;
197 bool covers = true;
198 for (int d = 0; d < 3; ++d) {
199 const long u = llo[d] + (r.lo[d] - wq[d]); // the leaf's lo in the probe's frame
200 if (u + s - 1 < r.hi[d]) {
201 covers = false;
202 break;
203 }
204 }
205 if (covers) {
206 emit(slot);
207 continue;
208 }
209 }
210 int ax = 0;
211 for (int d = 1; d < 3; ++d)
212 if (r.hi[d] - r.lo[d] > r.hi[ax] - r.lo[ax])
213 ax = d;
214 // Coarsest 2^k boundary in (lo, hi]: the highest bit where lo and hi differ.
215 unsigned long long x = static_cast<unsigned long long>(r.lo[ax] ^ r.hi[ax]);
216 int k = 0;
217 while (x >>= 1)
218 ++k;
219 const long cut = ((r.lo[ax] >> k) + 1) << k;
220 if (top + 2 > kMaxDepth)
221 throw std::runtime_error("amr::forEachCoveringSlot: descent stack overflow");
222 stack[top] = r;
223 stack[top].hi[ax] = cut - 1;
224 ++top;
225 stack[top] = r;
226 stack[top].lo[ax] = cut;
227 ++top;
228 }
229}
230
231inline void gpsMonomials(const double d[3], int deg, double* m, int& nm) {
232 m[0] = 1.0;
233 m[1] = d[0];
234 m[2] = d[1];
235 m[3] = d[2];
236 nm = 4;
237 if (deg >= 2) {
238 m[4] = d[0] * d[0];
239 m[5] = d[1] * d[1];
240 m[6] = d[2] * d[2];
241 m[7] = d[0] * d[1];
242 m[8] = d[0] * d[2];
243 m[9] = d[1] * d[2];
244 nm = 10;
245 }
246}
247
248} // namespace detail
249
260template <unsigned Bits, class SdfFn>
262 SdfFn sdfFn, const Vec<3>& h0, Vec<3> origin,
263 std::array<long, 3> frameShift = {}) {
264 return [&t, &pres, sdfFn, h0, origin, frameShift](const Vec<3>& fc, int axis) -> double {
265 (void)t;
266 auto centerSample = [&](int side) -> std::pair<bool, float> {
267 Vec<3> probe = fc;
268 probe[axis] += side * 0.25 * h0[axis]; // strictly inside the adjacent leaf (per axis)
269 std::array<long, 3> q{};
270 for (int d = 0; d < 3; ++d)
271 q[d] = static_cast<long>(std::floor((probe[d] - origin[d]) / h0[d])) - frameShift[d];
272 const Index j = pres.probeSlot(q).first;
273 if (j < 0)
274 return {false, -1.0f};
275 const auto lo = pres.loOf(j);
276 const double s = static_cast<double>(Index(1) << pres.levelOf(j));
277 Vec<3> c{};
278 for (int d = 0; d < 3; ++d)
279 c[d] = origin[d] + (static_cast<double>(lo[d] + frameShift[d]) + 0.5 * s) * h0[d];
280 const double sd = sdfFn(c);
281 return {sd > 0.0, static_cast<float>(sd)};
282 };
283 const auto [flM, sM] = centerSample(-1);
284 const auto [flP, sP] = centerSample(+1);
285 return (flM && flP && 0.5f * (sM + sP) >= 0.0f) ? 1.0 : 0.0;
286 };
287}
288
308template <unsigned Bits, class SdfFn>
311 int rhsOrder, Vec<3> origin = Vec<3>{}, const std::array<long, 3>* globalFine = nullptr,
312 std::array<long, 3> frameShift = {}, bool discovery = false, double rhoFactor = 2.2,
313 long maxSamples = 0) {
314 GhostOverlaySampled ov;
315 const Index n = t.numLeaves();
316 // Phase 3 (`docs/amr_anisotropic.md` §5): the ROOT spacing per axis. `pres.h0()` already is
317 // it; recovering it from `cellWidth(0)` (as this line did) only ever worked because the cells
318 // were cubes.
319 const Vec<3> h0 = pres.h0();
320 const auto fe = pres.fineExt();
321 const std::array<long, 3> shiftG = frameShift;
322 const std::array<long, 3> gfine =
324 : std::array<long, 3>{static_cast<long>(fe[0]), static_cast<long>(fe[1]),
325 static_cast<long>(fe[2])};
326 // World position -> block-local fine coord (the probe frame). Single-rank the shift is 0.
327 auto probeCoord = [&](const Vec<3>& p, long q[3]) {
328 for (int d = 0; d < 3; ++d)
329 q[d] = static_cast<long>(std::floor((p[d] - origin[d]) / h0[d])) - shiftG[d];
330 };
331
332 // Leaf centers + fluid flags. Host-parallel (rung 4): per-leaf disjoint writes.
333 std::vector<Vec<3>> cen(static_cast<std::size_t>(n));
334 std::vector<char> fluid(static_cast<std::size_t>(n));
335 hostParFor(n, [&](Index i) {
336 auto b = t.bounds(i);
337 const double s = static_cast<double>(Index(1) << t.level(i));
338 Vec<3> c{};
339 for (int d = 0; d < 3; ++d)
340 c[d] = origin[d] +
341 (static_cast<double>(b[0][d]) + static_cast<double>(shiftG[d]) + 0.5 * s) * h0[d];
342 cen[static_cast<std::size_t>(i)] = c;
343 fluid[static_cast<std::size_t>(i)] = sdf(c) > 0.0 ? 1 : 0;
344 });
345
346 // D2: the same two per-slot facts for the GHOST tail [n, n+nGhost). A ghost's geometry is
347 // recomputed here rather than exchanged: `loOf` is the covering leaf's global anchor and the
348 // level is the owner's, so `sdf(centre)` reproduces the owner's own value bit for bit (the same
349 // argument buildOpenness's ghost rows rest on). Empty single-rank — there are no ghosts.
350 const Index ng = pres.numGhosts();
351 std::vector<Vec<3>> cenG(static_cast<std::size_t>(ng));
352 std::vector<char> fluidG(static_cast<std::size_t>(ng));
353 for (Index g = 0; g < ng; ++g) {
354 const auto lo = pres.loOf(n + g);
355 const double s = static_cast<double>(Index(1) << pres.levelOf(n + g));
356 Vec<3> c{};
357 for (int d = 0; d < 3; ++d)
358 c[d] = origin[d] + (static_cast<double>(lo[d] + shiftG[d]) + 0.5 * s) * h0[d];
359 cenG[static_cast<std::size_t>(g)] = c;
360 fluidG[static_cast<std::size_t>(g)] = sdf(c) > 0.0 ? 1 : 0;
361 }
362 auto centerOf = [&](Index j) -> const Vec<3>& {
363 return j < n ? cen[static_cast<std::size_t>(j)] : cenG[static_cast<std::size_t>(j - n)];
364 };
365 auto fluidOf = [&](Index j) -> char {
366 return j < n ? fluid[static_cast<std::size_t>(j)] : fluidG[static_cast<std::size_t>(j - n)];
367 };
368
369 // ---- DD1 (docs/amr_march_perf_and_distributed_plan.md): the LS cloud candidates are a
370 // DETERMINISTIC PROBE SET, not a search over the local leaf array.
371 //
372 // The set a cloud must contain is "every fluid leaf whose CENTER lies within rho of the sample
373 // position" — a pure function of (octree, geometry). The old hash-bin search enumerated it by
374 // walking THIS RANK's leaf array, which would make the cloud decomposition-DEPENDENT near a
375 // block boundary (D6 forbids that). It is now enumerated by `detail::forEachCoveringSlot`, a
376 // coordinate-space descent through `pres.probeSlot` — the route every other builder already uses
377 // to reach across ranks.
378 //
379 // ORDER is contractual: it fixes the floating-point value of the normal-matrix sums and hence
380 // the weights. The enumeration is therefore re-sorted into the OLD bin traversal order — bins of
381 // 4*h0 in (bx, by, bz) loop order, and within a bin ascending Morton code of the leaf's global
382 // lo corner. That is bit-for-bit the old order (the leaf array is Z-order sorted, so ascending
383 // code == ascending leaf index) and, unlike the leaf index, it is the same under any
384 // decomposition.
385 //
386 // The bins survive DD1 only as that order key, but `domain` below is also the minimum-image
387 // PERIOD of the cloud metric, so it has to be the true one: F2 (§Findings, fixed on main before
388 // this rung) derived it from the max leaf bound, which is INCLUSIVE, and the truncating /4 then
389 // lost a whole bin — 4 fine cells short at every power-of-two domain. It is the octree's fine
390 // extent, the period probeSlot and every other wrap already use. Single-rank the block IS the
391 // domain; distributed (D1) it is `gfine`, the GLOBAL fine extent — a per-rank period would be
392 // F2 all over again, in a decomposition-dependent coat.
393 // AM4 (`docs/amr_anisotropic.md` §5): the ORDER-KEY bins and the minimum-image PERIOD both
394 // become per-axis. `hb[d] = 4*h0[d]` keeps a bin four fine cells wide on every axis (so the
395 // traversal order is the old one on a cubic grid, bit for bit), and the period is the true
396 // world extent of each axis. NOTE the old single `domain` was the LONGEST axis's period, which
397 // is wrong for the shorter axes of a NON-CUBIC brick whenever a cloud straddles the periodic
398 // boundary there — pre-announced in the design note as the one place a bit can legitimately
399 // move, and it moves toward the correct value.
400 double hb[3], domain[3];
401 long nb[3];
402 for (int d = 0; d < 3; ++d) {
403 hb[d] = 4.0 * h0[d];
404 nb[d] = std::max<long>(1, gfine[d] / 4);
405 domain[d] = static_cast<double>(gfine[d]) * h0[d];
406 }
407 const long nbx = std::max(std::max(nb[0], nb[1]), nb[2]); // bin-index stride (order key only)
408 auto wrapLocal = [gfine, shiftG](const std::array<long, 3>& p) {
409 std::array<long, 3> q{};
410 for (int d = 0; d < 3; ++d) {
411 const long g = p[d] + shiftG[d];
412 q[d] = ((g % gfine[d]) + gfine[d]) % gfine[d] - shiftG[d];
413 }
414 return q;
415 };
417 using Code = typename Oct::Code;
418 // The order key: the Morton code of the slot's GLOBAL lo corner. `loOf` serves local leaves and
419 // ghosts alike (a ghost's lo IS its owner's, in this block's frame), so the key of a leaf is the
420 // same number on every rank that can see it — which is what makes the cloud's accumulation order
421 // decomposition-independent (D6) rather than merely deterministic.
422 auto keyOf = [&](Index j) {
423 const auto lo = pres.loOf(j);
424 std::array<typename Oct::Coord, 3> q{};
425 for (int d = 0; d < 3; ++d)
426 q[d] = static_cast<typename Oct::Coord>(lo[d] + shiftG[d]);
427 return Oct::M::encode(q).code();
428 };
429 struct Cand {
430 long bin;
431 Code key;
432 Index slot;
433 double d2;
434 };
435
436 // ---- M2a cloud-economy knobs (both default to the shipped behaviour exactly) -----------------
437 // A degree-2 least squares needs 12 points; the shipped radius gathers 95-162 (M1). The two
438 // arguments let the cloud-economy study vary the sampling without touching production defaults:
439 // rhoFactor radius factor, default 2.2 (rho = factor * max(h, H))
440 // maxSamples keep only the N nearest candidates, default 0 = no cap
441 // (AmrFlow::setGhostSampled(on, rho, maxSamples) / Python set_ghost_sampled(on, rho=,
442 // max_samples=); until 2026-09-10 these were the PECLET_CORE_GPS_RHO / _MAXN environment
443 // variables — QUALITY_PLAN D3 makes every result-changing knob an explicit argument.)
444 // The cap selects by (distance^2, then global Morton key) — a total order that is a pure
445 // function of geometry, so the kept SET is decomposition-independent like everything else here;
446 // the kept candidates are then emitted in the unchanged canonical (bin, Morton) order, so the
447 // accumulation discipline is untouched. Capping changes the WEIGHTS, which is exactly what the
448 // study measures — it is never on by default.
449 const double gpsRhoFactor = rhoFactor > 0.0 ? rhoFactor : 2.2;
450 const long gpsMaxN = maxSamples > 0 ? maxSamples : 0;
451
452 // LS functional at world position p, degree deg, radius rho, scale H: returns the (idx, w)
453 // list. Weight vector w_j = mono(d_j) . M^{-1} e0 with M the normal matrix.
454 // AM3 (`docs/amr_anisotropic.md` §5): the cloud metric is the INDEX-SPACE one — the ball
455 // `sum_d (del_d/rho_d)^2 <= 1` with `rho_d` the radius factor times the coarser of the two
456 // widths ON AXIS d, and the monomials built on `del_d/H_d`. The degree-2 polynomial space is
457 // closed under a per-axis scaling, so this is the SAME least-squares functional as the cubic
458 // one up to round-off, and bitwise identical when the three spacings agree.
459 auto lsFunctional = [&](const Vec<3>& p, const double rho[3], const double H[3], int deg,
460 std::vector<Index>& idx, std::vector<double>& w) -> bool {
461 idx.clear();
462 w.clear();
463 // (1) every leaf overlapping the fine-cell box that contains the ball (a leaf whose center is
464 // in the ball contains that center, so it overlaps the box).
465 long qlo[3], qhi[3];
466 for (int d = 0; d < 3; ++d) {
467 qlo[d] = static_cast<long>(std::floor((p[d] - origin[d] - rho[d]) / h0[d])) - shiftG[d];
468 qhi[d] = static_cast<long>(std::floor((p[d] - origin[d] + rho[d]) / h0[d])) - shiftG[d];
469 }
470 std::vector<Index> slots;
473 [&](Index s) {
474 if (!discovery)
475 slots.push_back(s);
476 },
477 /*descendPending=*/discovery);
478 if (discovery)
479 return false; // probe-only: the misses are registered, the weights are never consumed
480 std::sort(slots.begin(), slots.end());
481 slots.erase(std::unique(slots.begin(), slots.end()), slots.end());
482 // (2) keep the fluid ones whose center is inside the ball (the old predicate, verbatim).
483 std::vector<Cand> cand;
484 cand.reserve(slots.size());
485 for (Index j : slots) {
486 if (j < 0)
487 continue; // kPending: a discovery round; this pass's result is discarded anyway
488 if (!fluidOf(j))
489 continue;
490 double r2 = 0, q2 = 0;
491 for (int d = 0; d < 3; ++d) {
492 double del = centerOf(j)[d] - p[d];
493 if (del > 0.5 * domain[d])
494 del -= domain[d];
495 if (del < -0.5 * domain[d])
496 del += domain[d];
497 r2 += del * del; // the (distance^2) tie-break key of the nearest-N cap
498 const double u = del / rho[d];
499 q2 += u * u; // the axis-scaled ball predicate
500 }
501 if (q2 > 1.0)
502 continue;
503 const Vec<3>& cj = centerOf(j);
504 const long bx = static_cast<long>((cj[0] - origin[0]) / hb[0]) % nbx;
505 const long by = static_cast<long>((cj[1] - origin[1]) / hb[1]) % nbx;
506 const long bz = static_cast<long>((cj[2] - origin[2]) / hb[2]) % nbx;
507 cand.push_back(Cand{(bz * nbx + by) * nbx + bx, keyOf(j), j, r2});
508 }
509 if (gpsMaxN > 0 && static_cast<long>(cand.size()) > gpsMaxN) {
510 // nearest-N by (distance^2, global Morton key): a strict, geometry-only total order.
511 std::nth_element(
512 cand.begin(), cand.begin() + gpsMaxN, cand.end(),
513 [](const Cand& a, const Cand& b) { return a.d2 != b.d2 ? a.d2 < b.d2 : a.key < b.key; });
514 cand.resize(static_cast<std::size_t>(gpsMaxN));
515 }
516 std::sort(cand.begin(), cand.end(), [](const Cand& a, const Cand& b) {
517 return a.bin != b.bin ? a.bin < b.bin : a.key < b.key;
518 });
519 // (3) emit in the canonical bin traversal order.
520 std::vector<Index> pts;
521 pts.reserve(cand.size());
522 long lo[3], hi[3];
523 for (int d = 0; d < 3; ++d) {
524 lo[d] = static_cast<long>(std::floor((p[d] - origin[d] - rho[d]) / hb[d]));
525 hi[d] = static_cast<long>(std::floor((p[d] - origin[d] + rho[d]) / hb[d]));
526 }
527 for (long bx = lo[0]; bx <= hi[0]; ++bx)
528 for (long by = lo[1]; by <= hi[1]; ++by)
529 for (long bz = lo[2]; bz <= hi[2]; ++bz) {
530 const long wx = (bx % nbx + nbx) % nbx, wy = (by % nbx + nbx) % nbx,
531 wz = (bz % nbx + nbx) % nbx;
532 const long bl = (wz * nbx + wy) * nbx + wx;
533 auto it = std::lower_bound(cand.begin(), cand.end(), bl,
534 [](const Cand& c, long v) { return c.bin < v; });
535 for (; it != cand.end() && it->bin == bl; ++it)
536 pts.push_back(it->slot);
537 }
538 const int need = deg >= 2 ? 12 : 5;
539 if (static_cast<long>(pts.size()) < need)
540 return false;
541 double A[100] = {}, e0[10] = {};
542 int nm = 0;
543 double mono[10];
544 for (Index j : pts) {
545 double d[3];
546 for (int dd = 0; dd < 3; ++dd) {
547 double del = centerOf(j)[dd] - p[dd];
548 if (del > 0.5 * domain[dd])
549 del -= domain[dd];
550 if (del < -0.5 * domain[dd])
551 del += domain[dd];
552 d[dd] = del / H[dd];
553 }
555 for (int r = 0; r < nm; ++r)
556 for (int c = 0; c < nm; ++c)
557 A[r * nm + c] += mono[r] * mono[c];
558 }
559 e0[0] = 1.0;
561 return false;
562 for (Index j : pts) {
563 double d[3];
564 for (int dd = 0; dd < 3; ++dd) {
565 double del = centerOf(j)[dd] - p[dd];
566 if (del > 0.5 * domain[dd])
567 del -= domain[dd];
568 if (del < -0.5 * domain[dd])
569 del += domain[dd];
570 d[dd] = del / H[dd];
571 }
573 double wj = 0;
574 for (int k = 0; k < nm; ++k)
575 wj += mono[k] * e0[k];
576 idx.push_back(j);
577 w.push_back(wj);
578 }
579 return true;
580 };
581
582 ov.rowOf.assign(static_cast<std::size_t>(n), -1);
583 ov.sampStart.assign(1, 0);
584 std::vector<Index> sIdx;
585 std::vector<double> sW;
586
587 // ---- Rung 4 (setup-parallel plan, D2's third pattern): the single sequential-append pass is
588 // split in two. PASS 1 (below, host-parallel) does ALL the geometry per leaf and stages an
589 // accepted row into that leaf's OWN heap-allocated RowStage — nothing is appended to `ov`, so
590 // there is no `slot = ov.base.n` cursor to serialize on. PASS 2 (serial, after) walks the
591 // leaves IN ORDER and appends the staged rows, which reproduces today's row order and CSR slot
592 // order EXACTLY: row order = leaf order, and within a row the 15 chain slots in (a, q) order
593 // with each slot's entries in the order lsFunctional produced them. Nothing is classified
594 // twice — the plan permits it (row state is a pure function of geometry) but staging is
595 // cheaper.
596 //
597 // Only ~13% of leaves become rows, so stages are allocated per accepted row (a null pointer for
598 // every clean/solid leaf) rather than as a dense array.
599 //
600 // NOTE the cloud's candidate ORDER sets the order the LS normal-matrix sums are accumulated,
601 // i.e. the floating-point value of the weights; DD1's canonical (bin, Morton) sort above pins it
602 // independently of both the thread schedule and the decomposition.
603 struct RowStage {
604 Index cell = 0;
605 float rescale = 0.0f;
606 int8_t coupled = 0;
607 int8_t state[6] = {};
608 float th[6] = {}, w_bc[6] = {}, w_n1[6] = {}, w_n2[6] = {}, wm_n1[6] = {}, wm_n2[6] = {};
609 double invh[3] = {0.0, 0.0, 0.0};
610 int8_t sampFluid[15] = {};
611 Index sampCnt[15] = {};
612 std::vector<Index> sIdx;
613 std::vector<double> sW;
614 long nIdentity = 0, nLS2 = 0, nLS1 = 0, nDegraded = 0, nSolidSlot = 0;
615 };
616 std::vector<std::unique_ptr<RowStage>> stages(static_cast<std::size_t>(n));
617 // Per-leaf census slots for the two counters that are tallied over EVERY fluid leaf (clean rows
618 // included), summed in leaf order in pass 2 — integers, so the total is order-independent.
619 std::vector<uint8_t> sfPer(static_cast<std::size_t>(n), 0), mfPer(static_cast<std::size_t>(n), 0);
620
621 hostParFor(n, [&](Index i) {
622 if (!fluid[static_cast<std::size_t>(i)])
623 return;
624 const unsigned Li = t.level(i);
625 const double h = pres.cellWidth(i);
626 const Vec<3>& c = cen[static_cast<std::size_t>(i)];
627
628 // Virtual uniform-position float samples (theta / closure magnitudes — the D2 route).
629 // AM2 (`docs/amr_anisotropic.md` §5): a virtual sample sits `q` cells out along axis `a`, so
630 // it steps by THAT axis's cell width. Identical to the single `h` on a cubic octree.
631 float Cq[3][5], F[3][4];
632 for (int a = 0; a < 3; ++a) {
633 const double ha = pres.cellWidth(i, a);
634 for (int q = -2; q <= 2; ++q) {
635 Vec<3> p = c;
636 p[a] += static_cast<double>(q) * ha;
637 Cq[a][q + 2] = static_cast<float>(sdf(p));
638 }
639 for (int m = 0; m < 4; ++m)
640 F[a][m] = 0.5f * (Cq[a][m] + Cq[a][m + 1]);
641 }
642 // Canonical own-face openness (any-sub-face-open across finer neighbours): force the SIGN
643 // of the own-face F entries to the canonical rule so overlay-closed <=> binary-closed.
644 bool anyOpen[6] = {}; // slot k = 2*axis + (0 plus, 1 minus)
645 int nSub[6] = {};
646 const float si0 = static_cast<float>(sdf(c));
647 pres.forEachFaceFull(i, [&](Index j, int axis, int dir, double, double, double) {
648 const int face = 2 * axis + (dir > 0 ? 0 : 1);
649 ++nSub[face];
650 if (j >= 0 && fluidOf(j)) {
651 const float sj = static_cast<float>(sdf(centerOf(j)));
652 if (0.5f * (si0 + sj) >= 0.0f)
653 anyOpen[face] = true;
654 }
655 });
656 for (int k = 0; k < 6; ++k) {
657 const int a = k / 2;
658 const int m = (k & 1) ? 1 : 2; // minus own face = F[a][1], plus own face = F[a][2]
659 const bool virtOpen = F[a][m] >= 0.0f && Cq[a][(k & 1) ? 1 : 3] > 0.0f && Cq[a][2] > 0.0f;
660 if (virtOpen != anyOpen[k]) {
661 // Marginal face where the virtual and canonical classifications disagree: force the
662 // canonical sign (overlay-closed <=> binary-closed), keep the (small) magnitude.
663 ++sfPer[static_cast<std::size_t>(i)];
664 const float mag = std::fabs(F[a][m]) > 0.0f ? std::fabs(F[a][m]) : 1e-6f;
665 F[a][m] = anyOpen[k] ? mag : -mag;
666 }
667 if (nSub[k] > 1 && !anyOpen[k])
668 ++mfPer[static_cast<std::size_t>(i)]; // coarse face fully closed against finer neighbours
669 }
670 // Non-clean test (canonical own faces + virtual +/-1 centers), as the classic builder.
671 bool clean = true;
672 for (int a = 0; a < 3; ++a)
673 clean = clean && Cq[a][1] >= 0.0f && Cq[a][3] >= 0.0f && F[a][1] >= 0.0f && F[a][2] >= 0.0f;
674 if (clean)
675 return;
676
677 // Fill the row into a scratch ONE-row overlay (gpFillRow writes ov.field(slot), so it needs a
678 // GhostOverlayRef; slot 0 of a private overlay gives the identical arithmetic).
679 GhostOverlay one;
680 one.cell.resize(1);
681 one.rescale.resize(1);
682 one.coupled.resize(1);
683 one.state.resize(6);
684 one.th.resize(6);
685 one.w_bc.resize(6);
686 one.w_n1.resize(6);
687 one.w_n2.resize(6);
688 one.wm_n1.resize(6);
689 one.wm_n2.resize(6);
690 detail::GhostOverlayRef ref{one};
691 if (!scheme::gpFillRow(ref, 0, i, F, Cq, matrixOrder, rhsOrder))
692 return; // rejected row: nothing staged (the old code's resize(slot) rollback)
693
694 auto st = std::make_unique<RowStage>();
695 st->cell = one.cell[0];
696 st->rescale = one.rescale[0];
697 st->coupled = one.coupled[0];
698 for (int k = 0; k < 6; ++k) {
699 st->state[k] = one.state[static_cast<std::size_t>(k)];
700 st->th[k] = one.th[static_cast<std::size_t>(k)];
701 st->w_bc[k] = one.w_bc[static_cast<std::size_t>(k)];
702 st->w_n1[k] = one.w_n1[static_cast<std::size_t>(k)];
703 st->w_n2[k] = one.w_n2[static_cast<std::size_t>(k)];
704 st->wm_n1[k] = one.wm_n1[static_cast<std::size_t>(k)];
705 st->wm_n2[k] = one.wm_n2[static_cast<std::size_t>(k)];
706 }
707 for (int a = 0; a < 3; ++a) // Phase 3: per axis
708 st->invh[a] = 1.0 / pres.cellWidth(i, a);
709
710 // Sample functionals for the 15 chain slots.
711 for (int a = 0; a < 3; ++a)
712 for (int q = -2; q <= 2; ++q) {
713 const int sl = a * 5 + (q + 2);
714 const Index nBefore = static_cast<Index>(st->sIdx.size());
715 Vec<3> p = c;
716 p[a] += static_cast<double>(q) * pres.cellWidth(i, a); // AM2: per-axis step
717 // Recover the covering leaf: floor the world position to fine units (a level-L cell
718 // center is lo + 0.5*2^L in fine units, so the floor lands inside the cell), then into
719 // the block-local probe frame.
720 long lcq[3];
721 probeCoord(p, lcq);
722 const std::array<long, 3> lc{lcq[0], lcq[1], lcq[2]};
723 const Index j = pres.probeSlot(lc).first;
724 const bool fluidP = sdf(p) > 0.0;
725 st->sampFluid[sl] = fluidP ? 1 : 0;
726 // `pres.levelOf`, never `t.level`: D2 made `j` an EXTENDED slot, and a ghost index
727 // read out of the local level array — silently reporting a wrong level, which sent
728 // same-level covers down the least-squares branch instead of the identity one.
729 if (q == 0 || (j >= 0 && pres.levelOf(j) == Li)) {
730 // own cell or same-level cover: identity (the classic chain; bit-identical band).
731 if (j >= 0 && fluidOf(j) == (fluidP ? 1 : 0)) {
732 st->sIdx.push_back(j);
733 st->sW.push_back(1.0);
734 ++st->nIdentity;
735 } else if (j >= 0) {
736 st->sIdx.push_back(j); // marginal float disagreement: still the covering value
737 st->sW.push_back(1.0);
738 ++st->nIdentity;
739 }
740 st->sampCnt[sl] = static_cast<Index>(st->sIdx.size()) - nBefore;
741 continue;
742 }
743 if (!fluidP) {
744 // solid virtual position: empty functional (reads the masked 0).
745 ++st->nSolidSlot;
746 st->sampCnt[sl] = 0;
747 continue;
748 }
749 // AM3: the scale and the cloud radius are PER AXIS — the coarser of the two widths on
750 // each axis. On a cubic octree all three are the same double, so the cloud, its
751 // accumulation order and its weights are the pre-Phase-3 ones bit for bit.
752 double H[3], rho[3];
753 for (int d = 0; d < 3; ++d) {
754 const double hd = pres.cellWidth(i, d);
755 H[d] = (j >= 0) ? pres.cellWidth(j, d) : hd;
756 rho[d] = gpsRhoFactor * std::max(hd, H[d]);
757 }
758 std::vector<Index> idx;
759 std::vector<double> w;
760 if (discovery) {
761 // Probe-only: one descent registers the box's misses; the degree-1 retry would descend
762 // the SAME box, so it is skipped along with everything below that reads the weights.
763 (void)lsFunctional(p, rho, H, 2, idx, w);
764 continue;
765 }
766 if (lsFunctional(p, rho, H, 2, idx, w)) {
767 ++st->nLS2;
768 } else if (lsFunctional(p, rho, H, 1, idx, w)) {
769 ++st->nLS1;
770 } else if (j >= 0 && fluidOf(j)) {
771 idx.assign(1, j);
772 w.assign(1, 1.0);
773 ++st->nDegraded;
774 } else {
775 ++st->nDegraded; // no support at all: reads 0 (fluid-only cascade floor)
776 }
777 for (std::size_t k = 0; k < idx.size(); ++k) {
778 st->sIdx.push_back(idx[k]);
779 st->sW.push_back(w[k]);
780 }
781 st->sampCnt[sl] = static_cast<Index>(st->sIdx.size()) - nBefore;
782 }
783 stages[static_cast<std::size_t>(i)] = std::move(st);
784 });
785
786 if (discovery) {
787 // A discovery round consumes nothing staged: the misses are in the resolver, which is the
788 // whole point. Return the empty overlay without pass 2 or the census line.
789 return ov;
790 }
791
792 // PASS 2 (serial, order-preserving): append the staged rows in LEAF order — the row order, the
793 // 15-slot order and the sampStart/sampIdx/sampW concatenation are exactly what the old single
794 // pass produced.
795 for (Index i = 0; i < n; ++i) {
796 ov.nSignForced += sfPer[static_cast<std::size_t>(i)];
797 ov.nMixedFace += mfPer[static_cast<std::size_t>(i)];
798 const RowStage* st = stages[static_cast<std::size_t>(i)].get();
799 if (!st)
800 continue;
801 ov.base.cell.push_back(st->cell);
802 ov.base.rescale.push_back(st->rescale);
803 ov.base.coupled.push_back(st->coupled);
804 for (int k = 0; k < 6; ++k) {
805 ov.base.state.push_back(st->state[k]);
806 ov.base.th.push_back(st->th[k]);
807 ov.base.w_bc.push_back(st->w_bc[k]);
808 ov.base.w_n1.push_back(st->w_n1[k]);
809 ov.base.w_n2.push_back(st->w_n2[k]);
810 ov.base.wm_n1.push_back(st->wm_n1[k]);
811 ov.base.wm_n2.push_back(st->wm_n2[k]);
812 }
813 Index acc = static_cast<Index>(sIdx.size());
814 for (int sl = 0; sl < 15; ++sl) {
815 ov.sampFluid.push_back(st->sampFluid[sl]);
816 acc += st->sampCnt[sl];
817 ov.sampStart.push_back(acc);
818 }
819 sIdx.insert(sIdx.end(), st->sIdx.begin(), st->sIdx.end());
820 sW.insert(sW.end(), st->sW.begin(), st->sW.end());
821 ov.nIdentity += st->nIdentity;
822 ov.nLS2 += st->nLS2;
823 ov.nLS1 += st->nLS1;
824 ov.nDegraded += st->nDegraded;
825 ov.nSolidSlot += st->nSolidSlot;
826 for (int a = 0; a < 3; ++a)
827 ov.base.invh.push_back(st->invh[a]);
828 ov.rowOf[static_cast<std::size_t>(i)] = ov.base.n;
829 ++ov.base.n;
830 }
831 ov.sampIdx = std::move(sIdx);
832 ov.sampW = std::move(sW);
833 std::fprintf(stderr,
834 "[peclet.amr] sampled ghost overlay: %lld rows | slots identity %ld, LS2 %ld, "
835 "LS1 %ld, degraded %ld, solid %ld | csr %lld | sign-forced faces %ld, closed mixed "
836 "faces %ld\n",
837 static_cast<long long>(ov.base.n), ov.nIdentity, ov.nLS2, ov.nLS1, ov.nDegraded,
838 ov.nSolidSlot, static_cast<long long>(ov.sampIdx.size()), ov.nSignForced,
839 ov.nMixedFace);
840 return ov;
841}
842
844inline double gpsSample(const GhostOverlaySampled& ov, Index r, int a, int q,
845 const std::vector<double>& x) {
846 const std::size_t s = static_cast<std::size_t>(r * 15 + a * 5 + (q + 2));
847 double v = 0.0;
848 for (Index k = ov.sampStart[s]; k < ov.sampStart[s + 1]; ++k)
849 v += ov.sampW[static_cast<std::size_t>(k)] *
850 x[static_cast<std::size_t>(ov.sampIdx[static_cast<std::size_t>(k)])];
851 return v;
852}
853
856inline void ghostApplyDeltaSampledHost(const GhostOverlaySampled& ov, const std::vector<double>& x,
857 std::vector<double>& y) {
858 const GhostOverlay& g = ov.base;
859 for (Index r = 0; r < g.n; ++r) {
860 const std::size_t rr = static_cast<std::size_t>(r);
861 const Index c = g.cell[rr];
862 if (!g.coupled[rr]) {
863 y[static_cast<std::size_t>(c)] = 0.0;
864 continue;
865 }
866 auto X = [&](int a, int q) { return gpsSample(ov, r, a, q, x); };
867 // Phase 3: the ratio form (see ghostApplyDeltaHost) — exactly 1.0 on a cubic octree.
868 const double ih = g.invh[rr * 3];
869 const double rx = g.invh[rr * 3 + 1] / ih, ry = g.invh[rr * 3 + 2] / ih;
870 const double r2[3] = {1.0, rx * rx, ry * ry};
871 double delta = 0.0;
872 for (int k = 0; k < 6; ++k) {
873 const int8_t st = g.state[rr * 6 + static_cast<std::size_t>(k)];
874 if (st != scheme::GP_QUAD && st != scheme::GP_LIN)
875 continue;
876 const int a = k / 2;
877 const int sgn = (k & 1) ? -1 : 1;
878 const int mn = (k & 1) ? 1 : 0;
879 const int mf = (k & 1) ? 2 : -1;
880 const double w1 = g.wm_n1[rr * 6 + static_cast<std::size_t>(k)];
881 const double w2 = g.wm_n2[rr * 6 + static_cast<std::size_t>(k)];
882 delta += r2[a] * (sgn * w1 * (X(a, mn) - X(a, mn - 1)));
883 if (st == scheme::GP_QUAD && w2 != 0.0)
884 delta += r2[a] * (sgn * w2 * (X(a, mf) - X(a, mf - 1)));
885 }
886 y[static_cast<std::size_t>(c)] =
887 g.rescale[rr] * (y[static_cast<std::size_t>(c)] + ih * ih * delta);
888 }
889}
890
893 const std::array<std::vector<double>, 3>& u,
894 std::vector<double>& d) {
895 const GhostOverlay& g = ov.base;
896 for (Index r = 0; r < g.n; ++r) {
897 const std::size_t rr = static_cast<std::size_t>(r);
898 const Index c = g.cell[rr];
899 if (!g.coupled[rr]) {
900 d[static_cast<std::size_t>(c)] = 0.0;
901 continue;
902 }
903 auto U = [&](int a, int m) { // face-averaged value at face index i+m along axis a
904 return 0.5 * (gpsSample(ov, r, a, m - 1, u[static_cast<std::size_t>(a)]) +
905 gpsSample(ov, r, a, m, u[static_cast<std::size_t>(a)]));
906 };
907 // Phase 3: the ratio form (see ghostDivergDeltaHost).
908 const double ihd = g.invh[rr * 3];
909 const double r1[3] = {1.0, g.invh[rr * 3 + 1] / ihd, g.invh[rr * 3 + 2] / ihd};
910 double dd = 0.0;
911 for (int k = 0; k < 6; ++k) {
912 const int8_t st = g.state[rr * 6 + static_cast<std::size_t>(k)];
913 if (st == scheme::GP_COUPLED)
914 continue;
915 const int a = k / 2;
916 const int sgn = (k & 1) ? -1 : 1;
917 const int mg = (k & 1) ? 0 : 1;
918 const int mn = (k & 1) ? 1 : 0;
919 const int mf = (k & 1) ? 2 : -1;
920 if (st == scheme::GP_EXPLICIT) {
921 dd += r1[a] * (sgn * U(a, mg));
922 continue;
923 }
924 if (st == scheme::GP_BC_ONLY)
925 continue;
926 double val = g.w_n1[rr * 6 + static_cast<std::size_t>(k)] * U(a, mn);
927 if (st == scheme::GP_QUAD)
928 val += g.w_n2[rr * 6 + static_cast<std::size_t>(k)] * U(a, mf);
929 dd += r1[a] * (sgn * val);
930 }
931 d[static_cast<std::size_t>(c)] = g.rescale[rr] * (d[static_cast<std::size_t>(c)] + ihd * dd);
932 }
933}
934
954template <unsigned Bits, class Mom, class SdfFn>
956 const AmrPoisson<3, Bits>& pres, const Mom& mom, SdfFn&& sdf,
957 Vec<3> origin, double idiag, double mu, long* nSkipped = nullptr,
958 std::array<long, 3> frameShift = {}) {
959 const Index n = t.numLeaves();
960 const double h0 = pres.cellWidth(0) / static_cast<double>(Index(1) << t.level(0));
961 std::vector<std::vector<detail::ScalarEnt>> per(static_cast<std::size_t>(n));
962 long skipped = 0;
963 for (Index i = 0; i < n; ++i) {
964 const Index r = ov.rowOf[static_cast<std::size_t>(i)];
965 if (r < 0)
966 continue;
967 const unsigned Li = t.level(i);
968 bool seam = false;
969 for (int k = 0; k < 6; ++k) {
970 const Index nb = mom.neighborOf(i, k);
971 if (nb < 0 || pres.levelOf(nb) != Li) // extended slot: ghost-safe (D2)
972 seam = true;
973 }
974 if (!seam)
975 continue;
976 // Virtual classification + row-local ξ stencil.
977 auto b = t.bounds(i);
978 const double s = static_cast<double>(Index(1) << Li);
979 Vec<3> c{};
980 for (int d = 0; d < 3; ++d)
981 c[d] = origin[d] +
982 (static_cast<double>(b[0][d]) + static_cast<double>(frameShift[d]) + 0.5 * s) * h0;
983 const double h = pres.cellWidth(i);
984 const double sdfC = sdf(c);
985 double sdfNv[6];
986 bool anyGhost = false;
987 for (int k = 0; k < 6; ++k) {
988 Vec<3> p = c;
989 p[k / 2] += ((k % 2 == 0) ? +1.0 : -1.0) * h; // k even = plus side (AmrCutCell::neighbor)
990 sdfNv[k] = sdf(p);
991 if (sdfNv[k] < 0.0)
992 anyGhost = true;
993 }
994 if (!mom.isCut(i) && !anyGhost)
995 continue; // regular both ways: the C/F-aware raw row is already the target
996 if (!mom.isCut(i) && anyGhost) {
997 ++skipped; // raw-regular but virtually ghost (marginal): out of rung-1 scope
998 continue;
999 }
1000 auto push = [&](Index cell, double w) {
1001 if (w != 0.0)
1002 per[static_cast<std::size_t>(i)].push_back(detail::ScalarEnt{cell, w});
1003 };
1004 // + raw row / rscale_r (the assembled ξ row verbatim).
1005 const double rsr = mom.rhsScale(i);
1006 push(i, mom.acRaw()[static_cast<std::size_t>(i)] / rsr);
1007 for (int k = 0; k < 6; ++k) {
1008 const double a = mom.offRaw()[static_cast<std::size_t>(i) * 6 + k];
1009 const Index nb = mom.neighborOf(i, k);
1010 if (a != 0.0 && nb >= 0)
1011 push(nb, a / rsr);
1012 }
1013 // − virtual row / rscale_v.
1014 if (anyGhost) {
1015 // Phase 3: the ROW-LOCAL beta per axis (`docs/amr_anisotropic.md` §3). `h` is the row's
1016 // cellWidth on axis 0; the other two come from the same leaf.
1017 double beta[3];
1018 for (int d = 0; d < 3; ++d) {
1019 const double hd = pres.cellWidth(i, d);
1020 beta[d] = mu / (hd * hd);
1021 }
1022 const double AC0v = idiag + ((2.0 * beta[0] + 2.0 * beta[1]) + 2.0 * beta[2]);
1023 double ACv, offv[6], rsv = 1.0, inhomv = 0.0;
1024 Mom::buildCutStencil(sdfC, sdfNv, beta, AC0v, ACv, offv, rsv, inhomv);
1025 push(i, -ACv / rsv);
1026 for (int k = 0; k < 6; ++k) {
1027 if (offv[k] == 0.0)
1028 continue;
1029 const int q = (k % 2 == 0) ? +1 : -1;
1030 const std::size_t sl = static_cast<std::size_t>(r * 15 + (k / 2) * 5 + (q + 2));
1031 for (Index e = ov.sampStart[sl]; e < ov.sampStart[sl + 1]; ++e)
1032 push(ov.sampIdx[static_cast<std::size_t>(e)],
1033 -offv[k] / rsv * ov.sampW[static_cast<std::size_t>(e)]);
1034 }
1035 } else {
1036 // Virtually clean: target = the level-aware conservative regular row (assembleOperator's
1037 // regular branch, reproduced coefficient-for-coefficient).
1038 const double invV = 1.0 / mom.lap().cellVolume(i);
1039 double dsum = 0.0;
1040 mom.lap().forEachFaceNeighbor(i, [&](Index j, Real cf, int, double a) {
1041 push(j, -(-mu * invV * (a * cf))); // minus the target's off-diagonal
1042 dsum += a * cf;
1043 });
1044 push(i, -(idiag + mu * invV * dsum));
1045 }
1046 }
1047 CfCsr out;
1049 if (nSkipped)
1050 *nSkipped = skipped;
1051 return out;
1052}
1053
1059inline double gpsDirGrad(const GhostOverlaySampled& ov, Index r, const std::vector<double>& fld,
1060 int c, double invh) {
1061 auto fl = [&](int q) {
1062 return ov.sampFluid[static_cast<std::size_t>(r * 15 + c * 5 + (q + 2))] != 0;
1063 };
1064 auto F = [&](int q) { return gpsSample(ov, r, c, q, fld); };
1065 const bool ap = fl(+1), am = fl(-1);
1066 if (am && ap)
1067 return (F(+1) - F(-1)) * 0.5 * invh;
1068 if (ap)
1069 return fl(+2) ? (-3.0 * F(0) + 4.0 * F(+1) - F(+2)) * 0.5 * invh : (F(+1) - F(0)) * invh;
1070 if (am)
1071 return fl(-2) ? (3.0 * F(0) - 4.0 * F(-1) + F(-2)) * 0.5 * invh : (F(0) - F(-1)) * invh;
1072 return 0.0;
1073}
1074
1083 std::vector<Index> cell;
1084 std::vector<Index> start;
1085 std::vector<Index> idx;
1086 std::vector<double> w;
1087};
1088
1089template <unsigned Bits, class IsCutFn, class OkFn>
1091 const BlockOctree<3, Bits>& t,
1092 const AmrPoisson<3, Bits>& pres, IsCutFn&& isCut,
1093 OkFn&& classicOk) {
1095 g.start.assign(1, 0);
1096 const Index n = t.numLeaves();
1097 auto pushSlot = [&](Index r, int a, int q, double wgt) {
1098 const std::size_t s = static_cast<std::size_t>(r * 15 + a * 5 + (q + 2));
1099 for (Index e = ov.sampStart[s]; e < ov.sampStart[s + 1]; ++e) {
1100 g.idx.push_back(ov.sampIdx[static_cast<std::size_t>(e)]);
1101 g.w.push_back(wgt * ov.sampW[static_cast<std::size_t>(e)]);
1102 }
1103 };
1104 auto pushCell = [&](Index j, double wgt) {
1105 g.idx.push_back(j);
1106 g.w.push_back(wgt);
1107 };
1108 for (Index i = 0; i < n; ++i) {
1109 if (!isCut(i))
1110 continue;
1111 g.cell.push_back(i);
1112 const double h = pres.cellWidth(i);
1113 const Index r = ov.rowOf[static_cast<std::size_t>(i)];
1114 for (int a = 0; a < 3; ++a) {
1115 if (r >= 0) {
1116 auto fl = [&](int q) {
1117 return ov.sampFluid[static_cast<std::size_t>(r * 15 + a * 5 + (q + 2))] != 0;
1118 };
1119 const bool ap = fl(+1), am = fl(-1);
1120 if (am && ap) {
1121 pushSlot(r, a, +1, 0.5 / h);
1122 pushSlot(r, a, -1, -0.5 / h);
1123 } else if (ap) {
1124 if (fl(+2)) {
1125 pushSlot(r, a, 0, -1.5 / h);
1126 pushSlot(r, a, +1, 2.0 / h);
1127 pushSlot(r, a, +2, -0.5 / h);
1128 } else {
1129 pushSlot(r, a, +1, 1.0 / h);
1130 pushSlot(r, a, 0, -1.0 / h);
1131 }
1132 } else if (am) {
1133 if (fl(-2)) {
1134 pushSlot(r, a, 0, 1.5 / h);
1135 pushSlot(r, a, -1, -2.0 / h);
1136 pushSlot(r, a, -2, 0.5 / h);
1137 } else {
1138 pushSlot(r, a, 0, 1.0 / h);
1139 pushSlot(r, a, -1, -1.0 / h);
1140 }
1141 } // sandwiched: no entries (gradient 0)
1142 } else {
1143 // Classic same-level cascade (buildGhostGradOverlay's per-cell body).
1144 const Index jp = pres.periodicNeighbor(i, a, +1);
1145 const Index jm = pres.periodicNeighbor(i, a, -1);
1146 const bool ap = classicOk(jp, i), am = classicOk(jm, i);
1147 if (am && ap) {
1148 pushCell(jp, 0.5 / h);
1149 pushCell(jm, -0.5 / h);
1150 } else if (ap) {
1151 const Index jpp = pres.periodicNeighbor(jp, a, +1);
1152 if (classicOk(jpp, i)) {
1153 pushCell(i, -1.5 / h);
1154 pushCell(jp, 2.0 / h);
1155 pushCell(jpp, -0.5 / h);
1156 } else {
1157 pushCell(jp, 1.0 / h);
1158 pushCell(i, -1.0 / h);
1159 }
1160 } else if (am) {
1161 const Index jmm = pres.periodicNeighbor(jm, a, -1);
1162 if (classicOk(jmm, i)) {
1163 pushCell(i, 1.5 / h);
1164 pushCell(jm, -2.0 / h);
1165 pushCell(jmm, 0.5 / h);
1166 } else {
1167 pushCell(i, 1.0 / h);
1168 pushCell(jm, -1.0 / h);
1169 }
1170 }
1171 }
1172 g.start.push_back(static_cast<Index>(g.idx.size()));
1173 }
1174 }
1175 return g;
1176}
1177
1178// ---- device mirror + kernels (Kokkos TUs only; include after a Kokkos-carrying header) ---------
1179#ifdef KOKKOS_INLINE_FUNCTION
1180
1193
1196 d.n = h.base.n;
1197 if (h.base.n == 0)
1198 return d;
1199 d.cell = toDevice(h.base.cell, "gps_cell");
1200 d.rescale = toDevice(h.base.rescale, "gps_rescale");
1201 d.coupled = toDevice(h.base.coupled, "gps_coupled");
1202 d.state = toDevice(h.base.state, "gps_state");
1203 d.w_n1 = toDevice(h.base.w_n1, "gps_wn1");
1204 d.w_n2 = toDevice(h.base.w_n2, "gps_wn2");
1205 d.wm_n1 = toDevice(h.base.wm_n1, "gps_wmn1");
1206 d.wm_n2 = toDevice(h.base.wm_n2, "gps_wmn2");
1207 d.invh = toDevice(h.base.invh, "gps_invh");
1208 d.sampStart = toDevice(h.sampStart, "gps_sstart");
1209 d.sampIdx = toDevice(h.sampIdx, "gps_sidx");
1210 d.sampW = toDevice(h.sampW, "gps_sw");
1211 return d;
1212}
1213
1216 View<double> y) {
1217 if (ov.n == 0)
1218 return;
1219 auto cell = ov.cell;
1220 auto resc = ov.rescale;
1221 auto cpl = ov.coupled;
1222 auto st = ov.state;
1223 auto wm1 = ov.wm_n1;
1224 auto wm2 = ov.wm_n2;
1225 auto invh = ov.invh;
1226 auto ss = ov.sampStart;
1227 auto si = ov.sampIdx;
1228 auto sw = ov.sampW;
1229 Kokkos::parallel_for(
1230 "amr::gps_apply_delta", ov.n, KOKKOS_LAMBDA(const Index r) {
1231 const Index c = cell(r);
1232 if (!cpl(r)) {
1233 y(c) = 0.0;
1234 return;
1235 }
1236 // M2c: the +/- faces of an axis read overlapping slots, so a row re-gathered the same
1237 // slot's CSR sum up to twice. k = 2a and 2a+1 are consecutive, so a FIVE-entry cache for
1238 // the current axis, invalidated when the axis changes, catches every repeat at negligible
1239 // register cost. Each slot is still summed in its own CSR order, exactly once, and delta
1240 // still accumulates over k in the same sequence ⇒ pure read-dedup, bit-identical.
1241 double Sv[5];
1242 bool Sh[5] = {false, false, false, false, false};
1243 int aCur = -1;
1244 auto X = [&](int a, int q) {
1245 if (a != aCur) {
1246 aCur = a;
1247 for (int m = 0; m < 5; ++m)
1248 Sh[m] = false;
1249 }
1250 const int idx = q + 2;
1251 if (!Sh[idx]) {
1252 const Index s = r * 15 + a * 5 + idx;
1253 double v = 0.0;
1254 for (Index e = ss(s); e < ss(s + 1); ++e)
1255 v += sw(e) * x(si(e));
1256 Sv[idx] = v;
1257 Sh[idx] = true;
1258 }
1259 return Sv[idx];
1260 };
1261 // Phase 3: the ratio form (per-axis weights, exactly 1.0 on a cubic octree).
1262 const double ih = invh(r * 3);
1263 const double rx = invh(r * 3 + 1) / ih, ry = invh(r * 3 + 2) / ih;
1264 const double r2[3] = {1.0, rx * rx, ry * ry};
1265 double delta = 0.0;
1266 for (int k = 0; k < 6; ++k) {
1267 const int8_t s = st(r * 6 + k);
1268 if (s != scheme::GP_QUAD && s != scheme::GP_LIN)
1269 continue;
1270 const int a = k / 2;
1271 const int sgn = (k & 1) ? -1 : 1;
1272 const int mn = (k & 1) ? 1 : 0;
1273 const int mf = (k & 1) ? 2 : -1;
1274 const double w1 = wm1(r * 6 + k), w2 = wm2(r * 6 + k);
1275 delta += r2[a] * (sgn * w1 * (X(a, mn) - X(a, mn - 1)));
1276 if (s == scheme::GP_QUAD && w2 != 0.0)
1277 delta += r2[a] * (sgn * w2 * (X(a, mf) - X(a, mf - 1)));
1278 }
1279 y(c) = resc(r) * (y(c) + ih * ih * delta);
1280 });
1281}
1282
1286 if (ov.n == 0)
1287 return;
1288 auto cell = ov.cell;
1289 auto resc = ov.rescale;
1290 auto cpl = ov.coupled;
1291 auto st = ov.state;
1292 auto w1v = ov.w_n1;
1293 auto w2v = ov.w_n2;
1294 auto invh = ov.invh;
1295 auto ss = ov.sampStart;
1296 auto si = ov.sampIdx;
1297 auto sw = ov.sampW;
1298 Kokkos::parallel_for(
1299 "amr::gps_diverg_delta", ov.n, KOKKOS_LAMBDA(const Index r) {
1300 const Index c = cell(r);
1301 if (!cpl(r)) {
1302 d(c) = 0.0;
1303 return;
1304 }
1305 // M2c (see ghostApplyDeltaSampled): per-axis five-slot cache. Here the sharing is even
1306 // heavier — every U() straddles two slots and consecutive faces overlap — so the same
1307 // dedup applies verbatim, and each slot keeps its own CSR summation order.
1308 double Sv[5];
1309 bool Sh[5] = {false, false, false, false, false};
1310 int aCur = -1;
1311 auto S = [&](int a, int q) {
1312 if (a != aCur) {
1313 aCur = a;
1314 for (int m = 0; m < 5; ++m)
1315 Sh[m] = false;
1316 }
1317 const int idx = q + 2;
1318 if (!Sh[idx]) {
1319 const Index s = r * 15 + a * 5 + idx;
1320 double v = 0.0;
1321 for (Index e = ss(s); e < ss(s + 1); ++e) {
1322 const Index j = si(e);
1323 v += sw(e) * ((a == 0) ? u0(j) : (a == 1) ? u1(j) : u2(j));
1324 }
1325 Sv[idx] = v;
1326 Sh[idx] = true;
1327 }
1328 return Sv[idx];
1329 };
1330 auto U = [&](int a, int m) { return 0.5 * (S(a, m - 1) + S(a, m)); };
1331 // Phase 3: the ratio form (see ghostDivergDeltaSampledHost).
1332 const double ih = invh(r * 3);
1333 const double r1[3] = {1.0, invh(r * 3 + 1) / ih, invh(r * 3 + 2) / ih};
1334 double dd = 0.0;
1335 for (int k = 0; k < 6; ++k) {
1336 const int8_t s = st(r * 6 + k);
1337 if (s == scheme::GP_COUPLED)
1338 continue;
1339 const int a = k / 2;
1340 const int sgn = (k & 1) ? -1 : 1;
1341 const int mg = (k & 1) ? 0 : 1;
1342 const int mn = (k & 1) ? 1 : 0;
1343 const int mf = (k & 1) ? 2 : -1;
1344 if (s == scheme::GP_EXPLICIT) {
1345 dd += r1[a] * (sgn * U(a, mg));
1346 continue;
1347 }
1348 if (s == scheme::GP_BC_ONLY)
1349 continue;
1350 double val = w1v(r * 6 + k) * U(a, mn);
1351 if (s == scheme::GP_QUAD)
1352 val += w2v(r * 6 + k) * U(a, mf);
1353 dd += r1[a] * (sgn * val);
1354 }
1355 d(c) = resc(r) * (d(c) + ih * dd);
1356 });
1357}
1358
1366
1369 d.n = static_cast<Index>(h.cell.size());
1370 if (d.n == 0)
1371 return d;
1372 d.cell = toDevice(h.cell, "gcs_cell");
1373 d.start = toDevice(h.start, "gcs_start");
1374 d.idx = toDevice(h.idx, "gcs_idx");
1375 d.w = toDevice(h.w, "gcs_w");
1376 return d;
1377}
1378
1381 if (ov.n == 0)
1382 return;
1383 auto cell = ov.cell;
1384 auto start = ov.start;
1385 auto idx = ov.idx;
1386 auto w = ov.w;
1387 Kokkos::parallel_for(
1388 "amr::flow_ghostgrad_csr", ov.n, KOKKOS_LAMBDA(const Index s) {
1389 const Index i = cell(s);
1390 double g[3];
1391 for (int a = 0; a < 3; ++a) {
1392 double acc = 0.0;
1393 for (Index e = start(s * 3 + a); e < start(s * 3 + a + 1); ++e)
1394 acc += w(e) * f(idx(e));
1395 g[a] = acc;
1396 }
1397 gx(i) = g[0];
1398 gy(i) = g[1];
1399 gz(i) = g[2];
1400 });
1401}
1402
1403#endif // KOKKOS_INLINE_FUNCTION
1404
1405} // namespace peclet::amr
1406
1407#endif // PECLET_AMR_GHOST_PROJECTION_SAMPLED_HPP
Cell-centered FV Poisson operator on one (periodic) block octree.
Definition poisson.hpp:43
Per-block adaptive octree over block-local Morton codes.
void forEachCoveringSlot(const AmrPoisson< 3, Bits > &pres, const long lo[3], const long hi[3], WrapFn &&wrapLocal, EmitFn &&emit, bool descendPending=false)
DD1 (docs/amr_march_perf_and_distributed_plan.md) — the LS cloud's probe descent.
bool gpsSolveDense(int n, double *A, double *b)
Gaussian elimination with partial pivoting (small dense normal equations).
void compactCsr(const std::vector< std::vector< Ent > > &per, Csr &out, Index n)
void gpsMonomials(const double d[3], int deg, double *m, int &nm)
auto makeBinaryOpenFnMixed(const BlockOctree< 3, Bits > &t, const AmrPoisson< 3, Bits > &pres, SdfFn sdfFn, const Vec< 3 > &h0, Vec< 3 > origin, std::array< long, 3 > frameShift={})
The canonical (sub)face openness for mixed-level cut bands: open iff both adjacent ACTUAL leaf center...
GhostOverlaySampled buildGhostOverlaySampled(const BlockOctree< 3, Bits > &t, const AmrPoisson< 3, Bits > &pres, SdfFn &&sdf, int matrixOrder, int rhsOrder, Vec< 3 > origin=Vec< 3 >{}, const std::array< long, 3 > *globalFine=nullptr, std::array< long, 3 > frameShift={}, bool discovery=false, double rhoFactor=2.2, long maxSamples=0)
Build the sampled overlay.
GhostOverlaySampledDev uploadGhostOverlaySampled(const GhostOverlaySampled &h)
double gpsSample(const GhostOverlaySampled &ov, Index r, int a, int q, const std::vector< double > &x)
Evaluate slot (r, a, q) of a scalar leaf field. Empty functional reads 0.
GhostGradCsrHost buildSampledGradCsr(const GhostOverlaySampled &ov, const BlockOctree< 3, Bits > &t, const AmrPoisson< 3, Bits > &pres, IsCutFn &&isCut, OkFn &&classicOk)
void ghostApplyDeltaSampled(const GhostOverlaySampledDev &ov, View< const double > x, View< double > y)
Device matrix overlay (== ghostApplyDeltaSampledHost). Distinct rows per thread: no atomics.
void ghostDivergDeltaSampledHost(const GhostOverlaySampled &ov, const std::array< std::vector< double >, 3 > &u, std::vector< double > &d)
Divergence overlay (sampled): the sampled analog of ghostDivergDeltaHost (u_bc = 0).
void applyGhostGradCsr(const GhostGradCsrDev &ov, View< const double > f, View< double > gx, View< double > gy, View< double > gz)
void ghostDivergDeltaSampled(const GhostOverlaySampledDev &ov, View< const double > u0, View< const double > u1, View< const double > u2, View< double > d)
Device divergence overlay (== ghostDivergDeltaSampledHost).
GhostGradCsrDev uploadGhostGradCsr(const GhostGradCsrHost &h)
double gpsDirGrad(const GhostOverlaySampled &ov, Index r, const std::vector< double > &fld, int c, double invh)
Directional ghost cell-gradient on a sampled row (the mixed-level gradOfDir): the same cascade — cent...
std::vector< std::array< double, Dim > > transferFieldGradients(const BlockOctree< Dim, Bits > &oldT, const std::vector< double > &oldF)
Per-old-leaf minmod prolongation gradients (per fine-coordinate unit) — transferField's stencil,...
Definition adapt.hpp:51
CfCsr buildMomSeamDelta(const GhostOverlaySampled &ov, const BlockOctree< 3, Bits > &t, const AmrPoisson< 3, Bits > &pres, const Mom &mom, SdfFn &&sdf, Vec< 3 > origin, double idiag, double mu, long *nSkipped=nullptr, std::array< long, 3 > frameShift={})
Momentum ξ-overlay seam correction (plan §6.2, rung 1): at overlay rows with a different-level ±1 nei...
void ghostApplyDeltaSampledHost(const GhostOverlaySampled &ov, const std::vector< double > &x, std::vector< double > &y)
Matrix overlay (sampled): y currently holds the BINARY L matvec; overwrite overlay rows with y = rho·...
Scalar-input CSR overlay: out(i) += Σ_k coef·f(slot) over rows with C/F faces.
Definition cf_scheme.hpp:79
Device CSR gradient overlay (sampled mode) + upload + apply (the variable-length analog of GhostGradO...
Host CSR form of the SAMPLED directional gradient overlay (variable-length stencils — a seam row's ca...
std::vector< Index > start
[m*3 + 1] per (cell, axis)
std::vector< Index > cell
[m] cut leaf
Device mirror of GhostOverlaySampled (uploaded once per setSolid).
Sampled overlay: the classic per-row fields (weights, states, rescale — base; base....
std::vector< Index > sampStart
[base.n*15 + 1]
std::vector< Index > rowOf
[numLeaves] row index of a leaf, -1 if none
std::vector< int8_t > sampFluid
[base.n*15] virtual position is fluid (gradient cascade)
Host ghost-projection overlay: one row per non-clean fluid leaf (== cut cell: some ±1 center sample s...
std::vector< float > wm_n2
[n*6] matrix (implicit phi) weights (matrixOrder)
std::vector< int8_t > coupled
[n] 1 if the row has any phi coupling at all
std::vector< Index > cell
[n] leaf index
std::vector< float > wm_n1
std::vector< int8_t > state
[n*6]
std::vector< float > w_n2
[n*6] RHS/diagnostic closure weights (rhsOrder)
std::vector< float > rescale
[n] rho = min(1, min_f D_f) of the MATRIX weights
std::vector< float > w_n1
std::vector< double > invh
[n*3] 1/cellWidth PER AXIS of the row (Phase 3)