chombo-discharge
Loading...
Searching...
No Matches
CD_NearestNeighborParticleMergeImplem.H
Go to the documentation of this file.
1/*
2 * SPDX-FileCopyrightText: 2021-2026 SINTEF Energy Research
3 *
4 * SPDX-License-Identifier: GPL-3.0-or-later
5 */
6
13#ifndef CD_NEARESTNEIGHBORPARTICLEMERGEIMPLEM_H
14#define CD_NEARESTNEIGHBORPARTICLEMERGEIMPLEM_H
15
16// Std includes
17#include <algorithm>
18#include <cmath>
19
20// Chombo includes
21#include <CH_Timer.H>
22#include <MayDay.H>
23#include <SPMD.H>
24#include <DataIterator.H>
25#include <BoxIterator.H>
26
27// Our includes
29#include <CD_AmrMesh.H>
30#include <CD_NamespaceHeader.H>
31
32namespace ParticleManagement {
33
34namespace detail {
35
42inline Real
43nnMergeDistSquared(const RealVect& a_a, const RealVect& a_b) noexcept
44{
45 Real d2 = 0.0;
46
47 for (int dir = 0; dir < SpaceDim; dir++) {
48 const Real delta = a_a[dir] - a_b[dir];
49
50 d2 += delta * delta;
51 }
52
53 return d2;
54}
55
63inline int
64nnMergeChebyshevCellDistance(const IntVect& a_a, const IntVect& a_b) noexcept
65{
66 int dist = 0;
67
68 for (int dir = 0; dir < SpaceDim; dir++) {
69 dist = std::max(dist, std::abs(a_a[dir] - a_b[dir]));
70 }
71
72 return dist;
73}
74
91inline bool
92nnMergeCrossLevelTooFar(const RealVect& a_posA,
93 const int a_levelA,
94 const RealVect& a_posB,
95 const int a_levelB,
96 const RealVect& a_probLo,
97 const std::vector<RealVect>& a_dxByLevel,
98 const int a_maxCellDistance) noexcept
99{
100 const int refLevel = std::max(a_levelA, a_levelB);
101 const RealVect& refDx = a_dxByLevel[refLevel];
102 const IntVect keyA = cellKeyOf(a_posA, a_probLo, refDx);
103 const IntVect keyB = cellKeyOf(a_posB, a_probLo, refDx);
104 return nnMergeChebyshevCellDistance(keyA, keyB) > a_maxCellDistance;
105}
106
117inline void
118nnMergeWeightedCentroid(RealVect& a_mergedPos,
119 Real& a_mergedWeight,
120 const RealVect& a_posA,
121 const Real a_weightA,
122 const RealVect& a_posB,
123 const Real a_weightB) noexcept
124{
125 a_mergedWeight = a_weightA + a_weightB;
126
127 for (int dir = 0; dir < SpaceDim; dir++) {
128 a_mergedPos[dir] = (a_posA[dir] * a_weightA + a_posB[dir] * a_weightB) / a_mergedWeight;
129 }
130}
131
141inline Real
142nnMergePointToCellDistSquared(const RealVect& a_point,
143 const IntVect& a_cell,
144 const RealVect& a_probLo,
145 const RealVect& a_dx) noexcept
146{
147 Real d2 = 0.0;
148
149 for (int dir = 0; dir < SpaceDim; dir++) {
150 const Real lo = a_probLo[dir] + a_cell[dir] * a_dx[dir];
151 const Real hi = lo + a_dx[dir];
152
153 Real delta = 0.0;
154
155 if (a_point[dir] < lo) {
156 delta = lo - a_point[dir];
157 }
158 else if (a_point[dir] > hi) {
159 delta = a_point[dir] - hi;
160 }
161
162 d2 += delta * delta;
163 }
164
165 return d2;
166}
167
180template <typename T>
181inline std::vector<T>
182nnMergeExchangeByRank(const std::vector<std::vector<T>>& a_sendByRank)
183{
184 const int numRanks = static_cast<int>(a_sendByRank.size());
185
186#ifdef CH_MPI
187 if (numRanks <= 1) {
188 return a_sendByRank.empty() ? std::vector<T>() : a_sendByRank[0];
189 }
190
191 std::vector<int> sendCounts(numRanks, 0);
192
193 for (int r = 0; r < numRanks; r++) {
194 sendCounts[r] = static_cast<int>(a_sendByRank[r].size());
195 }
196
197 std::vector<int> recvCounts(numRanks, 0);
198 MPI_Alltoall(sendCounts.data(), 1, MPI_INT, recvCounts.data(), 1, MPI_INT, Chombo_MPI::comm);
199
200 std::vector<int> sdispl(numRanks, 0);
201 std::vector<int> rdispl(numRanks, 0);
202
203 long stot = 0;
204 long rtot = 0;
205
206 for (int r = 0; r < numRanks; r++) {
207 sdispl[r] = static_cast<int>(stot);
208 stot += sendCounts[r];
209
210 rdispl[r] = static_cast<int>(rtot);
211 rtot += recvCounts[r];
212 }
213
214 std::vector<T> sflat(stot);
215
216 for (int r = 0; r < numRanks; r++) {
217 if (sendCounts[r] > 0) {
218 std::copy(a_sendByRank[r].begin(), a_sendByRank[r].end(), sflat.begin() + sdispl[r]);
219 }
220 }
221
222 std::vector<T> rflat(rtot);
223
224 // Scale record counts/displacements up to bytes -- MPI only knows bytes.
225 std::vector<int> sendBytes(numRanks);
226 std::vector<int> recvBytes(numRanks);
227 std::vector<int> sdisplBytes(numRanks);
228 std::vector<int> rdisplBytes(numRanks);
229
230 for (int r = 0; r < numRanks; r++) {
231 sendBytes[r] = sendCounts[r] * static_cast<int>(sizeof(T));
232 recvBytes[r] = recvCounts[r] * static_cast<int>(sizeof(T));
233 sdisplBytes[r] = sdispl[r] * static_cast<int>(sizeof(T));
234 rdisplBytes[r] = rdispl[r] * static_cast<int>(sizeof(T));
235 }
236
237 MPI_Alltoallv(sflat.data(),
238 sendBytes.data(),
239 sdisplBytes.data(),
240 MPI_BYTE,
241 rflat.data(),
242 recvBytes.data(),
243 rdisplBytes.data(),
244 MPI_BYTE,
245 Chombo_MPI::comm);
246
247 return rflat;
248#else
249 return a_sendByRank.empty() ? std::vector<T>() : a_sendByRank[0];
250#endif
251}
252
253inline IntVect
254cellKeyOf(const RealVect& a_position, const RealVect& a_probLo, const RealVect& a_dx) noexcept
255{
256 IntVect iv;
257
258 for (int dir = 0; dir < SpaceDim; dir++) {
259 iv[dir] = static_cast<int>(std::floor((a_position[dir] - a_probLo[dir]) / a_dx[dir]));
260 }
261
262 return iv;
263}
264
279template <typename Packed>
280inline bool
281nnMergeAnyCellCrowded(const std::vector<MergeParticle<Packed>>& a_localValid,
282 const std::vector<MergeParticle<Packed>>& a_ghosts,
283 const RealVect& a_probLo,
284 const RealVect& a_dx,
285 const NNCellBudget& a_cellBudget) noexcept
286{
287 const std::size_t total = a_localValid.size() + a_ghosts.size();
288
289 std::unordered_map<NNWalkCell, int, LevelTiles::TileHasher> cellCount;
290 cellCount.reserve(total);
291
292 for (const auto& p : a_localValid) {
293 cellCount[cellKeyOf(p.position, a_probLo, a_dx)]++;
294 }
295
296 for (const auto& p : a_ghosts) {
297 cellCount[cellKeyOf(p.position, a_probLo, a_dx)]++;
298 }
299
300 // Compared against the SMALLEST budget any cell has rather than this cell's own. The count above
301 // is pooled per bare cell, in the query patch's single frame, so it has no level to look a budget
302 // up with -- and this is only a pre-filter guarding whether the patch gets a spatial index at all.
303 // Taking the most permissive budget keeps it strictly conservative: it can never skip a patch the
304 // real per-cell tests downstream would have found work in. For a uniform budget this is exactly
305 // the plain threshold comparison it replaced.
306 const int minBudget = a_cellBudget.minimum();
307
308 for (const auto& kv : cellCount) {
309 if (kv.second > minBudget) {
310 return true;
311 }
312 }
313
314 return false;
315}
316
317template <typename Packed, typename Cloud>
318inline void
319buildNNSpatialIndex(NNSpatialIndex<Packed, Cloud>& a_index,
320 const std::vector<MergeParticle<Packed>>& a_localValid,
321 const std::vector<MergeParticle<Packed>>& a_ghosts,
322 const RealVect& a_probLo,
323 const RealVect& a_dx,
324 const NNCellBudget& a_cellBudget,
325 const int a_kNearest) noexcept
326{
327 CH_TIME("ParticleManagement::buildNNSpatialIndex");
328
329 a_index.pointCloud = nullptr;
330 a_index.pointCloudGraph.clear();
331 a_index.pointCloudNLocal = 0;
332 a_index.pointCloudK = a_kNearest;
333
334 const std::size_t total = a_localValid.size() + a_ghosts.size();
335 if (total == 0) {
336 return;
337 }
338
339 // Exact per-cell pre-filter (see this function's own docs): build nothing unless some cell is
340 // over threshold.
341 if (!nnMergeAnyCellCrowded(a_localValid, a_ghosts, a_probLo, a_dx, a_cellBudget)) {
342 return;
343 }
344
345 // Assemble the whole-patch point cloud (local valid first, then ghosts). Metadata is each
346 // point's globalID; a hit's cloud index decodes ownership (< nLocal => local valid, else
347 // ghost). Positions are embedded into 3D (z = 0 in 2D) for EBGeometry's Vec3T.
348 const std::size_t nLocal = a_localValid.size();
349
350 std::vector<EBGeometry::Vec3T<Real>> positions;
351 std::vector<ParticleID> metadata;
352
353 positions.reserve(total);
354 metadata.reserve(total);
355
356 auto pushPoint = [&](const MergeParticle<Packed>& a_p) {
357 EBGeometry::Vec3T<Real> v(0.0, 0.0, 0.0);
358
359 for (int dir = 0; dir < SpaceDim; dir++) {
360 v[dir] = a_p.position[dir];
361 }
362
363 positions.push_back(v);
364 metadata.push_back(a_p.globalID);
365 };
366
367 for (const auto& p : a_localValid) {
368 pushPoint(p);
369 }
370
371 for (const auto& p : a_ghosts) {
372 pushPoint(p);
373 }
374
375 a_index.pointCloud = std::make_shared<Cloud>(positions, metadata);
376 a_index.pointCloudNLocal = nLocal;
377
378 // Precompute the whole kNN graph in one batched pass, reused across every internal
379 // a_iterateLocalTierToConvergence pass (positions never change within a round; only consumed
380 // status does, and that is filtered at lookup time). Ghost rows are computed but never read;
381 // see NNSpatialIndex::pointCloudGraph.
382 a_index.pointCloudGraph = a_index.pointCloud->allNearestNeighbors(static_cast<std::size_t>(a_kNearest));
383}
384
385template <typename Packed, typename Cloud>
386inline void
387findNearestNeighborCandidates(std::vector<NNMergeEdge<Packed>>& a_edges,
388 const std::vector<MergeParticle<Packed>>& a_localValid,
389 const std::vector<MergeParticle<Packed>>& a_ghosts,
390 const std::unordered_set<ParticleID>& a_consumedIDs,
391 const NNSpatialIndex<Packed, Cloud>& a_spatialIndex,
392 const std::unordered_map<NNCellKey, int, NNCellKeyHasher>& a_liveCellCount,
393 const RealVect& a_probLo,
394 const RealVect& a_dx,
395 const NNCellBudget& a_cellBudget,
396 const int a_maxFallbackCandidates,
397 const std::optional<int> a_maxCellDistance) noexcept
398{
399 CH_TIME("ParticleManagement::findNearestNeighborCandidates");
400
401 a_edges.clear();
402
403 const std::size_t nLocal = a_localValid.size();
404 const int K = 1 + a_maxFallbackCandidates;
405
406 // a_maxCellDistance's physical bound, computed once per call. The sqrt(SpaceDim) factor keeps
407 // this Euclidean prune no tighter than the per-axis Chebyshev cutoff applied downstream by
408 // resolveTrivialTier()/judgeProposals(), so it never excludes a candidate they would accept
409 // (see this function's own a_maxCellDistance docs).
410 std::optional<Real> maxDistBoundSquared;
411 if (a_maxCellDistance.has_value()) {
412 Real minDx = a_dx[0];
413
414 for (int dir = 1; dir < SpaceDim; dir++) {
415 minDx = std::min(minDx, a_dx[dir]);
416 }
417
418 const Real bound = static_cast<Real>(a_maxCellDistance.value()) * minDx * std::sqrt(static_cast<Real>(SpaceDim));
419 maxDistBoundSquared = bound * bound;
420 }
421
422 struct RankedCandidate
423 {
424 Real d2;
425 std::size_t idx;
426 bool isLocal;
427 };
428
429 for (std::size_t qi = 0; qi < nLocal; qi++) {
430 const MergeParticle<Packed>& q = a_localValid[qi];
431
432 // Lazy deletion -- a_localValid is the FULL per-patch set (see this function's own docs), so
433 // a query already consumed by an earlier pass/query this round is skipped explicitly here.
434 if (a_consumedIDs.count(q.globalID) > 0) {
435 continue;
436 }
437
438 // a_dx is this patch's own level's dx -- correct here since every query in a_localValid is
439 // necessarily on this same level (see this function's a_dx docs).
440 const IntVect qCell = cellKeyOf(q.position, a_probLo, a_dx);
441 const NNCellKey qKey{q.level, qCell};
442 const auto cntIt = a_liveCellCount.find(qKey);
443 const int count = (cntIt != a_liveCellCount.end()) ? cntIt->second : 0;
444
445 if (count <= a_cellBudget(qKey)) {
446 continue;
447 }
448
449 std::vector<RankedCandidate> best;
450 best.reserve(static_cast<std::size_t>(K) + 1);
451
452 auto considerCandidate = [&](const Real a_d2, const std::size_t a_idx, const bool a_isLocal) {
453 if (static_cast<int>(best.size()) == K && a_d2 >= best.back().d2) {
454 return;
455 }
456
457 const auto insertPos = std::upper_bound(best.begin(),
458 best.end(),
459 a_d2,
460 [](const Real a_val, const RankedCandidate& a_c) {
461 return a_val < a_c.d2;
462 });
463
464 best.insert(insertPos, RankedCandidate{a_d2, a_idx, a_isLocal});
465
466 if (static_cast<int>(best.size()) > K) {
467 best.pop_back();
468 }
469 };
470
471 // Read this query's precomputed kNN row out of the point-cloud graph (see
472 // buildNNSpatialIndex()). The query's own cloud index equals qi (local valid points packed
473 // first); allNearestNeighbors() already excluded it as self. Each hit's cloud index decodes
474 // ownership (< pointCloudNLocal => local valid, else ghost); apply the a_maxCellDistance bound
475 // and the a_consumedIDs skip before feeding survivors into the shared top-K logic. Filtering
476 // the distance-sorted k-nearest by the bound yields exactly the within-bound k-nearest.
477 // pointCloud is null only for a patch with no crowded cell, which generates no query.
478 if (a_spatialIndex.pointCloud) {
479 const std::size_t nLocalPC = a_spatialIndex.pointCloudNLocal;
480 const int Kgraph = a_spatialIndex.pointCloudK;
481 const std::size_t rowStart = qi * static_cast<std::size_t>(Kgraph);
482
483 for (int h = 0; h < Kgraph; h++) {
484 const typename Cloud::Hit& hit = a_spatialIndex.pointCloudGraph[rowStart + static_cast<std::size_t>(h)];
485
486 // A hit with distanceSquared == max() is an empty slot (fewer than k neighbors existed).
487 if (hit.distanceSquared == std::numeric_limits<Real>::max()) {
488 break;
489 }
490 const std::size_t c = hit.index;
491 const bool isLocal = c < nLocalPC;
492 const std::size_t idx = isLocal ? c : c - nLocalPC;
493 const Real d2 = hit.distanceSquared;
494
495 if (maxDistBoundSquared.has_value() && d2 > maxDistBoundSquared.value()) {
496 continue;
497 }
498
499 const ParticleID gid = isLocal ? a_localValid[idx].globalID : a_ghosts[idx].globalID;
500
501 if (a_consumedIDs.count(gid) > 0) {
502 continue;
503 }
504
505 considerCandidate(d2, idx, isLocal);
506 }
507 }
508
509 if (best.empty()) {
510 continue;
511 }
512
513 NNMergeEdge<Packed> edge;
514
515 edge.distanceSquared = best[0].d2;
516 edge.queryID = q.globalID;
517 edge.candidateID = best[0].isLocal ? a_localValid[best[0].idx].globalID : a_ghosts[best[0].idx].globalID;
518 edge.candidateIsLocal = best[0].isLocal;
519
520 for (std::size_t k = 1; k < best.size(); k++) {
521 NNFallbackCandidate fc;
522
523 fc.candidateID = best[k].isLocal ? a_localValid[best[k].idx].globalID : a_ghosts[best[k].idx].globalID;
524 fc.distanceSquared = best[k].d2;
525 fc.candidateIsLocal = best[k].isLocal;
526
527 edge.fallbackCandidates.push_back(fc);
528 }
529
530 a_edges.push_back(std::move(edge));
531 }
532}
533
534template <typename Packed>
535inline void
536buildNNCellSpatialIndex(NNCellSpatialIndex& a_index,
537 const std::vector<MergeParticle<Packed>>& a_localValid,
538 const std::vector<MergeParticle<Packed>>& a_ghosts,
539 const RealVect& a_probLo,
540 const RealVect& a_dx,
541 const NNCellBudget& a_cellBudget) noexcept
542{
543 CH_TIME("ParticleManagement::buildNNCellSpatialIndex");
544
545 a_index.clear();
546
547 const std::size_t total = a_localValid.size() + a_ghosts.size();
548 if (total == 0) {
549 return;
550 }
551
552 // Exact per-cell pre-filter, same contract as buildNNSpatialIndex()'s own: build nothing for
553 // this patch unless some cell is over threshold.
554 if (!nnMergeAnyCellCrowded(a_localValid, a_ghosts, a_probLo, a_dx, a_cellBudget)) {
555 return;
556 }
557
558 // Bucket every particle (local and ghost alike) by cell, then build one cloud per occupied
559 // cell -- not just crowded ones, since an under-threshold cell can still be a valid Moore-
560 // neighbor candidate source (see findNearestNeighborCandidatesOneCell()).
561 std::unordered_map<NNWalkCell,
562 std::pair<std::vector<EBGeometry::Vec3T<Real>>, std::vector<NNCellMeta>>,
564 perCell;
565 perCell.reserve(total);
566
567 auto pushPoint = [&](const MergeParticle<Packed>& a_p, const bool a_isLocal) {
568 const NNWalkCell cell = cellKeyOf(a_p.position, a_probLo, a_dx);
569
570 EBGeometry::Vec3T<Real> v(0.0, 0.0, 0.0);
571
572 for (int dir = 0; dir < SpaceDim; dir++) {
573 v[dir] = a_p.position[dir];
574 }
575
576 auto& bucket = perCell[cell];
577
578 bucket.first.push_back(v);
579 bucket.second.push_back(NNCellMeta{a_p.globalID, a_isLocal, a_p.level});
580 };
581
582 for (const auto& p : a_localValid) {
583 pushPoint(p, true);
584 }
585
586 for (const auto& p : a_ghosts) {
587 pushPoint(p, false);
588 }
589
590 a_index.reserve(perCell.size());
591
592 for (auto& kv : perCell) {
593 a_index[kv.first] = std::make_shared<NNCellCloud>(kv.second.first, kv.second.second);
594 }
595}
596
597template <typename Packed>
598inline void
599findNearestNeighborCandidatesOneCell(std::vector<NNMergeEdge<Packed>>& a_edges,
600 const std::vector<MergeParticle<Packed>>& a_localValid,
601 const std::unordered_set<ParticleID>& a_consumedIDs,
602 const NNCellSpatialIndex& a_cellIndex,
603 const std::unordered_map<NNCellKey, int, NNCellKeyHasher>& a_liveCellCount,
604 const RealVect& a_probLo,
605 const RealVect& a_dx,
606 const std::vector<RealVect>& a_dxByLevel,
607 const NNCellBudget& a_cellBudget,
608 const int a_maxFallbackCandidates) noexcept
609{
610 CH_TIME("ParticleManagement::findNearestNeighborCandidatesOneCell");
611
612 a_edges.clear();
613
614 const int K = 1 + a_maxFallbackCandidates;
615
616 struct RankedCandidate
617 {
618 Real d2;
619 ParticleID id;
620 bool isLocal;
621 };
622
623 // The 3^SpaceDim Moore-neighborhood offsets (own cell included), computed once and reused for
624 // every query in this call.
625 std::vector<IntVect> offsets;
626 offsets.reserve(27);
627
628 const Box neighborhood(-IntVect::Unit, IntVect::Unit);
629
630 for (BoxIterator bit(neighborhood); bit.ok(); ++bit) {
631 offsets.push_back(bit());
632 }
633
634 using PCBVH = NNCellCloud;
635
636 for (const MergeParticle<Packed>& q : a_localValid) {
637 // Lazy deletion -- see findNearestNeighborCandidates()'s own docs.
638 if (a_consumedIDs.count(q.globalID) > 0) {
639 continue;
640 }
641
642 const IntVect qCell = cellKeyOf(q.position, a_probLo, a_dx);
643 const NNCellKey qKey{q.level, qCell};
644 const auto cntIt = a_liveCellCount.find(qKey);
645 const int count = (cntIt != a_liveCellCount.end()) ? cntIt->second : 0;
646
647 if (count <= a_cellBudget(qKey)) {
648 continue;
649 }
650
651 EBGeometry::Vec3T<Real> queryVec(0.0, 0.0, 0.0);
652
653 for (int dir = 0; dir < SpaceDim; dir++) {
654 queryVec[dir] = q.position[dir];
655 }
656
657 // Rank the Moore-neighborhood cells by their EXACT minimum possible distance to the query,
658 // ascending. This is a true lower bound (point-to-AABB distance), so once a full top-K set is
659 // found, no cell beyond the current worst-of-K's distance can ever improve it -- see the break
660 // below.
661 std::vector<std::pair<Real, IntVect>> rankedCells;
662 rankedCells.reserve(offsets.size());
663
664 for (const IntVect& offset : offsets) {
665 const IntVect cell = qCell + offset;
666 const Real d2 = (offset == IntVect::Zero) ? 0.0 : nnMergePointToCellDistSquared(q.position, cell, a_probLo, a_dx);
667
668 rankedCells.push_back(std::make_pair(d2, cell));
669 }
670
671 std::sort(rankedCells.begin(),
672 rankedCells.end(),
673 [](const std::pair<Real, IntVect>& a_a, const std::pair<Real, IntVect>& a_b) {
674 if (a_a.first != a_b.first) {
675 return a_a.first < a_b.first;
676 }
677
678 // Deterministic tiebreak -- IntVect::operator< is a component-wise dominance
679 // partial order, not a strict weak ordering (see NNCellKeyHasher's own docs), so a
680 // plain lexicographic compare is used instead.
681 for (int dir = 0; dir < SpaceDim; dir++) {
682 if (a_a.second[dir] != a_b.second[dir]) {
683 return a_a.second[dir] < a_b.second[dir];
684 }
685 }
686
687 return false;
688 });
689
690 std::vector<RankedCandidate> best;
691 best.reserve(static_cast<std::size_t>(K) + 1);
692
693 auto considerCandidate = [&](const Real a_d2, const ParticleID a_id, const bool a_isLocal) {
694 if (static_cast<int>(best.size()) == K && a_d2 >= best.back().d2) {
695 return;
696 }
697
698 const auto insertPos = std::upper_bound(best.begin(),
699 best.end(),
700 a_d2,
701 [](const Real a_val, const RankedCandidate& a_c) {
702 return a_val < a_c.d2;
703 });
704
705 best.insert(insertPos, RankedCandidate{a_d2, a_id, a_isLocal});
706
707 if (static_cast<int>(best.size()) > K) {
708 best.pop_back();
709 }
710 };
711
712 std::vector<typename PCBVH::Hit> hitBuf;
713
714 for (const auto& ranked : rankedCells) {
715 const Real cellDistSquared = ranked.first;
716
717 if (static_cast<int>(best.size()) == K && cellDistSquared > best.back().d2) {
718 break;
719 }
720
721 const auto it = a_cellIndex.find(ranked.second);
722
723 if (it == a_cellIndex.end() || it->second == nullptr) {
724 continue;
725 }
726
727 const PCBVH& cloud = *(it->second);
728
729 // The own cell also matches the query itself, so one extra neighbor is requested and the
730 // self match filtered out below; a neighbor cell can never contain the query, so no extra
731 // slot is needed there.
732 const bool isOwnCell = (ranked.second == qCell);
733 const std::size_t kQuery = isOwnCell ? static_cast<std::size_t>(K + 1) : static_cast<std::size_t>(K);
734
735 hitBuf.resize(kQuery);
736
737 const std::size_t found = cloud.closestPoints(queryVec, kQuery, hitBuf.data());
738
739 for (std::size_t h = 0; h < found; h++) {
740 const typename PCBVH::Hit& hit = hitBuf[h];
741 const NNCellMeta& meta = cloud.metadata(hit.index);
742
743 if (meta.globalID == q.globalID) {
744 continue;
745 }
746
747 if (a_consumedIDs.count(meta.globalID) > 0) {
748 continue;
749 }
750
751 // Own-cell/Moore-adjacency in the query patch's own-level frame does not by itself bound
752 // the Chebyshev distance in the finer-of-the-two-levels frame that resolveTrivialTier()/
753 // judgeProposals() actually enforce (see this function's own docs) -- a same-level
754 // candidate needs no extra check (query and candidate already share one frame), but a
755 // cross-level one is re-checked here, exactly, before ever being considered.
756 if (meta.level != q.level) {
757 const EBGeometry::Vec3T<Real>& candVec = cloud.position(hit.index);
758
759 RealVect candPos(D_DECL(candVec[0], candVec[1], candVec[2]));
760
761 if (nnMergeCrossLevelTooFar(q.position, q.level, candPos, meta.level, a_probLo, a_dxByLevel, 1)) {
762 continue;
763 }
764 }
765
766 considerCandidate(hit.distanceSquared, meta.globalID, meta.isLocal);
767 }
768 }
769
770 if (best.empty()) {
771 continue;
772 }
773
774 NNMergeEdge<Packed> edge;
775
776 edge.distanceSquared = best[0].d2;
777 edge.queryID = q.globalID;
778 edge.candidateID = best[0].id;
779 edge.candidateIsLocal = best[0].isLocal;
780
781 for (std::size_t k = 1; k < best.size(); k++) {
782 NNFallbackCandidate fc;
783
784 fc.candidateID = best[k].id;
785 fc.distanceSquared = best[k].d2;
786 fc.candidateIsLocal = best[k].isLocal;
787
788 edge.fallbackCandidates.push_back(fc);
789 }
790
791 a_edges.push_back(std::move(edge));
792 }
793}
794
795template <typename Packed, typename Combine, typename PosValid, typename Allocator, typename OnMerge>
796inline void
797resolveTrivialTier(std::unordered_map<NNCellKey, int, NNCellKeyHasher>& a_liveCellCount,
798 std::unordered_set<ParticleID>& a_consumedIDs,
799 std::unordered_set<ParticleID>& a_hasOutgoingCommitment,
800 std::vector<NNMergeResult<Packed>>& a_results,
801 const std::vector<NNMergeEdge<Packed>>& a_edges,
802 const std::unordered_map<ParticleID, NNPooledParticle<Packed>>& a_particlesByID,
803 const Combine& a_combine,
804 const NNCellBudget& a_cellBudget,
805 const RealVect& a_probLo,
806 const std::vector<RealVect>& a_dxByLevel,
807 const PosValid& a_isPositionValid,
808 const Allocator& a_allocateID,
809 const int a_maxFallbackCandidates,
810 const std::optional<int> a_maxCellDistance,
811 unsigned long long* a_crossLevelMergeCount,
812 const OnMerge& a_onMergeCommitted) noexcept
813{
814 CH_TIME("ParticleManagement::resolveTrivialTier");
815
816 a_results.clear();
817
818 // 1. Sort a copy of a_edges by (distanceSquared, queryID, candidateID) -- see this function's
819 // docs for why the deterministic tiebreak is required, not optional.
820 std::vector<const NNMergeEdge<Packed>*> sorted;
821 sorted.reserve(a_edges.size());
822
823 for (const auto& e : a_edges) {
824 sorted.push_back(&e);
825 }
826
827 std::sort(sorted.begin(), sorted.end(), [](const NNMergeEdge<Packed>* a_a, const NNMergeEdge<Packed>* a_b) {
828 if (a_a->distanceSquared != a_b->distanceSquared) {
829 return a_a->distanceSquared < a_b->distanceSquared;
830 }
831
832 if (a_a->queryID != a_b->queryID) {
833 return a_a->queryID < a_b->queryID;
834 }
835
836 return a_a->candidateID < a_b->candidateID;
837 });
838
839 for (const NNMergeEdge<Packed>* edgePtr : sorted) {
840 const NNMergeEdge<Packed>& edge = *edgePtr;
841
842 // a. Stale query -- already consumed by an earlier, closer edge this same pass.
843 if (a_consumedIDs.count(edge.queryID) > 0) {
844 continue;
845 }
846
847 const auto queryDataIt = a_particlesByID.find(edge.queryID);
848
849 if (queryDataIt == a_particlesByID.end()) {
850 continue;
851 }
852
853 const MergeParticle<Packed>& query = queryDataIt->second.particle;
854 const bool queryExposed = queryDataIt->second.exposed;
855
856 bool resolved = false; // an outcome (commit, or a legitimate "leave unmatched") was reached
857 bool originalStale = false;
858 bool originalIneligible = false; // too far or invalid position, evaluated on the ORIGINAL candidate
859
860 const int numAttempts = a_maxFallbackCandidates + 1;
861
862 for (int attempt = 0; attempt < numAttempts; attempt++) {
863 // A boundary-exposed query can never merge trivially -- trivialOk requires !queryExposed
864 // unconditionally, so no fallback candidate could ever help. Run the ORIGINAL candidate
865 // (attempt 0) to set originalStale/originalIneligible, then stop rather than walking doomed
866 // fallbacks: each would cost consumed/busy lookups plus, for an alive candidate, a
867 // cross-level distance calc and an a_isPositionValid() call (an EB point query for EB-aware
868 // callers). Outcome is identical -- the query still proposes off its original candidate.
869 if (queryExposed && attempt > 0) {
870 break;
871 }
872
873 ParticleID candidateID;
874 bool candidateIsLocal;
875
876 if (attempt == 0) {
877 candidateID = edge.candidateID;
878 candidateIsLocal = edge.candidateIsLocal;
879 }
880 else {
881 const std::size_t fbIdx = static_cast<std::size_t>(attempt - 1);
882
883 if (fbIdx >= edge.fallbackCandidates.size()) {
884 break;
885 }
886
887 candidateID = edge.fallbackCandidates[fbIdx].candidateID;
888 candidateIsLocal = edge.fallbackCandidates[fbIdx].candidateIsLocal;
889 }
890
891 const bool candidateStale = candidateIsLocal && (a_consumedIDs.count(candidateID) > 0);
892 const bool candidateBusy = candidateIsLocal && !candidateStale &&
893 (a_hasOutgoingCommitment.count(candidateID) > 0);
894
895 const auto candDataIt = a_particlesByID.find(candidateID);
896 const bool candidateExists = candDataIt != a_particlesByID.end();
897
898 // exposed is meaningful only for local candidates (ghosts carry false); the single
899 // particlesByID lookup above now also yields it, so there is no separate exposed map.
900 const bool candidateExposed = candidateIsLocal && candidateExists && candDataIt->second.exposed;
901
902 bool tooFar = false;
903 bool invalidPos = false;
904
905 RealVect mergedPos(D_DECL(0.0, 0.0, 0.0));
906 Real mergedWeight = 0.0;
907
908 if (!candidateStale && candidateExists) {
909 const MergeParticle<Packed>& cand = candDataIt->second.particle;
910
911 if (a_maxCellDistance.has_value()) {
912 tooFar = nnMergeCrossLevelTooFar(query.position,
913 query.level,
914 cand.position,
915 cand.level,
916 a_probLo,
917 a_dxByLevel,
918 a_maxCellDistance.value());
919 }
920
921 if (!tooFar) {
922 nnMergeWeightedCentroid(mergedPos, mergedWeight, query.position, query.weight, cand.position, cand.weight);
923 invalidPos = !a_isPositionValid(mergedPos);
924 }
925 }
926
927 const bool ineligible = tooFar || invalidPos;
928
929 if (attempt == 0) {
930 originalStale = candidateStale;
931 originalIneligible = ineligible;
932 }
933
934 const bool trivialOk = candidateIsLocal && candidateExists && !candidateStale && !candidateBusy &&
935 !candidateExposed && !queryExposed && !ineligible;
936
937 if (trivialOk) {
938 const MergeParticle<Packed>& cand = candDataIt->second.particle;
939
940 // Each participant's OWN level's dx -- see a_dxByLevel's docs. The cell key must match
941 // the one a_liveCellCount was populated under for that participant, so a single shared
942 // dx would break the recheck for every level but one.
943 const NNCellKey qKey(query.level, cellKeyOf(query.position, a_probLo, a_dxByLevel[query.level]));
944 const NNCellKey cKey(cand.level, cellKeyOf(cand.position, a_probLo, a_dxByLevel[cand.level]));
945
946 const auto qCountIt = a_liveCellCount.find(qKey);
947 const auto cCountIt = a_liveCellCount.find(cKey);
948
949 const int qCount = (qCountIt != a_liveCellCount.end()) ? qCountIt->second : 0;
950 const int cCount = (cCountIt != a_liveCellCount.end()) ? cCountIt->second : 0;
951
952 if (qCount > a_cellBudget(qKey) && cCount > a_cellBudget(cKey)) {
953 // d. COMMIT.
954 NNMergeResult<Packed> result;
955
956 result.firstID = query.globalID;
957 result.secondID = cand.globalID;
958
959 result.merged.position = mergedPos;
960 result.merged.weight = mergedWeight;
961 result.merged.globalID = a_allocateID();
962 result.merged.ownerRank = query.ownerRank;
963
964 // Provisional -- placeMergedParticles() determines the TRUE destination level (and
965 // patch/rank) from the merged position via findDestination(), never from this field.
966 result.merged.level = query.level;
967 result.merged.payload = a_combine(query.payload, query.weight, cand.payload, cand.weight);
968
969 a_results.push_back(result);
970
971 a_consumedIDs.insert(query.globalID);
972 a_consumedIDs.insert(cand.globalID);
973
974 a_liveCellCount[qKey] -= 1;
975 a_liveCellCount[cKey] -= 1;
976
977 if (a_crossLevelMergeCount != nullptr && query.level != cand.level) {
978 (*a_crossLevelMergeCount)++;
979 }
980
981 a_onMergeCommitted(query, cand, result.merged);
982
983 resolved = true;
984 break;
985 }
986 else {
987 // e. Threshold recheck failed -- legitimately left unmatched, NOT fallback-eligible
988 // (a different candidate wouldn't change the query's own cell occupancy).
989 resolved = true;
990 break;
991 }
992 }
993 // else: fall through to the next fallback attempt, if any remain.
994 } // end attempt loop
995
996 if (!resolved) {
997 // f./g.: not resolved trivially -- see this function's docs for why only the ORIGINAL
998 // candidate's staleness/ineligibility (not any fallback's) decides whether a proposal is
999 // generated.
1000 if (!originalStale && !originalIneligible) {
1001 a_hasOutgoingCommitment.insert(query.globalID);
1002 }
1003 }
1004 }
1005}
1006
1007template <typename Packed>
1008inline void
1009generateProposals(std::vector<std::vector<NNMergeProposal<Packed>>>& a_proposalsByDestRank,
1010 const std::vector<NNMergeEdge<Packed>>& a_edges,
1011 const std::unordered_set<ParticleID>& a_hasOutgoingCommitment,
1012 const std::unordered_map<ParticleID, NNPooledParticle<Packed>>& a_particlesByID,
1013 const int a_numRanks) noexcept
1014{
1015 CH_TIME("ParticleManagement::generateProposals");
1016
1017 a_proposalsByDestRank.clear();
1018 a_proposalsByDestRank.resize(a_numRanks);
1019
1020 for (const auto& edge : a_edges) {
1021 if (a_hasOutgoingCommitment.count(edge.queryID) == 0) {
1022 continue;
1023 }
1024
1025 const auto sourceIt = a_particlesByID.find(edge.queryID);
1026 const auto targetIt = a_particlesByID.find(edge.candidateID);
1027
1028 if (sourceIt == a_particlesByID.end() || targetIt == a_particlesByID.end()) {
1029 continue;
1030 }
1031
1032 NNMergeProposal<Packed> proposal;
1033
1034 proposal.source = sourceIt->second.particle;
1035 proposal.targetID = edge.candidateID;
1036 proposal.distanceSquared = edge.distanceSquared;
1037
1038 const int destRank = static_cast<int>(targetIt->second.particle.ownerRank);
1039
1040 CH_assert(destRank >= 0 && destRank < a_numRanks);
1041
1042 a_proposalsByDestRank[destRank].push_back(proposal);
1043 }
1044}
1045
1046template <typename Packed, typename Combine, typename PosValid, typename Allocator, typename OnMerge>
1047inline void
1048judgeProposals(std::unordered_map<NNCellKey, int, NNCellKeyHasher>& a_liveCellCount,
1049 std::vector<NNMergeResult<Packed>>& a_results,
1050 std::vector<std::vector<NNMergeVerdict>>& a_verdictsByDestRank,
1051 const std::vector<NNMergeProposal<Packed>>& a_incomingProposals,
1052 const std::unordered_map<ParticleID, NNPooledParticle<Packed>>& a_particlesByID,
1053 const std::unordered_set<ParticleID>& a_consumedIDs,
1054 const std::unordered_set<ParticleID>& a_hasOutgoingCommitment,
1055 const std::map<ParticleID, ParticleID>& a_outgoingTargetOf,
1056 const Combine& a_combine,
1057 const NNCellBudget& a_cellBudget,
1058 const RealVect& a_probLo,
1059 const std::vector<RealVect>& a_dxByLevel,
1060 const PosValid& a_isPositionValid,
1061 const Allocator& a_allocateID,
1062 const std::optional<int> a_maxCellDistance,
1063 unsigned long long* a_crossLevelMergeCount,
1064 const OnMerge& a_onMergeCommitted) noexcept
1065{
1066 CH_TIME("ParticleManagement::judgeProposals");
1067
1068 a_results.clear();
1069 // NOTE: a_verdictsByDestRank is APPENDED to, never resized here -- the caller must have
1070 // already sized it to the total rank count (same convention as placeMergedParticles's
1071 // a_scatterByDestRank).
1072
1073 // 1. Group incoming proposals by target id.
1074 std::map<ParticleID, std::vector<const NNMergeProposal<Packed>*>> byTarget;
1075
1076 for (const auto& p : a_incomingProposals) {
1077 byTarget[p.targetID].push_back(&p);
1078 }
1079
1080 struct TargetDecision
1081 {
1082 ParticleID targetID;
1083 const NNMergeProposal<Packed>* winner;
1084 };
1085
1086 std::vector<TargetDecision> decisions;
1087 decisions.reserve(byTarget.size());
1088
1089 for (auto& kv : byTarget) {
1090 const ParticleID targetID = kv.first;
1091 auto& proposals = kv.second;
1092
1093 const auto targetDataIt = a_particlesByID.find(targetID);
1094
1095 if (targetDataIt == a_particlesByID.end() || a_consumedIDs.count(targetID) > 0) {
1096 // Target no longer alive -- defensive skip, reject every incoming proposal.
1097 for (const NNMergeProposal<Packed>* p : proposals) {
1098 const int destRank = static_cast<int>(p->source.ownerRank);
1099 a_verdictsByDestRank[destRank].push_back(NNMergeVerdict{p->source.globalID, false});
1100 }
1101
1102 continue;
1103 }
1104
1105 const bool targetHasOutgoing = a_hasOutgoingCommitment.count(targetID) > 0;
1106
1107 const NNMergeProposal<Packed>* winner = nullptr;
1108
1109 if (!targetHasOutgoing) {
1110 // 2. Argmin over incoming proposals, deterministic id tiebreak.
1111 for (const NNMergeProposal<Packed>* p : proposals) {
1112 if (winner == nullptr || p->distanceSquared < winner->distanceSquared ||
1113 (p->distanceSquared == winner->distanceSquared && p->source.globalID < winner->source.globalID)) {
1114 winner = p;
1115 }
1116 }
1117 }
1118 else {
1119 // 3. Normally reject everything, UNLESS a mutual-match proposer is present.
1120 const auto ownTargetIt = a_outgoingTargetOf.find(targetID);
1121 if (ownTargetIt != a_outgoingTargetOf.end()) {
1122 const ParticleID ownTarget = ownTargetIt->second;
1123
1124 for (const NNMergeProposal<Packed>* p : proposals) {
1125 if (p->source.globalID == ownTarget) {
1126 if (targetID < ownTarget) {
1127 winner = p;
1128 }
1129
1130 break;
1131 }
1132 }
1133 }
1134 }
1135
1136 if (winner != nullptr) {
1137 decisions.push_back(TargetDecision{targetID, winner});
1138 }
1139
1140 // Reject every OTHER proposal to this target immediately -- a rejection is never
1141 // contingent on processing order, only an ACCEPT is (see rule 4).
1142 for (const NNMergeProposal<Packed>* p : proposals) {
1143 if (p != winner) {
1144 const int destRank = static_cast<int>(p->source.ownerRank);
1145
1146 a_verdictsByDestRank[destRank].push_back(NNMergeVerdict{p->source.globalID, false});
1147 }
1148 }
1149 }
1150
1151 // 4. Resolve all target decisions in ONE globally distance-sorted pass.
1152 std::sort(decisions.begin(), decisions.end(), [](const TargetDecision& a_a, const TargetDecision& a_b) {
1153 if (a_a.winner->distanceSquared != a_b.winner->distanceSquared) {
1154 return a_a.winner->distanceSquared < a_b.winner->distanceSquared;
1155 }
1156
1157 return a_a.targetID < a_b.targetID;
1158 });
1159
1160 for (const TargetDecision& decision : decisions) {
1161 const NNMergeProposal<Packed>& proposal = *decision.winner;
1162 const auto targetIt = a_particlesByID.find(decision.targetID);
1163
1164 CH_assert(targetIt != a_particlesByID.end() && a_consumedIDs.count(decision.targetID) == 0);
1165
1166 const MergeParticle<Packed>& target = targetIt->second.particle;
1167 const MergeParticle<Packed>& source = proposal.source;
1168
1169 const int sourceDestRank = static_cast<int>(source.ownerRank);
1170
1171 // 6. a_maxCellDistance / a_isPositionValid: a physical-validity rule, applies here exactly
1172 // as in resolveTrivialTier(), regardless of the mutual-match exception above.
1173 bool tooFar = false;
1174
1175 if (a_maxCellDistance.has_value()) {
1176 tooFar = nnMergeCrossLevelTooFar(target.position,
1177 target.level,
1178 source.position,
1179 source.level,
1180 a_probLo,
1181 a_dxByLevel,
1182 a_maxCellDistance.value());
1183 }
1184
1185 if (tooFar) {
1186 a_verdictsByDestRank[sourceDestRank].push_back(NNMergeVerdict{source.globalID, false});
1187 continue;
1188 }
1189
1190 RealVect mergedPos(D_DECL(0.0, 0.0, 0.0));
1191 Real mergedWeight = 0.0;
1192
1193 nnMergeWeightedCentroid(mergedPos, mergedWeight, target.position, target.weight, source.position, source.weight);
1194
1195 if (!a_isPositionValid(mergedPos)) {
1196 a_verdictsByDestRank[sourceDestRank].push_back(NNMergeVerdict{source.globalID, false});
1197
1198 continue;
1199 }
1200
1201 // Dynamic crowding recheck for BOTH participants (mirrors resolveTrivialTier()'s symmetric
1202 // recheck -- each participant's OWN level's dx, see a_dxByLevel's docs). The source's cell
1203 // must be rechecked too: an earlier merge this round can drain it below threshold between
1204 // proposal generation and judging, and accepting anyway would violate the crowding trigger on
1205 // the source side. If this rank has no live entry for the source's cell (never shipped here as
1206 // a ghost), sCount defaults to 0 -- rejected -- the same conservative default used for an
1207 // unknown target count.
1208 const NNCellKey tKey(target.level, cellKeyOf(target.position, a_probLo, a_dxByLevel[target.level]));
1209 const NNCellKey sKey(source.level, cellKeyOf(source.position, a_probLo, a_dxByLevel[source.level]));
1210
1211 const auto tCountIt = a_liveCellCount.find(tKey);
1212 const auto sCountIt = a_liveCellCount.find(sKey);
1213
1214 const int tCount = (tCountIt != a_liveCellCount.end()) ? tCountIt->second : 0;
1215 const int sCount = (sCountIt != a_liveCellCount.end()) ? sCountIt->second : 0;
1216
1217 if (tCount <= a_cellBudget(tKey) || sCount <= a_cellBudget(sKey)) {
1218 a_verdictsByDestRank[sourceDestRank].push_back(NNMergeVerdict{source.globalID, false});
1219
1220 continue;
1221 }
1222
1223 // Accept.
1224 NNMergeResult<Packed> result;
1225
1226 result.firstID = target.globalID;
1227 result.secondID = source.globalID;
1228
1229 result.merged.position = mergedPos;
1230 result.merged.weight = mergedWeight;
1231 result.merged.globalID = a_allocateID();
1232 result.merged.ownerRank = target.ownerRank;
1233
1234 // Provisional -- placeMergedParticles() determines the TRUE destination level (and
1235 // patch/rank) from the merged position via findDestination(), never from this field.
1236 result.merged.level = target.level;
1237 result.merged.payload = a_combine(target.payload, target.weight, source.payload, source.weight);
1238
1239 a_results.push_back(result);
1240
1241 // Symmetric decrement (mirrors resolveTrivialTier()'s commit), but only when this rank has a
1242 // live entry for the source's cell (sCountIt found above); otherwise there is nothing local
1243 // to decrement -- the source's owning rank updates its own copy on the accepted verdict.
1244 a_liveCellCount[tKey] -= 1;
1245
1246 if (sCountIt != a_liveCellCount.end()) {
1247 a_liveCellCount[sKey] -= 1;
1248 }
1249
1250 if (a_crossLevelMergeCount != nullptr && target.level != source.level) {
1251 (*a_crossLevelMergeCount)++;
1252 }
1253
1254 a_onMergeCommitted(target, source, result.merged);
1255
1256 a_verdictsByDestRank[sourceDestRank].push_back(NNMergeVerdict{source.globalID, true});
1257 }
1258}
1259
1260inline void
1261applyVerdicts(std::unordered_set<ParticleID>& a_consumedIDs,
1262 const std::vector<NNMergeVerdict>& a_incomingVerdicts) noexcept
1263{
1264 CH_TIME("ParticleManagement::applyVerdicts");
1265
1266 for (const auto& v : a_incomingVerdicts) {
1267 if (v.accepted) {
1268 a_consumedIDs.insert(v.proposerID);
1269 }
1270 }
1271}
1272
1273template <typename Packed>
1274inline void
1275placeMergedParticles(std::map<std::pair<int, unsigned int>, std::vector<MergeParticle<Packed>>>& a_localByPatch,
1276 std::vector<std::vector<MergeParticle<Packed>>>& a_scatterByDestRank,
1277 const std::vector<NNMergeResult<Packed>>& a_results,
1278 const PositionLocator& a_locate,
1279 const int a_thisRank) noexcept
1280{
1281 CH_TIME("ParticleManagement::placeMergedParticles");
1282
1283 a_localByPatch.clear();
1284 // a_scatterByDestRank is APPENDED to, never resized here -- see its own docs.
1285
1286 for (const auto& result : a_results) {
1287 // O(1) point->block query -- see PositionLocator. Never a linear scan over
1288 // patches (the bottleneck this avoids at large patch counts).
1289 const LevelTiles::LevelAndBox loc = a_locate(result.merged.position);
1290
1291 // A merged particle landing outside every known patch (e.g. exactly on/outside the domain
1292 // boundary due to floating-point roundoff in the weighted centroid) is not expected in
1293 // normal operation; MayDay rather than silently dropping mass.
1294 if (!loc.valid) {
1295 MayDay::Error("ParticleManagement::placeMergedParticles -- merged particle position not "
1296 "found in any patch");
1297 }
1298
1299 if (loc.rank == a_thisRank) {
1300 a_localByPatch[std::make_pair(loc.level, loc.gridIndex)].push_back(result.merged);
1301 }
1302 else {
1303 a_scatterByDestRank[loc.rank].push_back(result.merged);
1304 }
1305 }
1306}
1307
1308template <typename P, typename Packed, typename Traits, typename Gather>
1309inline void
1310gatherMergeParticles(ParticleContainer<P, Traits>& a_particles,
1311 std::vector<NNPatchWork>& a_patchWork,
1312 std::vector<std::vector<MergeParticle<Packed>>>& a_patchLocalValid,
1313 std::vector<std::vector<MergeParticle<Packed>>>& a_patchGhosts,
1314 std::unordered_map<ParticleID, NNPooledParticle<Packed>>& a_particlesByID,
1315 std::unordered_map<NNCellKey, int, NNCellKeyHasher>& a_liveCellCount,
1316 const AmrMesh& a_amr,
1317 const std::string& a_realm,
1318 const int a_finestLevel,
1319 const RealVect& a_probLo,
1320 const std::vector<RealVect>& a_dxByLevel,
1321 const int a_ghostWidth,
1322 const Gather& a_gather) noexcept
1323{
1324 CH_TIME("ParticleManagement::gatherMergeParticles");
1325
1326 a_patchWork.clear();
1327 a_patchLocalValid.clear();
1328 a_patchGhosts.clear();
1329 a_particlesByID.clear();
1330 a_liveCellCount.clear();
1331
1332 // Which cells hold a particle that some other box can also see -- the trivial tier's eligibility test.
1333 // Read from the realm, which derives it from the three ghost masks when they are built (see
1334 // Realm::m_particleGhostExposure), rather than re-deriving it here per particle per timestep.
1335 const AMRMask& exposure = a_amr.getParticleGhostExposure(a_realm, a_ghostWidth);
1336
1337 for (int lvl = 0; lvl <= a_finestLevel; lvl++) {
1338 const RealVect& dx = a_dxByLevel[lvl];
1339
1340 const DisjointBoxLayout& dbl = a_amr.getGrids(a_realm)[lvl];
1341
1342 const DataIterator& dit = dbl.dataIterator();
1343 const int nbox = dit.size();
1344
1345 // Serial (no omp): every patch writes into the shared a_particlesByID map and pushes onto the
1346 // shared a_patchWork/a_patchLocalValid/a_patchGhosts vectors. The map in particular is the whole
1347 // point of this pass -- it deduplicates a particle seen as a ghost by several of this rank's own
1348 // patches -- so the sharing cannot be split per thread without changing what it computes.
1349 for (int mybox = 0; mybox < nbox; mybox++) {
1350 const DataIndex& din = dit[mybox];
1351
1352 ParticleSoA<P, Traits>& leaf = a_particles[lvl][din];
1353
1354 std::vector<MergeParticle<Packed>> localValid;
1355 std::vector<MergeParticle<Packed>> ghosts;
1356
1357 localValid.reserve(leaf.size());
1358 ghosts.reserve(leaf.size());
1359
1360 a_particlesByID.reserve(a_particlesByID.size() + leaf.size());
1361
1362 const BaseFab<bool>& exposureDin = (*exposure[lvl])[din];
1363
1364 for (std::size_t i = 0; i < leaf.size(); i++) {
1365 const bool isGhost = leaf.isGhost(i);
1366
1367 int originLevel = lvl;
1368 if (isGhost) {
1369 switch (leaf.ghost(i)) {
1370 case GhostType::Coarse: {
1371 originLevel = lvl - 1;
1372
1373 break;
1374 }
1375 case GhostType::Fine: {
1376 originLevel = lvl + 1;
1377
1378 break;
1379 }
1380 default: {
1381 originLevel = lvl; // GhostType::SameLevel
1382
1383 break;
1384 }
1385 }
1386 }
1387
1388 MergeParticle<Packed> p;
1389
1390 p.position = leaf.position(i);
1391 p.weight = leaf.weight(i);
1392 p.globalID = leaf.particleID(i);
1393 p.ownerRank = leaf.rankID(i);
1394 p.level = originLevel;
1395 p.payload = a_gather(leaf, i);
1396
1397 const IntVect cell = cellKeyOf(p.position, a_probLo, a_dxByLevel[originLevel]);
1398
1399 if (isGhost) {
1400 ghosts.push_back(p);
1401 a_particlesByID[p.globalID] = NNPooledParticle<Packed>{p, false};
1402 }
1403 else {
1404 // A non-ghost particle is resident in this patch, so its cell lies in this box -- which is
1405 // exactly the region the exposure mask is defined over (it carries no ghost cells).
1406 CH_assert(exposureDin.box().contains(cell));
1407
1408 const bool isExposed = exposureDin(cell, 0);
1409
1410 localValid.push_back(p);
1411 a_particlesByID[p.globalID] = NNPooledParticle<Packed>{p, isExposed};
1412 }
1413 }
1414
1415 a_patchWork.push_back(NNPatchWork{lvl, din, dx});
1416 a_patchLocalValid.push_back(std::move(localValid));
1417 a_patchGhosts.push_back(std::move(ghosts));
1418 }
1419 }
1420
1421 // Derived from the pooled a_particlesByID, NOT inline in the gather loop above -- a particle can
1422 // appear as a ghost in several of this rank's own patches at once, so counting per occurrence
1423 // would multiply-count it; particlesByID collapses those to one entry per id.
1424 for (const auto& kv : a_particlesByID) {
1425 const MergeParticle<Packed>& p = kv.second.particle;
1426 const IntVect cell = cellKeyOf(p.position, a_probLo, a_dxByLevel[p.level]);
1427
1428 a_liveCellCount[NNCellKey(p.level, cell)] += 1;
1429 }
1430}
1431
1432template <typename P,
1433 typename Packed,
1434 typename Traits,
1435 typename Combine,
1436 typename Scatter,
1437 typename PosValid,
1438 typename Allocator,
1439 typename OnMerge>
1440inline void
1441finishMergeRound(ParticleContainer<P, Traits>& a_particles,
1442 std::unordered_map<ParticleID, NNPooledParticle<Packed>>& a_particlesByID,
1443 std::unordered_set<ParticleID>& a_consumedIDs,
1444 std::unordered_map<NNCellKey, int, NNCellKeyHasher>& a_liveCellCount,
1445 std::vector<NNMergeResult<Packed>>& a_allResults,
1446 unsigned long long* a_crossLevelMergeCount,
1447 const AmrMesh& a_amr,
1448 const std::string& a_realm,
1449 const NNCellBudget& a_cellBudget,
1450 const Combine& a_combine,
1451 const Scatter& a_scatter,
1452 const Allocator& a_allocateID,
1453 const std::optional<int> a_maxCellDistance,
1454 const PosValid& a_isPositionValid,
1455 const RealVect& a_probLo,
1456 const std::vector<RealVect>& a_dxByLevel,
1457 const std::vector<NNPatchWork>& a_patchWork,
1458 const std::vector<NNMergeEdge<Packed>>& a_pooledEdges,
1459 const std::unordered_set<ParticleID>& a_hasOutgoingCommitment,
1460 const OnMerge& a_onMergeCommitted) noexcept
1461{
1462 CH_TIME("ParticleManagement::finishMergeRound");
1463
1464 const int myRank = procID();
1465 const int numRanks = static_cast<int>(numProc());
1466
1467 // outgoingTargetOf: for every query id with an outgoing commitment, remember its own nearest-
1468 // neighbor candidate id (needed by judgeProposals()'s mutual-match rule). Nothing is consumed
1469 // between the local tier's last pass and here, so a_pooledEdges is still exactly current.
1470 std::map<ParticleID, ParticleID> outgoingTargetOf;
1471
1472 for (const auto& edge : a_pooledEdges) {
1473 if (a_hasOutgoingCommitment.count(edge.queryID) > 0) {
1474 outgoingTargetOf[edge.queryID] = edge.candidateID;
1475 }
1476 }
1477
1478 std::vector<std::vector<NNMergeProposal<Packed>>> proposalsByDestRank;
1479 generateProposals(proposalsByDestRank, a_pooledEdges, a_hasOutgoingCommitment, a_particlesByID, numRanks);
1480
1481 const std::vector<NNMergeProposal<Packed>> incomingProposals = nnMergeExchangeByRank(proposalsByDestRank);
1482
1483 std::vector<NNMergeResult<Packed>> judgeResults;
1484 std::vector<std::vector<NNMergeVerdict>> verdictsByDestRank(numRanks);
1485
1486 judgeProposals(a_liveCellCount,
1487 judgeResults,
1488 verdictsByDestRank,
1489 incomingProposals,
1490 a_particlesByID,
1491 a_consumedIDs,
1492 a_hasOutgoingCommitment,
1493 outgoingTargetOf,
1494 a_combine,
1495 a_cellBudget,
1496 a_probLo,
1497 a_dxByLevel,
1498 a_isPositionValid,
1499 a_allocateID,
1500 a_maxCellDistance,
1501 a_crossLevelMergeCount,
1502 a_onMergeCommitted);
1503
1504 a_allResults.insert(a_allResults.end(), judgeResults.begin(), judgeResults.end());
1505
1506 for (const auto& r : judgeResults) {
1507 a_consumedIDs.insert(r.firstID);
1508 }
1509
1510 const std::vector<NNMergeVerdict> incomingVerdicts = nnMergeExchangeByRank(verdictsByDestRank);
1511
1512 applyVerdicts(a_consumedIDs, incomingVerdicts);
1513
1514 // O(1) point->block query. ParticleContainer::findDestination() delegates to the shared
1515 // LevelTiles::findDestination core, so this is the same mapping remap() routes its own particles
1516 // through -- never a linear scan over patches.
1517 const PositionLocator locate = [&a_particles](const RealVect& a_pos) -> LevelTiles::LevelAndBox {
1518 return a_particles.findDestination(a_pos);
1519 };
1520
1521 std::map<std::pair<int, unsigned int>, std::vector<MergeParticle<Packed>>> localByPatch;
1522 std::vector<std::vector<MergeParticle<Packed>>> scatterByDestRank(numRanks);
1523
1524 placeMergedParticles(localByPatch, scatterByDestRank, a_allResults, locate, myRank);
1525
1526 const std::vector<MergeParticle<Packed>> incomingScattered = nnMergeExchangeByRank(scatterByDestRank);
1527
1528 // ---- Apply everything to the real ParticleSoA containers ----
1529 for (std::size_t pw = 0; pw < a_patchWork.size(); pw++) {
1530 ParticleSoA<P, Traits>& leaf = a_particles[a_patchWork[pw].level][a_patchWork[pw].din];
1531
1532 for (std::size_t i = 0; i < leaf.size();) {
1533 if (!leaf.isGhost(i) && a_consumedIDs.count(leaf.particleID(i)) > 0) {
1534 leaf.remove(i);
1535 }
1536 else {
1537 i++;
1538 }
1539 }
1540 }
1541
1542 a_particles.clearGhostParticles();
1543
1544 for (const auto& kv : localByPatch) {
1545 const int level = kv.first.first;
1546 const unsigned int gridIndex = kv.first.second;
1547
1548 const DataIndex din = a_amr.getLevelTiles(a_realm)[level]->getMyGrids().at(gridIndex);
1549
1550 ParticleSoA<P, Traits>& leaf = a_particles[level][din];
1551
1552 for (const auto& merged : kv.second) {
1553 // Correct the provisional ownerRank to this rank -- see mergeNearestNeighborsTree()'s own
1554 // docs on why this matters (a stale rank would misroute a later merge's verdict).
1555 MergeParticle<Packed> corrected = merged;
1556
1557 corrected.ownerRank = myRank;
1558 a_scatter(leaf, corrected);
1559 }
1560 }
1561
1562 for (const auto& merged : incomingScattered) {
1563 const LevelTiles::LevelAndBox loc = locate(merged.position);
1564
1565 if (!loc.valid || loc.rank != myRank) {
1566 MayDay::Error("ParticleManagement::finishMergeRound -- incoming scattered particle not "
1567 "found in any of this rank's own patches");
1568 }
1569
1570 const DataIndex din = a_amr.getLevelTiles(a_realm)[loc.level]->getMyGrids().at(loc.gridIndex);
1571
1572 MergeParticle<Packed> corrected = merged;
1573 corrected.ownerRank = myRank;
1574
1575 ParticleSoA<P, Traits>& leaf = a_particles[loc.level][din];
1576 a_scatter(leaf, corrected);
1577 }
1578}
1579
1580template <typename P,
1581 typename Packed,
1582 typename Traits,
1583 typename Cloud,
1584 typename Gather,
1585 typename Combine,
1586 typename Scatter,
1587 typename Allocator,
1588 typename PosValid,
1589 typename OnMerge>
1590inline void
1591mergeNearestNeighborsRoundImpl(ParticleContainer<P, Traits>& a_particles,
1592 unsigned long long* a_crossLevelMergeCount,
1593 const AmrMesh& a_amr,
1594 const NNCellBudget& a_cellBudget,
1595 const Gather& a_gather,
1596 const Combine& a_combine,
1597 const Scatter& a_scatter,
1598 const Allocator& a_allocateID,
1599 const bool a_iterateLocalTierToConvergence,
1600 const int a_maxFallbackCandidates,
1601 const std::optional<int> a_maxCellDistance,
1602 const int a_ghostWidth,
1603 const PosValid& a_isPositionValid,
1604 const OnMerge& a_onMergeCommitted)
1605{
1606 CH_TIME("ParticleManagement::mergeNearestNeighborsRoundImpl");
1607
1608 const std::string realm = a_particles.getRealm();
1609 const int finestLevel = a_amr.getFinestLevel();
1610 const RealVect probLo = a_amr.getProbLo();
1611
1612 // Per-level dx, indexed by level -- every cell-key computation below and downstream (in
1613 // resolveTrivialTier()/judgeProposals()) MUST use the dx of the PARTICLE'S OWN level (see
1614 // MergeParticle::level's docs), never a single dx shared across levels. Built here rather than
1615 // held by AmrMesh: unlike the kd merges, which only ever want the dx of the level they are looping
1616 // over, this one is indexed by each PARTICLE's level, so it genuinely needs the whole vector.
1617 std::vector<RealVect> dxByLevel(finestLevel + 1);
1618
1619 for (int lvl = 0; lvl <= finestLevel; lvl++) {
1620 dxByLevel[lvl] = a_amr.getDx()[lvl] * RealVect::Unit;
1621 }
1622
1623 CH_assert(a_ghostWidth >= 1);
1624 const int ghostWidth = a_ghostWidth;
1625
1626 // Ghost particles are assumed ALREADY FILLED by the caller (an a_ghostWidth-cell halo -- one cell
1627 // per unit of a_ghostWidth in every direction, which is what the crowding trigger and the candidate
1628 // search look at) and are used here as candidates. This function does not fill them: filling is the
1629 // caller's responsibility, on equal footing with the caller cell-sorting before a cell-based merge
1630 // (see ItoSolver / ParticleManagement::ParticleMergeKind). A particle ghost mask of this same width
1631 // must have been registered on this realm before the grids were (re)built, and every rank must use
1632 // the same a_ghostWidth (see the a_ghostWidth docs -- it defines boundary-exposure).
1633 std::vector<NNPatchWork> patchWork;
1634 std::vector<std::vector<MergeParticle<Packed>>> patchLocalValid; // parallel to patchWork
1635 std::vector<std::vector<MergeParticle<Packed>>> patchGhosts; // parallel to patchWork
1636 std::unordered_map<ParticleID, NNPooledParticle<Packed>> particlesByID;
1637 std::unordered_map<NNCellKey, int, NNCellKeyHasher> liveCellCount;
1638
1639 gatherMergeParticles<P, Packed, Traits>(a_particles,
1640 patchWork,
1641 patchLocalValid,
1642 patchGhosts,
1643 particlesByID,
1644 liveCellCount,
1645 a_amr,
1646 realm,
1647 finestLevel,
1648 probLo,
1649 dxByLevel,
1650 ghostWidth,
1651 a_gather);
1652
1653 // Backend-specific: one whole-patch point-cloud index (see Cloud) per patch, built once here, per
1654 // round, over the FULL localValid/ghosts sets, before any consumption this round -- and reused
1655 // across every internal a_iterateLocalTierToConvergence pass via lazy deletion (see
1656 // findNearestNeighborCandidates()'s docs).
1657 std::vector<NNSpatialIndex<Packed, Cloud>> patchSpatialIndex;
1658 patchSpatialIndex.reserve(patchWork.size());
1659
1660 for (std::size_t pw = 0; pw < patchWork.size(); pw++) {
1661 NNSpatialIndex<Packed, Cloud> spatialIndex;
1662
1663 buildNNSpatialIndex(spatialIndex,
1664 patchLocalValid[pw],
1665 patchGhosts[pw],
1666 probLo,
1667 patchWork[pw].dx,
1668 a_cellBudget,
1669 1 + a_maxFallbackCandidates);
1670
1671 patchSpatialIndex.push_back(std::move(spatialIndex));
1672 }
1673
1674 std::unordered_set<ParticleID> consumedIDs;
1675 std::unordered_set<ParticleID> hasOutgoingCommitment;
1676
1677 std::vector<NNMergeResult<Packed>> allResults;
1678
1679 // 2. Per-patch candidate search + trivial-tier resolution, pooled rank-wide.
1680 //
1681 // lastPooledEdges deliberately survives past the loop below: it is reused (NOT recomputed) by
1682 // finishMergeRound() afterward. Nothing consumes any particles between the last runLocalTierPass()
1683 // and finishMergeRound(), so the edges are still exactly current; re-running the candidate search
1684 // purely to re-derive them would repeat the most expensive step for no new information.
1685 std::vector<NNMergeEdge<Packed>> lastPooledEdges;
1686
1687 auto runLocalTierPass = [&]() -> bool {
1688 lastPooledEdges.clear();
1689
1690 for (std::size_t pw = 0; pw < patchWork.size(); pw++) {
1691 // patchLocalValid[pw]/patchGhosts[pw] are this patch's FULL sets and patchSpatialIndex[pw]
1692 // was built once over them before the local-tier passes began. findNearestNeighborCandidates()
1693 // filters against consumedIDs itself (see its docs), so nothing is rebuilt or re-filtered here.
1694 std::vector<NNMergeEdge<Packed>> patchEdges;
1695
1696 findNearestNeighborCandidates(patchEdges,
1697 patchLocalValid[pw],
1698 patchGhosts[pw],
1699 consumedIDs,
1700 patchSpatialIndex[pw],
1701 liveCellCount,
1702 probLo,
1703 patchWork[pw].dx,
1704 a_cellBudget,
1705 a_maxFallbackCandidates,
1706 a_maxCellDistance);
1707
1708 lastPooledEdges.insert(lastPooledEdges.end(), patchEdges.begin(), patchEdges.end());
1709 }
1710
1711 std::vector<NNMergeResult<Packed>> passResults;
1712
1713 resolveTrivialTier(liveCellCount,
1714 consumedIDs,
1715 hasOutgoingCommitment,
1716 passResults,
1717 lastPooledEdges,
1718 particlesByID,
1719 a_combine,
1720 a_cellBudget,
1721 probLo,
1722 dxByLevel,
1723 a_isPositionValid,
1724 a_allocateID,
1725 a_maxFallbackCandidates,
1726 a_maxCellDistance,
1727 a_crossLevelMergeCount,
1728 a_onMergeCommitted);
1729
1730 allResults.insert(allResults.end(), passResults.begin(), passResults.end());
1731
1732 return !passResults.empty();
1733 };
1734
1735 if (a_iterateLocalTierToConvergence) {
1736 constexpr int safetyCap = 1000;
1737
1738 for (int iter = 0; iter < safetyCap; iter++) {
1739 // hasOutgoingCommitment must NOT accumulate across internal iterations -- each iteration
1740 // re-derives it from scratch via a fresh candidate search over the currently-alive set.
1741 hasOutgoingCommitment.clear();
1742
1743 if (!runLocalTierPass()) {
1744 break;
1745 }
1746 }
1747 }
1748 else {
1749 runLocalTierPass();
1750 }
1751
1752 // 3-7. Propose/judge/verdict, placement, and container application -- shared with
1753 // mergeNearestNeighborsOneCell() (see finishMergeRound()'s own docs).
1754 finishMergeRound<P, Packed, Traits>(a_particles,
1755 particlesByID,
1756 consumedIDs,
1757 liveCellCount,
1758 allResults,
1759 a_crossLevelMergeCount,
1760 a_amr,
1761 realm,
1762 a_cellBudget,
1763 a_combine,
1764 a_scatter,
1765 a_allocateID,
1766 a_maxCellDistance,
1767 a_isPositionValid,
1768 probLo,
1769 dxByLevel,
1770 patchWork,
1771 lastPooledEdges,
1772 hasOutgoingCommitment,
1773 a_onMergeCommitted);
1774}
1775
1776} // namespace detail
1777
1778template <typename P,
1779 typename Packed,
1780 typename Traits,
1781 typename Gather,
1782 typename Combine,
1783 typename Scatter,
1784 typename Allocator,
1785 typename PosValid,
1786 typename OnMerge>
1787inline void
1789 const AmrMesh& a_amr,
1790 const NNCellBudget& a_cellBudget,
1791 const Gather& a_gather,
1792 const Combine& a_combine,
1793 const Scatter& a_scatter,
1794 const Allocator& a_allocateID,
1795 const bool a_iterateLocalTierToConvergence,
1796 const int a_maxFallbackCandidates,
1797 const std::optional<int> a_maxCellDistance,
1798 const int a_ghostWidth,
1799 const PosValid& a_isPositionValid,
1800 unsigned long long* a_crossLevelMergeCount,
1801 const OnMerge& a_onMergeCommitted)
1802{
1803 CH_TIME("ParticleManagement::mergeNearestNeighborsTree");
1804
1805 detail::mergeNearestNeighborsRoundImpl<P, Packed, Traits, EBGeometry::PointCloudBVH<Real, ParticleID>>(
1806 a_particles,
1807 a_crossLevelMergeCount,
1808 a_amr,
1809 a_cellBudget,
1810 a_gather,
1811 a_combine,
1812 a_scatter,
1813 a_allocateID,
1814 a_iterateLocalTierToConvergence,
1815 a_maxFallbackCandidates,
1816 a_maxCellDistance,
1817 a_ghostWidth,
1818 a_isPositionValid,
1819 a_onMergeCommitted);
1820}
1821
1822template <typename P,
1823 typename Packed,
1824 typename Traits,
1825 typename Gather,
1826 typename Combine,
1827 typename Scatter,
1828 typename Allocator,
1829 typename PosValid,
1830 typename OnMerge>
1831inline void
1833 const AmrMesh& a_amr,
1834 const NNCellBudget& a_cellBudget,
1835 const Gather& a_gather,
1836 const Combine& a_combine,
1837 const Scatter& a_scatter,
1838 const Allocator& a_allocateID,
1839 const bool a_iterateLocalTierToConvergence,
1840 const int a_maxFallbackCandidates,
1841 const std::optional<int> a_maxCellDistance,
1842 const int a_ghostWidth,
1843 const PosValid& a_isPositionValid,
1844 unsigned long long* a_crossLevelMergeCount,
1845 const OnMerge& a_onMergeCommitted)
1846{
1847 CH_TIME("ParticleManagement::mergeNearestNeighborsHash");
1848
1849 detail::mergeNearestNeighborsRoundImpl<P, Packed, Traits, EBGeometry::PointCloudHashGrid<Real, ParticleID>>(
1850 a_particles,
1851 a_crossLevelMergeCount,
1852 a_amr,
1853 a_cellBudget,
1854 a_gather,
1855 a_combine,
1856 a_scatter,
1857 a_allocateID,
1858 a_iterateLocalTierToConvergence,
1859 a_maxFallbackCandidates,
1860 a_maxCellDistance,
1861 a_ghostWidth,
1862 a_isPositionValid,
1863 a_onMergeCommitted);
1864}
1865
1866template <typename P,
1867 typename Packed,
1868 typename Traits,
1869 typename Gather,
1870 typename Combine,
1871 typename Scatter,
1872 typename Allocator,
1873 typename PosValid,
1874 typename OnMerge>
1875inline void
1877 const AmrMesh& a_amr,
1878 const NNCellBudget& a_cellBudget,
1879 const Gather& a_gather,
1880 const Combine& a_combine,
1881 const Scatter& a_scatter,
1882 const Allocator& a_allocateID,
1883 const bool a_iterateLocalTierToConvergence,
1884 const int a_maxFallbackCandidates,
1885 const PosValid& a_isPositionValid,
1886 unsigned long long* a_crossLevelMergeCount,
1887 const OnMerge& a_onMergeCommitted)
1888{
1889 CH_TIME("ParticleManagement::mergeNearestNeighborsOneCell");
1890
1891 using namespace detail;
1892
1893 const std::string realm = a_particles.getRealm();
1894 const int finestLevel = a_amr.getFinestLevel();
1895 const RealVect probLo = a_amr.getProbLo();
1896
1897 // Per-level dx, indexed by level -- see mergeNearestNeighborsRoundImpl()'s own note on why every cell
1898 // key must use the dx of the particle's OWN level, and why it is built here.
1899 std::vector<RealVect> dxByLevel(finestLevel + 1);
1900
1901 for (int lvl = 0; lvl <= finestLevel; lvl++) {
1902 dxByLevel[lvl] = a_amr.getDx()[lvl] * RealVect::Unit;
1903 }
1904
1905 // Structurally fixed at 1 -- findNearestNeighborCandidatesOneCell() only ever visits the query's
1906 // own cell and its Moore neighbors, so a width-1 halo already contains every reachable candidate
1907 // (see this function's own docs).
1908 constexpr int ghostWidth = 1;
1909 const std::optional<int> maxCellDistance = 1;
1910
1911 std::vector<NNPatchWork> patchWork;
1912 std::vector<std::vector<MergeParticle<Packed>>> patchLocalValid;
1913 std::vector<std::vector<MergeParticle<Packed>>> patchGhosts;
1914 std::unordered_map<ParticleID, NNPooledParticle<Packed>> particlesByID;
1915 std::unordered_map<NNCellKey, int, NNCellKeyHasher> liveCellCount;
1916
1917 gatherMergeParticles<P, Packed, Traits>(a_particles,
1918 patchWork,
1919 patchLocalValid,
1920 patchGhosts,
1921 particlesByID,
1922 liveCellCount,
1923 a_amr,
1924 realm,
1925 finestLevel,
1926 probLo,
1927 dxByLevel,
1928 ghostWidth,
1929 a_gather);
1930
1931 // Backend-specific: one PointCloudBVH per OCCUPIED cell (not per patch), built once here, per
1932 // round, over the FULL localValid/ghosts sets -- see buildNNCellSpatialIndex()'s own docs.
1933 std::vector<NNCellSpatialIndex> patchCellSpatialIndex;
1934 patchCellSpatialIndex.reserve(patchWork.size());
1935
1936 for (std::size_t pw = 0; pw < patchWork.size(); pw++) {
1937 NNCellSpatialIndex cellIndex;
1938
1939 buildNNCellSpatialIndex(cellIndex, patchLocalValid[pw], patchGhosts[pw], probLo, patchWork[pw].dx, a_cellBudget);
1940
1941 patchCellSpatialIndex.push_back(std::move(cellIndex));
1942 }
1943
1944 std::unordered_set<ParticleID> consumedIDs;
1945 std::unordered_set<ParticleID> hasOutgoingCommitment;
1946
1947 std::vector<NNMergeResult<Packed>> allResults;
1948 std::vector<NNMergeEdge<Packed>> lastPooledEdges;
1949
1950 auto runLocalTierPass = [&]() -> bool {
1951 lastPooledEdges.clear();
1952
1953 for (std::size_t pw = 0; pw < patchWork.size(); pw++) {
1954 std::vector<NNMergeEdge<Packed>> patchEdges;
1955
1956 findNearestNeighborCandidatesOneCell(patchEdges,
1957 patchLocalValid[pw],
1958 consumedIDs,
1959 patchCellSpatialIndex[pw],
1960 liveCellCount,
1961 probLo,
1962 patchWork[pw].dx,
1963 dxByLevel,
1964 a_cellBudget,
1965 a_maxFallbackCandidates);
1966
1967 lastPooledEdges.insert(lastPooledEdges.end(), patchEdges.begin(), patchEdges.end());
1968 }
1969
1970 std::vector<NNMergeResult<Packed>> passResults;
1971
1972 resolveTrivialTier(liveCellCount,
1973 consumedIDs,
1974 hasOutgoingCommitment,
1975 passResults,
1976 lastPooledEdges,
1977 particlesByID,
1978 a_combine,
1979 a_cellBudget,
1980 probLo,
1981 dxByLevel,
1982 a_isPositionValid,
1983 a_allocateID,
1984 a_maxFallbackCandidates,
1985 maxCellDistance,
1986 a_crossLevelMergeCount,
1987 a_onMergeCommitted);
1988
1989 allResults.insert(allResults.end(), passResults.begin(), passResults.end());
1990
1991 return !passResults.empty();
1992 };
1993
1994 if (a_iterateLocalTierToConvergence) {
1995 constexpr int safetyCap = 1000;
1996
1997 for (int iter = 0; iter < safetyCap; iter++) {
1998 hasOutgoingCommitment.clear();
1999
2000 if (!runLocalTierPass()) {
2001 break;
2002 }
2003 }
2004 }
2005 else {
2006 runLocalTierPass();
2007 }
2008
2009 finishMergeRound<P, Packed, Traits>(a_particles,
2010 particlesByID,
2011 consumedIDs,
2012 liveCellCount,
2013 allResults,
2014 a_crossLevelMergeCount,
2015 a_amr,
2016 realm,
2017 a_cellBudget,
2018 a_combine,
2019 a_scatter,
2020 a_allocateID,
2021 maxCellDistance,
2022 a_isPositionValid,
2023 probLo,
2024 dxByLevel,
2025 patchWork,
2026 lastPooledEdges,
2027 hasOutgoingCommitment,
2028 a_onMergeCommitted);
2029}
2030
2031} // namespace ParticleManagement
2032
2033#include <CD_NamespaceFooter.H>
2034
2035#endif
Declaration of core class for handling AMR-related operations (with embedded boundaries)
Declaration of a distributed, MPI-safe nearest-neighbor particle-merge algorithm.
@ Coarse
Ghost from the next-coarser level (level l-1).
@ Fine
Ghost from the next-finer level (level l+1).
std::int64_t ParticleID
Global particle identifier type (container-owned metadata column; fixed-width for I/O).
Definition CD_ParticleSoA.H:161
Vector< RefCountedPtr< LevelData< BaseFab< bool > > > > AMRMask
Alias for cutting down on the typic of booleans defined over AMR grids.
Definition CD_Realm.H:33
Class for handling spatial operations.
Definition CD_AmrMesh.H:45
RealVect getProbLo() const
Get lower-left corner of computational domain.
Definition CD_AmrMesh.cpp:3042
const Vector< Real > & getDx() const
Get spatial resolutions.
Definition CD_AmrMesh.cpp:3215
int getFinestLevel() const
Get finest grid level.
Definition CD_AmrMesh.cpp:3064
AMR-hierarchy container of computational particles, stored per patch in Struct-of-Arrays form.
Definition CD_ParticleContainer.H:123
std::string getRealm() const
Realm label.
Definition CD_ParticleContainer.H:300
The per-cell particle count this merge drains each cell down to.
Definition CD_NearestNeighborParticleMerge.H:372
Arena-backed Struct-of-Arrays particle container for a single grid patch.
Definition CD_ParticleSoA.H:655
RankID & rankID(const std::size_t a_index) noexcept
Owning rank of particle i (container-owned metadata).
Definition CD_ParticleSoA.H:1279
bool isGhost(const std::size_t a_index) const noexcept
Whether particle i is a ghost particle (any non-Valid designation).
Definition CD_ParticleSoA.H:1331
RealVect position(const std::size_t a_index) const noexcept
Position of particle i as a RealVect (by value, assembled from the scalar columns).
Definition CD_ParticleSoA.H:1188
GhostType & ghost(const std::size_t a_index) noexcept
Ghost designation of particle i (container-owned metadata).
Definition CD_ParticleSoA.H:1305
double & weight(const std::size_t a_index) noexcept
Weight of particle i.
Definition CD_ParticleSoA.H:1222
std::size_t size() const noexcept
Number of particles currently stored.
Definition CD_ParticleSoA.H:882
ParticleID & particleID(const std::size_t a_index) noexcept
Global id of particle i (container-owned metadata).
Definition CD_ParticleSoA.H:1253
void remove(const std::size_t a_index) noexcept
Remove particle i using swap-and-pop (O(1), does NOT preserve order).
Definition CD_ParticleSoA.H:1033
void reserve(const std::size_t a_capacity)
Ensure capacity for at least a_capacity particles (reallocates + moves on growth).
Definition CD_ParticleSoAImplem.H:88
RealVect position(Location::Cell a_location, const VolIndex &a_vof, const EBISBox &a_ebisbox, const Real &a_dx)
Compute the position (ignoring the "origin) of a Vof.
Definition CD_LocationImplem.H:21
IntVect cellKeyOf(const RealVect &a_position, const RealVect &a_probLo, const RealVect &a_dx) noexcept
The unclamped, position-derived cell index a given physical position falls in.
Definition CD_NearestNeighborParticleMergeImplem.H:254
int nnMergeChebyshevCellDistance(const IntVect &a_a, const IntVect &a_b) noexcept
Chebyshev (max-per-axis) distance between two cell-index vectors – see CD_NearestNeighborParticleMerg...
Definition CD_NearestNeighborParticleMergeImplem.H:64
void nnMergeWeightedCentroid(RealVect &a_mergedPos, Real &a_mergedWeight, const RealVect &a_posA, const Real a_weightA, const RealVect &a_posB, const Real a_weightB) noexcept
Weighted-centroid merge of two positions/weights – shared by resolveTrivialTier() and judgeProposals(...
Definition CD_NearestNeighborParticleMergeImplem.H:118
Real nnMergePointToCellDistSquared(const RealVect &a_point, const IntVect &a_cell, const RealVect &a_probLo, const RealVect &a_dx) noexcept
Squared distance from a physical point to a cell's axis-aligned bounding box (0 if the point is insid...
Definition CD_NearestNeighborParticleMergeImplem.H:142
bool nnMergeCrossLevelTooFar(const RealVect &a_posA, const int a_levelA, const RealVect &a_posB, const int a_levelB, const RealVect &a_probLo, const std::vector< RealVect > &a_dxByLevel, const int a_maxCellDistance) noexcept
a_maxCellDistance check for two positions that may be on DIFFERENT AMR levels.
Definition CD_NearestNeighborParticleMergeImplem.H:92
bool nnMergeAnyCellCrowded(const std::vector< MergeParticle< Packed > > &a_localValid, const std::vector< MergeParticle< Packed > > &a_ghosts, const RealVect &a_probLo, const RealVect &a_dx, const NNCellBudget &a_cellBudget) noexcept
Exact per-cell crowding pre-filter shared by buildNNSpatialIndex()/buildNNCellSpatialIndex(): is any ...
Definition CD_NearestNeighborParticleMergeImplem.H:281
Real nnMergeDistSquared(const RealVect &a_a, const RealVect &a_b) noexcept
Squared Euclidean distance between two physical positions.
Definition CD_NearestNeighborParticleMergeImplem.H:43
std::vector< T > nnMergeExchangeByRank(const std::vector< std::vector< T > > &a_sendByRank)
Generic alltoallv-style exchange: send a per-destination-rank bucket of trivially- copyable records,...
Definition CD_NearestNeighborParticleMergeImplem.H:182
std::pair< int, IntVect > NNCellKey
A cell key together with the AMR level it was computed on – see cellKeyOf()'s warning.
Definition CD_NearestNeighborParticleMerge.H:323
Namespace for various particle management tools.
Definition CD_KDParticleMerge.H:33
void mergeNearestNeighborsOneCell(ParticleContainer< P, Traits > &a_particles, const AmrMesh &a_amr, const NNCellBudget &a_cellBudget, const Gather &a_gather, const Combine &a_combine, const Scatter &a_scatter, const Allocator &a_allocateID, const bool a_iterateLocalTierToConvergence=false, const int a_maxFallbackCandidates=0, const PosValid &a_isPositionValid=[](const RealVect &) { return true;}, unsigned long long *a_crossLevelMergeCount=nullptr, const OnMerge &a_onMergeCommitted=[](const MergeParticle< Packed > &, const MergeParticle< Packed > &, const MergeParticle< Packed > &) { })
Run one full round of the one-cell nearest-neighbor merge: the same distributed propose/judge/verdict...
Definition CD_NearestNeighborParticleMergeImplem.H:1876
void mergeNearestNeighborsHash(ParticleContainer< P, Traits > &a_particles, const AmrMesh &a_amr, const NNCellBudget &a_cellBudget, const Gather &a_gather, const Combine &a_combine, const Scatter &a_scatter, const Allocator &a_allocateID, const bool a_iterateLocalTierToConvergence=false, const int a_maxFallbackCandidates=0, const std::optional< int > a_maxCellDistance=std::nullopt, const int a_ghostWidth=1, const PosValid &a_isPositionValid=[](const RealVect &) { return true;}, unsigned long long *a_crossLevelMergeCount=nullptr, const OnMerge &a_onMergeCommitted=[](const MergeParticle< Packed > &, const MergeParticle< Packed > &, const MergeParticle< Packed > &) { })
Run one full round of the nearest-neighbor merge algorithm, identical in every respect to mergeNeares...
Definition CD_NearestNeighborParticleMergeImplem.H:1832
void mergeNearestNeighborsTree(ParticleContainer< P, Traits > &a_particles, const AmrMesh &a_amr, const NNCellBudget &a_cellBudget, const Gather &a_gather, const Combine &a_combine, const Scatter &a_scatter, const Allocator &a_allocateID, const bool a_iterateLocalTierToConvergence=false, const int a_maxFallbackCandidates=0, const std::optional< int > a_maxCellDistance=std::nullopt, const int a_ghostWidth=1, const PosValid &a_isPositionValid=[](const RealVect &) { return true;}, unsigned long long *a_crossLevelMergeCount=nullptr, const OnMerge &a_onMergeCommitted=[](const MergeParticle< Packed > &, const MergeParticle< Packed > &, const MergeParticle< Packed > &) { })
Run one full round (one timestep's worth) of the nearest-neighbor merge algorithm.
Definition CD_NearestNeighborParticleMergeImplem.H:1788
constexpr Real c
Speed of light.
Definition CD_Units.H:40
Result of a point->block query. See findDestination.
Definition CD_LevelTiles.H:123
unsigned int gridIndex
Global grid/box index of the covering box within that level's LevelTiles.
Definition CD_LevelTiles.H:132
int rank
MPI rank owning that box.
Definition CD_LevelTiles.H:137
int level
Owning AMR level (finest tile that contains the point), or -1 if not found.
Definition CD_LevelTiles.H:127
bool valid
True if a covering tile/box was found (false when the point is off-domain).
Definition CD_LevelTiles.H:142
Hash functor for using IntVect tiles as unordered_map keys.
Definition CD_LevelTiles.H:55
Minimal, payload-agnostic description of one particle as input to a distributed merge.
Definition CD_ParticleManagement.H:430