peclet-dem 1.1.0
Performance-portable XPBD Discrete Element Method (Kokkos + ArborX)
Loading...
Searching...
No Matches
sim.hpp
Go to the documentation of this file.
1
10#ifndef DEM_SIM_HPP
11#define DEM_SIM_HPP
12
13#include <algorithm>
14#include <cmath>
15#include <cstdio>
16#include <cstdlib>
17#include <fstream>
18#include <Kokkos_Core.hpp>
19#include <map>
20#include <memory>
21#include <stdexcept>
22#include <string>
23#include <utility>
24#include <vector>
25
26#include "broadphase_arborx.hpp"
28#include "integration.hpp"
29#include "io.hpp"
30#include "narrowphase.hpp"
31#include "output_sdf.hpp"
32#include "particles.hpp"
33#include "peclet/core/common/view.hpp" // peclet::core::toVector — single-copy device View -> host std::vector (S2a)
34#include "peclet/core/geom/scene_builder.hpp"
35#include "periodicity.hpp"
36#include "shape_registry.hpp" // ShapeRegistry (the base class: shapes, shells, P_)
37#include "shapes_portable.hpp"
38#include "sleeping.hpp" // island sleeping / freezing (single-GPU statics)
39#include "solve_driver.hpp" // demSolveContacts + SoloSolveHooks + readInt/readFloat
40#include "solve_driver_force.hpp" // demStepForce + HertzMindlinLaw + demStepHertz
41#include "solver_friction.hpp"
42#include "solver_hertz.hpp"
43#include "solver_multilevel.hpp"
44#include "solver_position.hpp"
45#include "solver_velocity.hpp"
46#include "step_solve.hpp" // demStep / computeOverlapsKokkos / growContactBuffers
47
48#ifdef PECLET_DEM_MPI
49#include "mpi_halo.hpp" // ParticleHalo (gated; default module never includes it)
50#include "step_solve_mpi.hpp" // demStepMpi / demStepHertzMpi + the MPI hook policies
51#endif
52
53namespace peclet::dem {
54
56class Simulation : public ShapeRegistry {
57 public:
58 explicit Simulation(int capacity) {
59 registry().push_back(this);
60 P_.allocate(capacity, capacity * 64, capacity * 16, /*shapes*/ 1, /*shell*/ 1, /*planes*/ 8);
61 // default sphere shape (radius 1) + identity-ish defaults
62 setSphereShape(1.0f);
63 }
65 auto& r = registry();
66 r.erase(std::remove(r.begin(), r.end(), this), r.end());
67 }
68
69 // Teardown safety: the Particles SoA holds Kokkos Views, so they MUST be freed before
70 // Kokkos::finalize (else "deallocated after finalize" aborts). releaseAll() (called from the
71 // module's atexit, before finalize) frees every live Sim's Views, so callers need not `del sim;
72 // gc.collect()` themselves.
73 void releaseViews() {
74 P_ = Particles{};
75#ifdef PECLET_DEM_MPI
76 halo_.reset(); // the halo also owns Kokkos Views (gather/forward buffers + its core
77 // sub-objects') that must be freed before Kokkos::finalize, else "deallocated
78 // after finalize" aborts. Destroying it via the unique_ptr frees them all.
79#endif
80 }
81 static void releaseAll() {
82 for (auto* s : registry())
83 s->releaseViews();
84 }
85 static std::vector<Simulation*>& registry() {
86 static std::vector<Simulation*> r;
87 return r;
88 }
89
115 int addAnalyticWall(const std::vector<int>& nodeInts, const std::vector<float>& nodeReals,
116 int root, bool invert, float restitution, float friction) {
117 namespace g = peclet::core::geom;
118 if (nodeInts.size() % g::kNodeIntStride || nodeReals.size() % g::kNodeRealStride)
119 throw std::runtime_error("add_analytic_wall: node arrays are not a whole number of records");
120 const int n = static_cast<int>(nodeInts.size() / g::kNodeIntStride);
121 if (n == 0 || static_cast<int>(nodeReals.size() / g::kNodeRealStride) != n)
122 throw std::runtime_error("add_analytic_wall: node int/real counts disagree");
123 if (root < 0 || root >= n)
124 throw std::runtime_error("add_analytic_wall: root out of range");
125 const int base = static_cast<int>(wallNodesHost_.size());
126 for (int i = 0; i < n; ++i) {
127 g::ShapeNode<float> nd = g::decodeNode<float>(&nodeInts[i * g::kNodeIntStride],
128 &nodeReals[i * g::kNodeRealStride]);
129 if (nd.kind == g::kGrid)
130 throw std::runtime_error("add_analytic_wall: grid leaves are not supported here");
131 if (nd.kind >= g::kCsgBase) { // rebase child indices into the shared pool
132 nd.aux0 += base;
133 nd.aux1 += base;
134 }
135 wallNodesHost_.push_back(nd);
136 }
137 P_.wallNodes = Kokkos::View<g::ShapeNode<float>*, CpMem>("wallNodes", wallNodesHost_.size());
138 auto hn = Kokkos::create_mirror_view(P_.wallNodes);
139 for (std::size_t i = 0; i < wallNodesHost_.size(); ++i)
140 hn(i) = wallNodesHost_[i];
141 Kokkos::deep_copy(P_.wallNodes, hn);
142
143 WallSdf w{};
144 w.shapeRoot = base + root;
145 w.nodeCount = static_cast<int>(wallNodesHost_.size());
146 // R1: the AUTHORED root transform, kept so setWallTransform composes onto it instead of onto
147 // the previous frame's result (which would compound and drift).
148 wallRootTf_[base + root] = wallNodesHost_[(std::size_t)(base + root)].transform;
149 w.sign = invert ? -1.0f : 1.0f;
150 w.restitution = restitution;
151 w.friction = friction;
152 const int idx = static_cast<int>(wallsHost_.size());
153 wallsHost_.push_back(w);
154 // Every wall's node pointer must be refreshed: the pool View was just reallocated.
155 for (auto& wh : wallsHost_)
156 if (wh.shapeRoot >= 0)
157 wh.nodes = P_.wallNodes.data();
158 P_.numWalls = static_cast<int>(wallsHost_.size());
159 uploadWalls();
161 return idx;
162 }
163
164 void setDomain(float lx, float ly, float lz, bool px, bool py, bool pz) {
165 P_.domain = Domain{F3{0, 0, 0}, F3{lx, ly, lz}, F3{lx, ly, lz}, px, py, pz};
166 P_.skin = 0.1f * P_.globalScale;
167 }
168 // CUDA Simulation::set_domain(min, max): arbitrary origin; keeps the current periodicity flags.
169 void setDomainMinMax(F3 mn, F3 mx) {
170 P_.domain = Domain{mn,
171 mx,
172 F3{mx.x - mn.x, mx.y - mn.y, mx.z - mn.z},
176 P_.skin = 0.1f * P_.globalScale;
177 }
178 void enablePeriodicity(bool x, bool y, bool z) {
179 P_.domain.periodic_x = x;
180 P_.domain.periodic_y = y;
181 P_.domain.periodic_z = z;
182 }
183 // The suite-canonical domain quartet (suite/docs/NAMING.md 1.1): `origin` is the lower corner,
184 // `extent` the SIZE, `periodic` the per-axis flags — the same four names flow, voro and the AMR
185 // octree use. `setDomainCanonical` is what the keyword form of `set_domain` binds to.
186 void setDomainCanonical(F3 extent, F3 origin, bool px, bool py, bool pz) {
187 P_.domain = Domain{origin, F3{origin.x + extent.x, origin.y + extent.y, origin.z + extent.z},
188 extent, px,
189 py, pz};
190 P_.skin = 0.1f * P_.globalScale;
191 }
192 std::tuple<float, float, float> domainOrigin() const {
193 return {P_.domain.min.x, P_.domain.min.y, P_.domain.min.z};
194 }
195 std::tuple<float, float, float> domainExtent() const {
196 return {P_.domain.size.x, P_.domain.size.y, P_.domain.size.z};
197 }
198 std::tuple<bool, bool, bool> domainPeriodic() const {
200 }
201 void setGravity(float gx, float gy, float gz) { P_.gravity = F3{gx, gy, gz}; }
202 std::tuple<float, float, float> gravity() const {
203 return {P_.gravity.x, P_.gravity.y, P_.gravity.z};
204 }
205 void setThermostat(float temperature, float tau, float kB) { // Berendsen; tau=0 disables
206 P_.thermostatTemp = temperature;
207 P_.thermostatTau = tau;
208 P_.thermostatKB = kB;
209 }
210 void setSolverIterations(int pos, int vel) {
213 }
214 // Select the single-GPU collision solves: true (default) = colored Gauss–Seidel for both the
215 // restitution and the overlap solve, false = count-averaged Jacobi (legacy). For A/B validation.
216 void setVelocityUseGS(bool useGS) { P_.velocityUseGS = useGS; }
217 bool velocityUseGS() const { return P_.velocityUseGS; }
224 void setStabilizationMode(const std::string& mode) {
225 if (mode == "off")
227 else if (mode == "onesided")
229 else if (mode == "multilevel")
231 else if (mode == "escalate")
233 else if (mode == "ordered")
235 else
236 throw std::invalid_argument(
237 "set_stabilization: expected 'off', 'onesided', 'multilevel', 'escalate' or "
238 "'ordered'");
239 }
240 std::string stabilizationMode() const {
241 static const char* const names[] = {"off", "onesided", "multilevel", "escalate", "ordered"};
242 return names[P_.stabilizationMode];
243 }
251 void setRestitutionModel(const std::string& model) {
252 if (model == "newton")
254 else if (model == "poisson")
256 else
257 throw std::invalid_argument("set_restitution_model: expected 'newton' or 'poisson'");
258 }
259 std::string restitutionModel() const { return P_.restitutionModel == 1 ? "poisson" : "newton"; }
275 void setSleeping(bool enabled, float threshold_scale = 2.0f, int consecutive = 64,
276 float wake_scale = 40.0f, bool wake_on_lost_contact = false,
277 float immovable_frac = 0.01f) {
278 P_.sleepingEnabled = enabled;
279 if (threshold_scale > 0.0f)
280 P_.sleepScale = threshold_scale;
281 if (consecutive > 0)
282 P_.sleepK = consecutive;
283 if (wake_scale > 0.0f)
284 P_.wakeScale = wake_scale; // hysteresis: wake only well above the residual settling jitter
285 P_.sleepWakeLostContact = wake_on_lost_contact;
286 if (immovable_frac >= 0.0f)
287 P_.sleepImmovableFrac = immovable_frac;
288 }
290 bool sleeping() const { return P_.sleepingEnabled; }
296 void setVerletSkin(float skin_frac) {
297 P_.verletSkinFrac = skin_frac;
298 P_.impNumPairs = -1; // invalidate the cache
299 }
300 float verletSkin() const { return P_.verletSkinFrac; }
305 void setCudaGraphs(bool enabled) { P_.cudaGraphs = enabled; }
306 bool cudaGraphs() const { return P_.cudaGraphs; }
311 void setFusedSweeps(const std::string& mode) {
312 if (mode == "auto")
313 P_.fusedSweeps = -1;
314 else if (mode == "off")
315 P_.fusedSweeps = 0;
316 else if (mode == "on")
317 P_.fusedSweeps = 1;
318 else
319 throw std::invalid_argument("set_fused_sweeps: expected 'auto', 'on' or 'off'");
320 }
321 std::string fusedSweeps() const {
322 return P_.fusedSweeps < 0 ? "auto" : (P_.fusedSweeps == 1 ? "on" : "off");
323 }
328 void setIncrementalColoring(bool enabled) { P_.incrementalColoring = enabled; }
336 void setShellDetection(const std::string& mode) {
337 if (mode == "one_way")
338 P_.shellDetection = 0;
339 else if (mode == "two_way")
340 P_.shellDetection = 1;
341 else
342 throw std::invalid_argument("set_shell_detection: expected 'one_way' or 'two_way'");
343 ensureContactCapacity(); // the setup-time bound doubles for two-way
344 }
345 std::string shellDetection() const { return P_.shellDetection == 1 ? "two_way" : "one_way"; }
347 int numAsleep() {
348 int n = 0;
349 auto a = P_.asleep;
350 Kokkos::parallel_reduce(
351 "peclet::dem::count_asleep", Kokkos::RangePolicy<CpExec>(0, P_.numReal),
352 KOKKOS_LAMBDA(int i, int& acc) { acc += a(i) ? 1 : 0; }, n);
353 return n;
354 }
357 void setHertzMaterial(int mat, float youngs, float poisson) {
358 if (mat < 0 || mat >= 8)
359 throw std::invalid_argument("material id out of range");
360 auto he = Kokkos::create_mirror_view(P_.hertzE);
361 auto hn = Kokkos::create_mirror_view(P_.hertzNu);
362 Kokkos::deep_copy(he, P_.hertzE);
363 Kokkos::deep_copy(hn, P_.hertzNu);
364 he(mat) = youngs;
365 hn(mat) = poisson;
366 Kokkos::deep_copy(P_.hertzE, he);
367 Kokkos::deep_copy(P_.hertzNu, hn);
368 }
371 void stepHertz(int substeps, float skin_frac) {
372 requireDt("step_hertz");
373 demStepHertz(P_, P_.dt, substeps, skin_frac);
374 }
375 void setGlobalScale(float s) {
376 P_.globalScale = s;
377 P_.skin = 0.1f * s;
378 }
379 float globalScale() const { return P_.globalScale; }
382 void setDt(float dt) {
383 if (!(dt > 0.0f))
384 throw std::invalid_argument("set_dt: dt must be > 0 (relax() is the dynamics-free substep)");
385 P_.dt = dt;
386 dtSet_ = true;
387 }
388 float dt() const { return P_.dt; }
389 int capacity() const { return P_.capacity; }
390 // (restitution_normal, restitution_tangent, friction) to match CUDA set_material_params; the
391 // Kokkos pipeline currently carries normal restitution + dynamic friction (tangential restitution
392 // unused).
393 // NOTE (found via peclet-examples/stirred-column, 2026-08-30): the DEFAULT body-body material is
394 // frictionless. add_analytic_wall / add_sdf_wall set the particle-WALL material only, so a bed
395 // more than a few layers deep with the default body-body friction behaves like a liquid -- it
396 // transmits full hydrostatic pressure to the container and the position solve squeezes grains
397 // through the boundary. The failure is silent and looks exactly like a solver-convergence bug
398 // (raising the position iterations 4,4 -> 24,12 and halving dt changed the measured leakage not
399 // at all). Set a non-zero friction here for any deep bed.
400 void setMaterialParams(float restitution_normal, float restitution_tangent, float friction) {
401 P_.restitutionNormal = restitution_normal;
402 P_.frictionDynamic = friction;
403 P_.restitutionTangent = restitution_tangent; // Walton beta (impact law, cone-clamped)
404 }
408 void setMaterialIds(const std::vector<int>& ids) {
409 auto h = Kokkos::create_mirror_view(P_.materialId);
410 Kokkos::deep_copy(h, P_.materialId);
411 for (int i = 0; i < P_.numReal && i < (int)ids.size(); ++i)
412 h(i) = static_cast<unsigned char>(ids[i]);
413 Kokkos::deep_copy(P_.materialId, h);
414 }
417 void setPairMaterial(int a, int b, float restitution, float friction) {
418 if (a < 0 || b < 0 || a >= kMaxMaterials || b >= kMaxMaterials)
419 throw std::invalid_argument("material id out of range");
420 if (P_.pairMaterials.extent(0) == 0) {
422 Kokkos::View<float*, CpMem>("pairMaterials", kMaxMaterials * kMaxMaterials * 2);
423 auto h0 = Kokkos::create_mirror_view(P_.pairMaterials);
424 for (int i = 0; i < kMaxMaterials * kMaxMaterials; ++i) {
425 h0(2 * i) = P_.restitutionNormal;
426 h0(2 * i + 1) = P_.frictionDynamic;
427 }
428 Kokkos::deep_copy(P_.pairMaterials, h0);
429 }
430 auto h = Kokkos::create_mirror_view(P_.pairMaterials);
431 Kokkos::deep_copy(h, P_.pairMaterials);
432 for (auto xy : {std::pair<int, int>{a, b}, std::pair<int, int>{b, a}}) {
433 h((xy.first * kMaxMaterials + xy.second) * 2) = restitution;
434 h((xy.first * kMaxMaterials + xy.second) * 2 + 1) = friction;
435 }
436 Kokkos::deep_copy(P_.pairMaterials, h);
437 }
439 void setWallMaterialId(int wid, int mat) {
440 if (wid < 0 || wid >= P_.numWalls)
441 throw std::invalid_argument("wall index out of range");
442 auto h = Kokkos::create_mirror_view(P_.walls);
443 Kokkos::deep_copy(h, P_.walls);
444 h(wid).materialId = mat;
445 Kokkos::deep_copy(P_.walls, h);
446 }
447 void addPlane(float px, float py, float pz, float nx, float ny, float nz) {
448 auto h = Kokkos::create_mirror_view(P_.planes);
449 Kokkos::deep_copy(h, P_.planes);
450 if (P_.numPlanes < static_cast<int>(P_.planes.extent(0)))
451 h(P_.numPlanes++) = PlaneP{F3{px, py, pz}, F3{nx, ny, nz}};
452 Kokkos::deep_copy(P_.planes, h);
453 }
454
455 // Add a static, world-space SDF wall/container the grains collide against (a drum barrel, hopper,
456 // vibrating tray). `grid` is a flat [nx*ny*nz] signed-distance field, x-fastest (idx = x + y*nx +
457 // z*nx*ny), sampled at world nodes origin + (x,y,z)*spacing — POSITIVE in the void where the
458 // grains live, NEGATIVE inside the solid wall (so a grain surface point reads the penetration
459 // depth and the outward gradient is the push-out normal). restitution/friction are the binary
460 // particle–wall material (independent of the body-body material). The wall is motionless but
461 // carries a rigid-body surface-velocity field (set via setWallVelocity) so a grain touching it
462 // feels the wall's motion. Returns the wall's index (for setWallVelocity). Add walls before
463 // stepping.
464 int addSdfWall(const std::vector<float>& grid, int nx, int ny, int nz, F3 origin, F3 spacing,
465 float restitution, float friction) {
466 if (nx < 2 || ny < 2 || nz < 2)
467 throw std::runtime_error("addSdfWall: grid dims must be >= 2 on each axis");
468 if (static_cast<long>(nx) * ny * nz != static_cast<long>(grid.size()))
469 throw std::runtime_error("addSdfWall: grid.size() must equal nx*ny*nz");
470
471 WallSdf w{};
472 w.grid.nx = nx;
473 w.grid.ny = ny;
474 w.grid.nz = nz;
475 w.grid.offset = static_cast<int>(wallGridHost_.size());
476 w.grid.origin = toCoreVec(origin);
477 w.grid.invSpacing = peclet::core::Vec3<float>{spacing.x > 0 ? 1.0f / spacing.x : 0.0f,
478 spacing.y > 0 ? 1.0f / spacing.y : 0.0f,
479 spacing.z > 0 ? 1.0f / spacing.z : 0.0f};
480 // A CONTAINER: beyond the stored box is wall-side, so the off-grid residual is SUBTRACTED.
481 w.grid.extension = peclet::core::geom::GridExtension::kContainer;
482 w.restitution = restitution;
483 w.friction = friction;
484 const int idx = static_cast<int>(wallsHost_.size());
485 wallsHost_.push_back(w);
486 wallGridHost_.insert(wallGridHost_.end(), grid.begin(), grid.end());
487 // Upload the concatenated grid samples (only changes when a wall is added, not per step).
488 P_.wallGrid = Kokkos::View<float*, CpMem>("wallGrid", wallGridHost_.size());
489 auto hg = Kokkos::create_mirror_view(P_.wallGrid);
490 for (size_t i = 0; i < wallGridHost_.size(); ++i)
491 hg(i) = wallGridHost_[i];
492 Kokkos::deep_copy(P_.wallGrid, hg);
493 uploadWalls();
494 P_.numWalls = static_cast<int>(wallsHost_.size());
495 if (friction > P_.wallFrictionMax)
496 P_.wallFrictionMax = friction;
498 return idx;
499 }
500
501 // Set a wall's rigid-body surface-velocity field v(x) = linVel + angVel × (x − center). A grain
502 // in contact feels this velocity even though the geometry never moves: set angVel for a rotating
503 // drum (about `center` on the axis), or drive linVel sinusoidally each step for a vibrating wall.
504 // Cheap (a few host scalars); safe to call every step.
505 void setWallVelocity(int wallIndex, F3 linVel, F3 angVel, F3 center) {
506 if (wallIndex < 0 || wallIndex >= static_cast<int>(wallsHost_.size()))
507 throw std::runtime_error("setWallVelocity: wall index out of range");
508 wallsHost_[wallIndex].linVel = linVel;
509 wallsHost_[wallIndex].angVel = angVel;
510 wallsHost_[wallIndex].center = center;
511 uploadWalls();
512 }
513
514 // R1: RIGID-BODY PLACEMENT of an analytic wall -- rotate/translate the GEOMETRY, not just its
515 // surface-velocity field. setWallVelocity alone is enough for an axisymmetric wall (a drum
516 // barrel looks the same at every angle), but a stirrer blade has to actually move. The world
517 // transform W is composed ONTO THE AUTHORED root transform (kept from add_analytic_wall), so
518 // repeated calls place the wall absolutely and never compound; the KB-sized node pool is
519 // re-uploaded, which is the whole cost.
520 //
521 // W is the placement of the authored frame in the world: a probe point p is evaluated as
522 // eval_authored(toCanonical(W, p)). Drive it together with setWallVelocity (v = linVel +
523 // angVel x (p - center)) so the surface velocity a grain feels matches the geometry it touches;
524 // nothing here infers one from the other.
525 void setWallTransform(int wallIndex, F3 translation, float qx, float qy, float qz, float qw) {
526 namespace g = peclet::core::geom;
527 if (wallIndex < 0 || wallIndex >= static_cast<int>(wallsHost_.size()))
528 throw std::runtime_error("setWallTransform: wall index out of range");
529 const int root = wallsHost_[(std::size_t)wallIndex].shapeRoot;
530 if (root < 0)
531 throw std::runtime_error(
532 "setWallTransform: this wall is a sampled GRID SDF, which has no shape tree to place -- "
533 "only analytic walls (add_analytic_wall) can be moved");
534 const auto it = wallRootTf_.find(root);
535 if (it == wallRootTf_.end())
536 throw std::runtime_error("setWallTransform: authored root transform missing (internal)");
537 g::Transform<float> W;
538 W.translation = peclet::core::Vec3<float>{translation.x, translation.y, translation.z};
539 W.rotation = peclet::core::Quat<float>{qx, qy, qz, qw};
540 W.scale = 1.0f;
541 wallNodesHost_[(std::size_t)root].transform =
542 g::SceneBuilder<float>::composeTransform(W, it->second);
543 auto hn = Kokkos::create_mirror_view(P_.wallNodes);
544 for (std::size_t i = 0; i < wallNodesHost_.size(); ++i)
545 hn(i) = wallNodesHost_[i];
546 Kokkos::deep_copy(P_.wallNodes, hn);
547 }
548
549 // Diagnostic/authoring probe: the wall's own SDF at world points (flat [m*3]), sign included --
550 // POSITIVE in the void where the grains live. This is exactly what the narrow phase reads
551 // (sampleWallSdf), so it is the honest way to check a placement or draw a stirrer.
552 std::vector<float> wallSdfAt(int wallIndex, const std::vector<float>& pts) const {
553 if (wallIndex < 0 || wallIndex >= static_cast<int>(wallsHost_.size()))
554 throw std::runtime_error("wall_sdf_at: wall index out of range");
555 const int m = static_cast<int>(pts.size() / 3);
556 std::vector<float> out((std::size_t)m, 0.0f);
557 const WallSdf w = wallsHost_[(std::size_t)wallIndex];
558 Kokkos::View<float*, CpMem> d("wallProbe", std::max(1, m));
559 Kokkos::View<float*, CpMem> q("wallProbePts", std::max(1, 3 * m));
560 auto hq = Kokkos::create_mirror_view(q);
561 for (int i = 0; i < 3 * m; ++i)
562 hq(i) = pts[(std::size_t)i];
563 Kokkos::deep_copy(q, hq);
564 auto grid = P_.wallGrid;
565 Kokkos::parallel_for(
566 "peclet::dem::wall_sdf_probe", Kokkos::RangePolicy<CpExec>(0, m), KOKKOS_LAMBDA(int i) {
567 d(i) = sampleWallSdf(F3{q(3 * i), q(3 * i + 1), q(3 * i + 2)}, w, grid);
568 });
569 Kokkos::fence();
570 auto hd = Kokkos::create_mirror_view(d);
571 Kokkos::deep_copy(hd, d);
572 for (int i = 0; i < m; ++i)
573 out[(std::size_t)i] = hd(i);
574 return out;
575 }
576
577 // positions: flat [n*3]; (re)sets the real-particle count and default state.
578 void setPositions(const std::vector<float>& xyz) {
579 const int n = static_cast<int>(xyz.size() / 3);
580 if (n > P_.capacity)
581 throw std::invalid_argument(
582 "set_positions: " + std::to_string(n) +
583 " particles exceed Simulation(capacity=" + std::to_string(P_.capacity) + ")");
584 P_.numReal = n;
585 P_.numParticles = n;
586 P_.hertzNumPairs = -1; // particle indices changed: invalidate the hertz pair cache
587 P_.hertzPrevCount = 0;
588 P_.prevPairCount = 0; // stale persistent-pair ledger must not warm-start the new set
589#ifdef PECLET_DEM_MPI
590 mpiGidsGlobal_ = false; // new particle set -> re-base the global ids at the next stepMpi
591#endif
592 auto pos = Kokkos::create_mirror_view(P_.pos);
593 auto q = Kokkos::create_mirror_view(P_.quat);
594 auto im = Kokkos::create_mirror_view(P_.invMass);
595 auto sc = Kokkos::create_mirror_view(P_.scale);
596 auto ii = Kokkos::create_mirror_view(P_.invInertia);
597 auto sid = Kokkos::create_mirror_view(P_.shapeId);
598 auto vel = Kokkos::create_mirror_view(P_.vel);
599 auto av = Kokkos::create_mirror_view(P_.angVel);
600 auto gi = Kokkos::create_mirror_view(P_.gid);
601 for (int i = 0; i < n; ++i) {
602 gi(i) = i; // identity global id; the MPI enable re-bases it to a global Exscan offset
603 pos(i, 0) = xyz[3 * i];
604 pos(i, 1) = xyz[3 * i + 1];
605 pos(i, 2) = xyz[3 * i + 2];
606 q(i, 0) = 0;
607 q(i, 1) = 0;
608 q(i, 2) = 0;
609 q(i, 3) = 1;
610 im(i) = 1.0f;
611 sc(i) = 1.0f;
612 sid(i) = 0;
613 ii(i, 0) = defaultInvI_.x;
614 ii(i, 1) = defaultInvI_.y;
615 ii(i, 2) = defaultInvI_.z;
616 vel(i, 0) = vel(i, 1) = vel(i, 2) = 0;
617 av(i, 0) = av(i, 1) = av(i, 2) = 0;
618 }
619 Kokkos::deep_copy(P_.pos, pos);
620 Kokkos::deep_copy(P_.quat, q);
621 Kokkos::deep_copy(P_.invMass, im);
622 Kokkos::deep_copy(P_.scale, sc);
623 Kokkos::deep_copy(P_.invInertia, ii);
624 Kokkos::deep_copy(P_.shapeId, sid);
625 Kokkos::deep_copy(P_.vel, vel);
626 Kokkos::deep_copy(P_.angVel, av);
627 Kokkos::deep_copy(P_.gid, gi);
628 Kokkos::deep_copy(P_.targetScale, P_.scale); // unscaled growth target = the set scale
629 }
630 void setScalesUniform(float s) {
631 auto sc = Kokkos::create_mirror_view(P_.scale);
632 for (int i = 0; i < P_.numReal; ++i)
633 sc(i) = s;
634 Kokkos::deep_copy(P_.scale, sc);
635 Kokkos::deep_copy(P_.targetScale, P_.scale);
636 }
637 // per-particle scales (growth target): flat [n]. scale starts at target (growth factor applies in
638 // step).
639 void setScales(const std::vector<float>& s) {
640 auto tsc = Kokkos::create_mirror_view(P_.targetScale);
641 for (int i = 0; i < P_.numReal && i < (int)s.size(); ++i)
642 tsc(i) = s[i];
643 Kokkos::deep_copy(P_.targetScale, tsc);
644 Kokkos::deep_copy(P_.scale, P_.targetScale);
645 }
646 void setVelocities(const std::vector<float>& v) {
647 auto vel = Kokkos::create_mirror_view(P_.vel);
648 for (int i = 0; i < P_.numReal && 3 * i + 2 < (int)v.size(); ++i) {
649 vel(i, 0) = v[3 * i];
650 vel(i, 1) = v[3 * i + 1];
651 vel(i, 2) = v[3 * i + 2];
652 }
653 Kokkos::deep_copy(P_.vel, vel);
654 }
655 // Per-particle external FORCE (fluid drag etc.), an (N,3) flat array. Applied in the next
656 // step()'s velocity predict as dv = F*invMass*dt. Persists across steps until re-set or cleared.
657 void setExternalForces(const std::vector<float>& f) {
658 auto ef = Kokkos::create_mirror_view(P_.extForce);
659 for (int i = 0; i < P_.numReal && 3 * i + 2 < (int)f.size(); ++i) {
660 ef(i, 0) = f[3 * i];
661 ef(i, 1) = f[3 * i + 1];
662 ef(i, 2) = f[3 * i + 2];
663 }
664 Kokkos::deep_copy(P_.extForce, ef);
665 }
666 void clearExternalForces() { Kokkos::deep_copy(P_.extForce, 0.0f); }
667 // Per-particle external TORQUE in the WORLD frame, an (N,3) flat array. Applied in the next
668 // step()'s angular predict as the body-frame Euler update dw = invI*(tau_body - w x I w)*dt.
669 // Persists across steps until re-set or cleared. Only bodies with a finite inertia (invInertia
670 // > 0, i.e. a registered non-degenerate shape) respond -- a torque on a point mass is silently
671 // inert, exactly as the gyroscopic term is.
672 void setExternalTorques(const std::vector<float>& t) {
673 auto et = Kokkos::create_mirror_view(P_.extTorque);
674 for (int i = 0; i < P_.numReal && 3 * i + 2 < (int)t.size(); ++i) {
675 et(i, 0) = t[3 * i];
676 et(i, 1) = t[3 * i + 1];
677 et(i, 2) = t[3 * i + 2];
678 }
679 Kokkos::deep_copy(P_.extTorque, et);
680 P_.extTorqueActive = true;
681 }
683 Kokkos::deep_copy(P_.extTorque, 0.0f);
684 P_.extTorqueActive = false;
685 }
686 const V3& externalTorquesView() const { return P_.extTorque; }
687 const V3& externalForcesView() const { return P_.extForce; }
688 const Vf& invMassView() const { return P_.invMass; }
689 // rigid-body rotation state (the pipeline integrates the gyroscopic Euler term + quaternion
690 // already)
691 void setQuaternions(const std::vector<float>& q) {
692 auto h = Kokkos::create_mirror_view(P_.quat);
693 for (int i = 0; i < P_.numReal && 4 * i + 3 < (int)q.size(); ++i) {
694 h(i, 0) = q[4 * i];
695 h(i, 1) = q[4 * i + 1];
696 h(i, 2) = q[4 * i + 2];
697 h(i, 3) = q[4 * i + 3];
698 }
699 Kokkos::deep_copy(P_.quat, h);
700 }
701 void setAngularVelocities(const std::vector<float>& w) {
702 auto h = Kokkos::create_mirror_view(P_.angVel);
703 for (int i = 0; i < P_.numReal && 3 * i + 2 < (int)w.size(); ++i) {
704 h(i, 0) = w[3 * i];
705 h(i, 1) = w[3 * i + 1];
706 h(i, 2) = w[3 * i + 2];
707 }
708 Kokkos::deep_copy(P_.angVel, h);
709 }
710 void setInvInertia(const std::vector<float>& ii) {
711 auto h = Kokkos::create_mirror_view(P_.invInertia);
712 for (int i = 0; i < P_.numReal && 3 * i + 2 < (int)ii.size(); ++i) {
713 h(i, 0) = ii[3 * i];
714 h(i, 1) = ii[3 * i + 1];
715 h(i, 2) = ii[3 * i + 2];
716 }
717 Kokkos::deep_copy(P_.invInertia, h);
718 }
719 void setInvMass(const std::vector<float>& im) {
720 auto h = Kokkos::create_mirror_view(P_.invMass);
721 for (int i = 0; i < P_.numReal && i < (int)im.size(); ++i)
722 h(i) = im[i];
723 Kokkos::deep_copy(P_.invMass, h);
724 }
725 std::vector<float> getAngularVelocities() const {
726 return peclet::core::toVector(
727 Kokkos::subview(P_.angVel, Kokkos::make_pair(0, P_.numReal), Kokkos::ALL));
728 }
729 std::vector<float> getInvInertia() const {
730 return peclet::core::toVector(
731 Kokkos::subview(P_.invInertia, Kokkos::make_pair(0, P_.numReal), Kokkos::ALL));
732 }
733 // growth: factor *= exp(rate*dt) per step (capped at 1); new_factor<0 keeps/initialises (0.01 if
734 // inactive).
735 void setGrowthParams(float rate, float new_factor) {
736 if (P_.growthFactor == -1.0f)
737 P_.growthFactor = (new_factor > 0.0f) ? new_factor : 0.01f;
738 else if (new_factor > 0.0f)
739 P_.growthFactor = new_factor;
740 P_.growthRate = rate;
741 if (P_.growthFactor > 0.0f)
743 }
744 float growthFactor() const { return P_.growthFactor; }
745 float getGrowthRate() const { return P_.growthRate; }
746 // per-particle mass = 1/invMass (0 for fixed/infinite-mass particles), CUDA
747 // Simulation::get_masses.
748 std::vector<float> getMasses() const {
749 auto im = Kokkos::create_mirror_view(P_.invMass);
750 Kokkos::deep_copy(im, P_.invMass);
751 std::vector<float> out(P_.numReal);
752 for (int i = 0; i < P_.numReal; ++i)
753 out[i] = (im(i) > 0.0f) ? (1.0f / im(i)) : 0.0f;
754 return out;
755 }
756
757 std::vector<float> getPositions() const {
758 return peclet::core::toVector(
759 Kokkos::subview(P_.pos, Kokkos::make_pair(0, P_.numReal), Kokkos::ALL));
760 }
761 std::vector<float> getVelocities() const {
762 return peclet::core::toVector(
763 Kokkos::subview(P_.vel, Kokkos::make_pair(0, P_.numReal), Kokkos::ALL));
764 }
765 std::vector<float> getQuaternions() const {
766 return peclet::core::toVector(
767 Kokkos::subview(P_.quat, Kokkos::make_pair(0, P_.numReal), Kokkos::ALL));
768 }
769 std::vector<float> getScales() const {
770 return peclet::core::toVector(Kokkos::subview(P_.scale, Kokkos::make_pair(0, P_.numReal)));
771 }
772
774 void step(int n = 1) {
775 requireDt("step");
776 P_.splitStats.mlHubAggregated = 0; // split_stats: max over this call's substeps
777 for (int i = 0; i < n; ++i)
778 demStep(P_);
779 }
783 void relax(int n = 1) {
784 const float dt = P_.dt;
785 P_.dt = 0.0f;
787 for (int i = 0; i < n; ++i)
788 demStep(P_);
789 P_.dt = dt;
790 }
791
792 // Max pair interpenetration on the current committed state (CUDA Simulation::compute_overlaps).
794
795 // LAMMPS "dump custom" of the current committed state (CUDA Simulation::export_lammps). Radius =
796 // scale*globalScale*baseRadius; bounds computed from the particle AABBs.
797 void exportLammps(const std::string& filename, int step) const {
798 const std::vector<float> pos = getPositions(), vel = getVelocities(), quat = getQuaternions();
799 auto sc = Kokkos::create_mirror_view(P_.scale);
800 Kokkos::deep_copy(sc, P_.scale);
801 std::vector<float> radii(P_.numReal);
802 for (int i = 0; i < P_.numReal; ++i)
803 radii[i] = sc(i) * P_.globalScale * baseRadius_;
804 const bool pbc = P_.domain.periodic_x || P_.domain.periodic_y || P_.domain.periodic_z;
805 peclet::dem::writeLammpsDump(filename, step, pos, vel, quat, radii, nullptr, nullptr, pbc);
806 }
807
808 // SDF field over the domain -> ImageData VTI (CUDA Simulation::export_sdf).
809 void exportSdf(const std::string& filename, int rx, int ry, int rz) {
810 const std::vector<float> grid = getSdfGrid(rx, ry, rz);
811 const float mn[3] = {P_.domain.min.x, P_.domain.min.y, P_.domain.min.z};
812 const float mx[3] = {P_.domain.max.x, P_.domain.max.y, P_.domain.max.z};
813 peclet::dem::writeSdfVti(filename, grid, rx, ry, rz, mn, mx);
814 }
815
816#ifdef PECLET_DEM_MPI
817 // Block decomposition over the GLOBAL domain (once); the per-block solver stays non-periodic, the
818 // halo supplies the periodic wrap. gsize is the ORB cell grid. Mirror of Simulation::mpi_init.
819 void initMpi(std::tuple<double, double, double> origin, std::tuple<double, double, double> size,
820 std::tuple<long, long, long> gsize, std::tuple<bool, bool, bool> periodic,
821 MPI_Comm comm) {
822 halo_->initMpi({std::get<0>(origin), std::get<1>(origin), std::get<2>(origin)},
823 {std::get<0>(size), std::get<1>(size), std::get<2>(size)},
824 {std::get<0>(gsize), std::get<1>(gsize), std::get<2>(gsize)},
825 {std::get<0>(periodic), std::get<1>(periodic), std::get<2>(periodic)}, comm);
826 }
827 // Enable the distributed step. rcut is a LOWER bound on the ghost-band width: the XPBD step
828 // uses max(rcut, 2.1 R_max) with R_max the GLOBAL maximum radius, re-evaluated every step
829 // (growth included -- the same band as the single-rank periodic ghosts), so rcut <= 0 means
830 // exactly the contact reach; sync_every: owner/ghost reconciliation interval; conservation is
831 // exact at any value, larger = more lag, fewer messages. rebalance_every: re-decompose by
832 // particle count + migrate ownership every
833 // N distributed steps to keep the per-rank load even as a packing densifies (0 = never; the
834 // partition is then fixed at the initial decomposition, as before). A pure redistribution — the
835 // physics result is unchanged.
836 void enableMpiStep(double rcut, int sync_every = 1, bool forward_rotation = true,
837 int rebalance_every = 0, double verlet_skin = 0.0) {
838 mpiRcut_ = rcut;
839 mpiSyncEvery_ = sync_every < 1 ? 1 : sync_every;
840 mpiForwardRotation_ = forward_rotation;
841 mpiRebalanceEvery_ = rebalance_every < 0 ? 0 : rebalance_every;
842 // Verlet-skin ghost reuse (D2): rebuild the halo topology only when a particle has moved >
843 // skin, instead of every substep. 0 (default) keeps the exact per-substep rebuild.
844 halo_->setVerletSkin(static_cast<float>(verlet_skin));
845 }
846 // Halo rebuild stats (D2): topology rebuilds vs total gather() calls (for benchmarking).
847 long mpiRebuilds() const { return halo_->numRebuilds(); }
848 long mpiGathers() const { return halo_->numGathers(); }
849 // Migrate ownership now so each rank holds a near-equal particle count. Safe to call at a step
850 // boundary; returns this rank's new owned count. Exposed for manual / adaptive balancing.
851 int rebalance() { return halo_->rebalance(P_); }
852 // Co-rebalance: migrate ownership onto the weighted ORB of per-cell weights `w` (the SAME
853 // partition the coupled flow solver redistributes onto from the same weight field), its split
854 // planes on multiples of `align` cells (1 = unaligned, the pre-`align` behaviour bit for bit;
855 // a coupled run passes what flow's rebalance_by_weights returned). Returns new owned count.
856 // Validated here because core's aligned weighted init checks its contract by assert only.
857 int migrateToWeights(const std::vector<peclet::core::Real>& w, int align = 1) {
858 if (!halo_->inited())
859 throw std::runtime_error("migrate_to_weights: call init_mpi first");
860 const auto& G = halo_->decomposer().globalSize();
861 int size = 1;
862 MPI_Comm_size(halo_->comm(), &size);
863 if (align < 1 || (align & (align - 1)) != 0)
864 throw std::invalid_argument("migrate_to_weights: align must be a power of two >= 1, got " +
865 std::to_string(align));
866 double coarseCells = 1.0;
867 std::size_t cells = 1;
868 for (int k = 0; k < 3; ++k) {
869 if (G[k] % align != 0)
870 throw std::invalid_argument("migrate_to_weights: align " + std::to_string(align) +
871 " does not divide the ORB grid (" + std::to_string(G[0]) +
872 ", " + std::to_string(G[1]) + ", " + std::to_string(G[2]) +
873 ")");
874 coarseCells *= static_cast<double>(G[k] / align);
875 cells *= static_cast<std::size_t>(G[k]);
876 }
877 if (coarseCells < static_cast<double>(size))
878 throw std::invalid_argument("migrate_to_weights: align " + std::to_string(align) +
879 " leaves fewer aligned boxes than ranks");
880 if (w.size() != cells)
881 throw std::invalid_argument("migrate_to_weights: weights has " + std::to_string(w.size()) +
882 " entries, the ORB grid has " + std::to_string(cells) + " cells");
883 return halo_->migrateToWeights(P_, w, align);
884 }
885 // The current block decomposition (C++ only: tests compare it with the partition flow builds).
886 const peclet::core::decomp::BlockDecomposer<3>& decomposer() const { return halo_->decomposer(); }
887 // Globally-unique particle ids (persistent-pair and Mindlin-history keys are gid-based):
888 // re-base each rank's identity ids by an exclusive scan of the owned counts, once per particle
889 // set. Migration and rebalance carry gids, so the ids stay stable afterwards; set_positions
890 // resets the flag. Any pre-MPI ledger was keyed with the identity ids, which the re-base makes
891 // stale — cold-start both engines' histories (one soft restart, negligible).
892 void ensureGlobalGids() {
893 if (mpiGidsGlobal_)
894 return;
895 long base = 0, mine = P_.numReal;
896 MPI_Exscan(&mine, &base, 1, MPI_LONG, MPI_SUM, halo_->comm());
897 if (halo_->rank() == 0)
898 base = 0; // MPI_Exscan leaves rank 0's recvbuf undefined
899 fillGidBaseKokkos(P_.gid, P_.numReal, static_cast<int>(base));
900 P_.prevPairCount = 0;
901 P_.hertzPrevCount = 0;
902 P_.hertzNumPairs = -1;
903 mpiGidsGlobal_ = true;
904 }
905 void stepMpi(int nsteps) {
906 requireDt("step_mpi");
907 const double rcut = mpiRcut_; // demStepMpi widens it to the global contact reach
908 ensureGlobalGids();
909 P_.splitStats.mlHubAggregated = 0; // split_stats: max over this call's substeps
910 if (P_.orphanClampCount.data() != nullptr)
911 Kokkos::deep_copy(P_.orphanClampCount, 0); // split_stats.orphanClamps: this call's sum
912 for (int s = 0; s < nsteps; ++s) {
913 if (mpiRebalanceEvery_ > 0 && mpiStepCount_ % mpiRebalanceEvery_ == 0)
914 halo_->rebalance(P_);
915 demStepMpi(P_, *halo_, rcut, mpiSyncEvery_, mpiForwardRotation_);
916 ++mpiStepCount_;
917 }
918 }
923 void stepHertzMpi(int substeps, float skin_frac) {
924 requireDt("step_hertz_mpi");
925 ensureGlobalGids();
926 if (mpiRebalanceEvery_ > 0 && mpiHertzCalls_ % mpiRebalanceEvery_ == 0)
927 halo_->rebalance(P_);
928 ++mpiHertzCalls_;
929 demStepHertzMpi(P_, *halo_, P_.dt, substeps, skin_frac);
930 }
931 int rank() const { return halo_->rank(); }
932 int numGhost() const { return halo_->numGhost(); }
936 void debugCaptureContacts(bool on) { P_.debugCapture = on; }
941 void debugIterationCounters(bool on) { P_.iterCounters = on; }
945 void debugNoAdaptiveStop(bool on) { P_.noAdaptiveStop = on; }
946 const DebugContactCapture& debugCapturedContacts() const { return P_.debugCaptured; }
947#endif // PECLET_DEM_MPI
948
949 // SDF grid (get_sdf_grid): Eikonal reconstruction over the domain, flat x-fastest, negative
950 // inside solid.
951 std::vector<float> getSdfGrid(int rx, int ry, int rz) {
956 }
957
958 int numParticles() const { return P_.numReal; }
959 // Live device Views of the owned particle state, for the zero-copy device-array export (H2): the
960 // binding wraps a [0,numReal) subview as a DLPack/__cuda_array_interface__ array (or a NumPy view
961 // on a host backend) referencing this memory — no device->host copy.
962 const V3& positionsView() const { return P_.pos; }
963 const V3& velocitiesView() const { return P_.vel; }
966 // TEST-ONLY colouring self-check: over the LAST solved substep's colourings, count how many
967 // (manifold, contact) pairs violate the graph-colouring invariant "no two same-colour items share
968 // a body" — must be exactly (0, 0) for a valid colouring (the incremental warm-start path must
969 // never import a conflict). Returns {velocity-colour conflicts, position-colour conflicts}.
970 std::pair<int, int> debugColoringConflicts() {
972 using peclet::dem::CpMem;
973 CpExec space;
974 const int nm = readInt(P_.manifoldCount);
975 const int nc = readInt(P_.contactCount);
976 // Colouring vertices include the hub copy slots (docs/contact_solve_framework.md §4.4), which
977 // sit above the SoA's live span; under MPI the contacts also reference ghost slots.
978 const int nb =
980 std::max(P_.velCopies.nCopies, P_.posCopies.nCopies) + 1;
981 Kokkos::View<std::uint64_t*, CpMem> seen("dbg_color_seen", std::max(nb, P_.capacity + 1));
982 int velConf = 0, posConf = 0;
983 if (nm > 0) {
984 Kokkos::deep_copy(space, seen, std::uint64_t(0));
985 auto manifolds = P_.manifolds;
986 auto realIdx = P_.realIndices;
987 auto mColor = P_.manifoldColor;
989 P_.velCopies.nHubs > 0
992 Kokkos::parallel_reduce(
993 "peclet::dem::dbg_vel_color", Kokkos::RangePolicy<CpExec>(space, 0, nm),
994 KOKKOS_LAMBDA(int idx, int& acc) {
995 const int c = mColor(idx);
996 if (c < 0)
997 return;
998 const auto m = manifolds(idx);
999 const std::uint64_t bit = std::uint64_t(1) << c;
1000 if ((Kokkos::atomic_fetch_or(&seen(ov.slotA(idx, realIdx(m.bodyA))), bit) >> c) & 1)
1001 acc += 1;
1002 if (m.bodyB >= 0 &&
1003 ((Kokkos::atomic_fetch_or(&seen(ov.slotB(idx, realIdx(m.bodyB))), bit) >> c) & 1))
1004 acc += 1;
1005 },
1006 velConf);
1007 }
1008 // Position graph: its edges are the position units (docs/contact_solve_framework.md §4.3),
1009 // each the leader contact's (bodyA, bodyB) -- a unit's contacts share one colour by design.
1010 const int nu = P_.numPosUnits;
1011 if (nc > 0 && nu > 0) {
1012 Kokkos::deep_copy(space, seen, std::uint64_t(0));
1013 auto contacts = P_.contacts;
1014 auto uColor = P_.unitColor;
1016 const peclet::dem::SlotOverride ov =
1017 P_.posCopies.nHubs > 0
1020 Kokkos::parallel_reduce(
1021 "peclet::dem::dbg_pos_color", Kokkos::RangePolicy<CpExec>(space, 0, nu),
1022 KOKKOS_LAMBDA(int idx, int& acc) {
1023 const int c = uColor(idx);
1024 if (c < 0)
1025 return;
1026 const auto ct = contacts(units.leader(idx));
1027 const std::uint64_t bit = std::uint64_t(1) << c;
1028 if ((Kokkos::atomic_fetch_or(&seen(ov.slotA(idx, ct.bodyA)), bit) >> c) & 1)
1029 acc += 1;
1030 if (ct.bodyB >= 0 &&
1031 ((Kokkos::atomic_fetch_or(&seen(ov.slotB(idx, ct.bodyB)), bit) >> c) & 1))
1032 acc += 1;
1033 },
1034 posConf);
1035 }
1036 space.fence();
1037 return {velConf, posConf};
1038 }
1039 // TEST-ONLY: (velocity, position) edges the LAST substep's colourings left uncoloured (-1, or
1040 // -3 = no free colour): manifolds and position units. After hub splitting this is 0 or the step
1041 // threw (the colouring invariant, docs/contact_solve_framework.md §4.2, §12 S3). Single-rank:
1042 // under MPI the non-owned tail of the manifold list keeps stale colours.
1043 std::pair<int, int> debugColoringLeftovers() {
1044 using peclet::dem::CpExec;
1045 CpExec space;
1046 const int nm = readInt(P_.manifoldCount);
1047 const int nu = P_.numPosUnits;
1048 int velLeft = 0, posLeft = 0;
1049 auto mColor = P_.manifoldColor;
1050 auto uColor = P_.unitColor;
1051 if (nm > 0 && P_.velocityUseGS)
1052 Kokkos::parallel_reduce(
1053 "peclet::dem::dbg_vel_left", Kokkos::RangePolicy<CpExec>(space, 0, nm),
1054 KOKKOS_LAMBDA(int i, int& acc) {
1055 acc += (mColor(i) == -1 || mColor(i) == peclet::dem::kColorUncolourable) ? 1 : 0;
1056 },
1057 velLeft);
1058 if (nu > 0)
1059 Kokkos::parallel_reduce(
1060 "peclet::dem::dbg_pos_left", Kokkos::RangePolicy<CpExec>(space, 0, nu),
1061 KOKKOS_LAMBDA(int u, int& acc) {
1062 acc += (uColor(u) == -1 || uColor(u) == peclet::dem::kColorUncolourable) ? 1 : 0;
1063 },
1064 posLeft);
1065 space.fence();
1066 return {velLeft, posLeft};
1067 }
1068 // TEST-ONLY (docs/contact_solve_framework.md §WO-4 item 3): same-colour pairs at an AGGREGATE
1069 // in the last multilevel hierarchy, summed over levels -- a valid coarse colouring is
1070 // group-disjoint per (level, colour). 0 when no hierarchy was built.
1072 using peclet::dem::CpExec;
1073 using peclet::dem::CpMem;
1074 const auto& L = P_.mlLast;
1075 if (L.numLevels <= 0 || L.numManifolds <= 0)
1076 return 0;
1077 CpExec space;
1078 const int nm = L.numManifolds, nb = L.numBodies;
1079 Kokkos::View<int*, CpMem> grp("dbg_ml_grp", nb);
1080 auto realIdx = P_.realIndices;
1081 auto manifolds = P_.manifolds;
1082 auto cp = P_.mlColorPacked;
1083 auto parent = P_.mlParent;
1084 Kokkos::parallel_for(
1085 "peclet::dem::dbg_ml_grp0", Kokkos::RangePolicy<CpExec>(space, 0, nb),
1086 KOKKOS_LAMBDA(int i) { grp(i) = i; });
1087 int total = 0;
1088 for (int lvl = 1; lvl <= L.numLevels; ++lvl) {
1089 const int pOff = L.parentOff[lvl - 1], ng = L.numGroups[lvl - 1];
1090 const int shift = 6 * (lvl - 1);
1091 Kokkos::parallel_for(
1092 "peclet::dem::dbg_ml_compose", Kokkos::RangePolicy<CpExec>(space, 0, nb),
1093 KOKKOS_LAMBDA(int i) { grp(i) = parent(pOff + grp(i)); });
1094 Kokkos::View<std::uint64_t*, CpMem> seen("dbg_ml_seen", ng + 1);
1095 int conf = 0;
1096 Kokkos::parallel_reduce(
1097 "peclet::dem::dbg_ml_color", Kokkos::RangePolicy<CpExec>(space, 0, nm),
1098 KOKKOS_LAMBDA(int idx, int& acc) {
1099 const int c = static_cast<int>((cp(idx) >> shift) & 63);
1100 if (c == peclet::dem::kMlSlotSkip)
1101 return;
1102 const auto m = manifolds(idx);
1103 const int gA = grp(realIdx(m.bodyA));
1104 const int gB = m.bodyB >= 0 ? grp(realIdx(m.bodyB)) : -1;
1105 const std::uint64_t bit = std::uint64_t(1) << c;
1106 if ((Kokkos::atomic_fetch_or(&seen(gA), bit) >> c) & 1)
1107 acc += 1;
1108 if (gB >= 0 && gB != gA && ((Kokkos::atomic_fetch_or(&seen(gB), bit) >> c) & 1))
1109 acc += 1;
1110 },
1111 conf);
1112 total += conf;
1113 }
1114 space.fence();
1115 return total;
1116 }
1117 // diagnostics.split_stats() (docs/contact_solve_framework.md §WO-4 item 4): the last substep's
1118 // body copies.
1121 if (P_.orphanClampCount.data() != nullptr) // device counter (§13.3): read on demand only
1122 Kokkos::deep_copy(st.orphanClamps, P_.orphanClampCount);
1123 return st;
1124 }
1125 float maxOverlap() {
1126 float h;
1127 Kokkos::deep_copy(h, P_.maxOverlap);
1128 return h;
1129 }
1132 std::tuple<double, float, int> restBankStats() {
1134 }
1136 std::tuple<double, float, int> restOrphanStats() {
1138 }
1139
1140 // ParaView PolyData (points + Radius + Velocity), faithful to CUDA Simulation::write_vtp:
1141 // Radius = scale * globalScale * baseRadius.
1142 void writeVtp(const std::string& filename) const {
1143 auto pos = Kokkos::create_mirror_view(P_.pos);
1144 Kokkos::deep_copy(pos, P_.pos);
1145 auto sc = Kokkos::create_mirror_view(P_.scale);
1146 Kokkos::deep_copy(sc, P_.scale);
1147 auto vel = Kokkos::create_mirror_view(P_.vel);
1148 Kokkos::deep_copy(vel, P_.vel);
1149 const int n = P_.numReal;
1150
1151 std::ofstream out(filename);
1152 if (!out)
1153 throw std::runtime_error("Could not open file for writing: " + filename);
1154 out << "<?xml version=\"1.0\"?>\n";
1155 out << "<VTKFile type=\"PolyData\" version=\"0.1\" byte_order=\"LittleEndian\">\n";
1156 out << " <PolyData>\n";
1157 out << " <Piece NumberOfPoints=\"" << n << "\" NumberOfVerts=\"0\" "
1158 << "NumberOfLines=\"0\" NumberOfStrips=\"0\" NumberOfPolys=\"0\">\n";
1159 out << " <Points>\n";
1160 out << " <DataArray type=\"Float32\" Name=\"Position\" NumberOfComponents=\"3\" "
1161 "format=\"ascii\">\n";
1162 for (int i = 0; i < n; ++i)
1163 out << pos(i, 0) << " " << pos(i, 1) << " " << pos(i, 2) << " ";
1164 out << "\n </DataArray>\n";
1165 out << " </Points>\n";
1166 out << " <PointData Scalars=\"Radius\">\n";
1167 out << " <DataArray type=\"Float32\" Name=\"Radius\" NumberOfComponents=\"1\" "
1168 "format=\"ascii\">\n";
1169 for (int i = 0; i < n; ++i)
1170 out << sc(i) * P_.globalScale * baseRadius_ << " ";
1171 out << "\n </DataArray>\n";
1172 out << " <DataArray type=\"Float32\" Name=\"Velocity\" NumberOfComponents=\"3\" "
1173 "format=\"ascii\">\n";
1174 for (int i = 0; i < n; ++i)
1175 out << vel(i, 0) << " " << vel(i, 1) << " " << vel(i, 2) << " ";
1176 out << "\n </DataArray>\n";
1177 out << " </PointData>\n";
1178 out << " </Piece>\n";
1179 out << " </PolyData>\n";
1180 out << "</VTKFile>\n";
1181 out.close();
1182 std::printf("Exported VTP: %s\n", filename.c_str());
1183 }
1184
1185 private:
1186 bool dtSet_ = false; // set_dt was called: every stepper checks it (no silent default)
1187 void requireDt(const char* who) const {
1188 if (!dtSet_)
1189 throw std::runtime_error(std::string(who) +
1190 ": call set_dt(dt) first (there is no default time step)");
1191 }
1192 // (Re)upload just the small WallSdf array (velocity fields change every step for a vibrating
1193 // wall; the grid samples are uploaded once in addSdfWall).
1194 void uploadWalls() {
1195 const int n = std::max<int>(1, static_cast<int>(wallsHost_.size()));
1196 if (static_cast<int>(P_.walls.extent(0)) < n)
1197 P_.walls = Kokkos::View<WallSdf*, CpMem>("walls", n);
1198 auto h = Kokkos::create_mirror_view(P_.walls);
1199 for (size_t i = 0; i < wallsHost_.size(); ++i)
1200 h(i) = wallsHost_[i];
1201 Kokkos::deep_copy(P_.walls, h);
1202 }
1203
1204 std::vector<WallSdf> wallsHost_;
1205 std::vector<float> wallGridHost_;
1206 std::vector<peclet::core::geom::ShapeNode<float>> wallNodesHost_;
1207 // R1: authored root transform per analytic-wall root node (see setWallTransform).
1208 std::map<int, peclet::core::geom::Transform<float>> wallRootTf_;
1209#ifdef PECLET_DEM_MPI
1210 std::unique_ptr<ParticleHalo> halo_ = std::make_unique<ParticleHalo>();
1211 double mpiRcut_ = 0.0;
1212 int mpiSyncEvery_ = 1;
1213 bool mpiForwardRotation_ = true;
1214 int mpiRebalanceEvery_ = 0;
1215 long mpiStepCount_ = 0;
1216 long mpiHertzCalls_ =
1217 0; // step_hertz_mpi call count (rebalance_every cadence for the force path)
1218 bool mpiGidsGlobal_ = false; // gids re-based to a global Exscan offset (once per particle set)
1219#endif
1220};
1221
1222} // namespace peclet::dem
1223
1224#endif // DEM_SIM_HPP
dem — portable (ArborX) broad-phase, the Kokkos-native replacement for the CUDA-only cuBQL broad-phas...
Host-side shape registry (Layer 1).
void setSphereShape(float radius)
Host-facing facade with std::vector setters/getters (binding-agnostic).
Definition sim.hpp:56
void setQuaternions(const std::vector< float > &q)
Definition sim.hpp:691
float computeOverlaps()
Definition sim.hpp:793
void setFusedSweeps(const std::string &mode)
Fused colour sweeps (CUDA): run a whole sweep — and where eligible the whole adaptive iteration loop ...
Definition sim.hpp:311
std::vector< float > getMasses() const
Definition sim.hpp:748
void setThermostat(float temperature, float tau, float kB)
Definition sim.hpp:205
bool cudaGraphs() const
Definition sim.hpp:306
void setWallTransform(int wallIndex, F3 translation, float qx, float qy, float qz, float qw)
Definition sim.hpp:525
bool incrementalColoring() const
Definition sim.hpp:329
void relax(int n=1)
n dynamics-free RELAXATION substeps (overlap removal only, no gravity / velocity update): the growth-...
Definition sim.hpp:783
std::vector< float > getInvInertia() const
Definition sim.hpp:729
std::pair< int, int > debugColoringConflicts()
Definition sim.hpp:970
bool velocityUseGS() const
Definition sim.hpp:217
int numAsleep()
Number of currently-sleeping real bodies (diagnostics / tests).
Definition sim.hpp:347
int capacity() const
Definition sim.hpp:389
std::vector< float > getVelocities() const
Definition sim.hpp:761
static void releaseAll()
Definition sim.hpp:81
void setPairMaterial(int a, int b, float restitution, float friction)
Set the symmetric pair material (restitution, friction) for material ids (a, b).
Definition sim.hpp:417
std::vector< float > getScales() const
Definition sim.hpp:769
std::pair< int, int > debugColoringLeftovers()
Definition sim.hpp:1043
void enablePeriodicity(bool x, bool y, bool z)
Definition sim.hpp:178
std::tuple< double, float, int > restOrphanStats()
Orphan-account diagnostics: (sum, max, count>0) of the per-body orphaned budget.
Definition sim.hpp:1136
void stepHertz(int substeps, float skin_frac)
Advance substeps explicit soft-sphere Hertz-Mindlin steps of the stored dt (device-side loop; the (e,...
Definition sim.hpp:371
peclet::dem::SplitStats debugSplitStats() const
Definition sim.hpp:1119
static std::vector< Simulation * > & registry()
Definition sim.hpp:85
std::tuple< float, float, float > domainExtent() const
Definition sim.hpp:195
void setVelocityUseGS(bool useGS)
Definition sim.hpp:216
std::tuple< float, float, float > domainOrigin() const
Definition sim.hpp:192
const V3 & externalTorquesView() const
Definition sim.hpp:686
Simulation(int capacity)
Definition sim.hpp:58
std::vector< float > getQuaternions() const
Definition sim.hpp:765
std::string stabilizationMode() const
Definition sim.hpp:240
bool sleeping() const
True while island sleeping is enabled.
Definition sim.hpp:290
void setWallVelocity(int wallIndex, F3 linVel, F3 angVel, F3 center)
Definition sim.hpp:505
std::string restitutionModel() const
Definition sim.hpp:259
void exportSdf(const std::string &filename, int rx, int ry, int rz)
Definition sim.hpp:809
std::tuple< float, float, float > gravity() const
Definition sim.hpp:202
void writeVtp(const std::string &filename) const
Definition sim.hpp:1142
void setVerletSkin(float skin_frac)
Verlet-cached impulse broadphase (single-GPU, non-periodic; default OFF).
Definition sim.hpp:296
int numParticles() const
Definition sim.hpp:958
void setGlobalScale(float s)
Definition sim.hpp:375
std::tuple< bool, bool, bool > domainPeriodic() const
Definition sim.hpp:198
void addPlane(float px, float py, float pz, float nx, float ny, float nz)
Definition sim.hpp:447
void setVelocities(const std::vector< float > &v)
Definition sim.hpp:646
float growthFactor() const
Definition sim.hpp:744
std::string shellDetection() const
Definition sim.hpp:345
int addSdfWall(const std::vector< float > &grid, int nx, int ny, int nz, F3 origin, F3 spacing, float restitution, float friction)
Definition sim.hpp:464
void setPositions(const std::vector< float > &xyz)
Definition sim.hpp:578
void setIncrementalColoring(bool enabled)
Incremental (warm-started) graph colouring of the contact manifolds (default ON): reuse last substep'...
Definition sim.hpp:328
void setShellDetection(const std::string &mode)
Narrow-phase shell detection of the XPBD engine (step / step_mpi / relax / compute_overlaps; the Hert...
Definition sim.hpp:336
float globalScale() const
Definition sim.hpp:379
void setSleeping(bool enabled, float threshold_scale=2.0f, int consecutive=64, float wake_scale=40.0f, bool wake_on_lost_contact=false, float immovable_frac=0.01f)
Island sleeping / freezing (single-GPU statics, default ON; enabled=False disables).
Definition sim.hpp:275
void setInvInertia(const std::vector< float > &ii)
Definition sim.hpp:710
const Vf & invMassView() const
Definition sim.hpp:688
void setDt(float dt)
The time step every stepper uses (suite/docs/NAMING.md 1.5).
Definition sim.hpp:382
void setAngularVelocities(const std::vector< float > &w)
Definition sim.hpp:701
const V3 & externalForcesView() const
Definition sim.hpp:687
void setDomainCanonical(F3 extent, F3 origin, bool px, bool py, bool pz)
Definition sim.hpp:186
void setHertzMaterial(int mat, float youngs, float poisson)
Per-material Young's modulus + Poisson ratio for the Hertz-Mindlin engine (material ids as in setMate...
Definition sim.hpp:357
void clearExternalForces()
Definition sim.hpp:666
std::vector< float > getAngularVelocities() const
Definition sim.hpp:725
void setScalesUniform(float s)
Definition sim.hpp:630
std::vector< float > getSdfGrid(int rx, int ry, int rz)
Definition sim.hpp:951
void clearExternalTorques()
Definition sim.hpp:682
void setCudaGraphs(bool enabled)
CUDA-graph replay of the solver's iteration loops (single-rank CUDA; default ON).
Definition sim.hpp:305
void setExternalTorques(const std::vector< float > &t)
Definition sim.hpp:672
void setDomain(float lx, float ly, float lz, bool px, bool py, bool pz)
Definition sim.hpp:164
int debugMultilevelColoringConflicts()
Definition sim.hpp:1071
void setSolverIterations(int pos, int vel)
Definition sim.hpp:210
const V3 & velocitiesView() const
Definition sim.hpp:963
void setExternalForces(const std::vector< float > &f)
Definition sim.hpp:657
std::vector< float > getPositions() const
Definition sim.hpp:757
std::tuple< double, float, int > restBankStats()
Poisson-restitution diagnostics: (sum, max, count>0) of the committed per-pair owed separation impuls...
Definition sim.hpp:1132
int addAnalyticWall(const std::vector< int > &nodeInts, const std::vector< float > &nodeReals, int root, bool invert, float restitution, float friction)
Add an ANALYTIC wall from a core shape tree in the flat encoding (Layer 1).
Definition sim.hpp:115
void setMaterialParams(float restitution_normal, float restitution_tangent, float friction)
Definition sim.hpp:400
std::string fusedSweeps() const
Definition sim.hpp:321
void exportLammps(const std::string &filename, int step) const
Definition sim.hpp:797
float getGrowthRate() const
Definition sim.hpp:745
void setDomainMinMax(F3 mn, F3 mx)
Definition sim.hpp:169
void setMaterialIds(const std::vector< int > &ids)
Per-particle material ids (0..kMaxMaterials-1); pair (e, mu) values come from setPairMaterial.
Definition sim.hpp:408
const V3 & positionsView() const
Definition sim.hpp:962
float verletSkin() const
Definition sim.hpp:300
void setInvMass(const std::vector< float > &im)
Definition sim.hpp:719
void setScales(const std::vector< float > &s)
Definition sim.hpp:639
void setGrowthParams(float rate, float new_factor)
Definition sim.hpp:735
void setGravity(float gx, float gy, float gz)
Definition sim.hpp:201
void setWallMaterialId(int wid, int mat)
Give an SDF wall a material id so particle-wall (e, mu) also resolves via the pair table.
Definition sim.hpp:439
std::vector< float > wallSdfAt(int wallIndex, const std::vector< float > &pts) const
Definition sim.hpp:552
void setStabilizationMode(const std::string &mode)
Select the stabilization pass of the staged velocity solve: "off" (pure symmetric PGS),...
Definition sim.hpp:224
void setRestitutionModel(const std::string &model)
Restitution model of the PGS velocity solve: "newton" (default; per-substep restitution with Moreau's...
Definition sim.hpp:251
float dt() const
Definition sim.hpp:388
void step(int n=1)
Advance n XPBD substeps of the stored dt (set_dt first; suite/docs/NAMING.md 1.5).
Definition sim.hpp:774
dem — portable (Kokkos) contact->manifold reduction, replacing the thrust-based reduce_contacts_to_ma...
dem — portable (Kokkos) time integration kernels (integration.cu).
dem — portable (CUDA-free) I/O helpers for the Kokkos dem module: a LAMMPS dump writer and a scalar-S...
dem — portable (Kokkos) owner<->ghost particle halo for the distributed XPBD step.
constexpr int kMlSlotSkip
void fillGidBaseKokkos(Vi gid, int n, int base)
gid(i) = base + i over [0, n) — the per-rank global-id re-base of the distributed step (namespace sco...
Kokkos::View< float *, CpMem > Vf
float sampleWallSdf(F3 p, const WallSdf &w, GridView grid)
Trilinearly sample a static world-space wall SDF at world point p.
Kokkos::View< float *[3], CpMem > V3
std::vector< float > generateSdfKokkos(int rx, int ry, int rz, F3 dmin, F3 dmax, int numReal, PosView pos, QuatView quat, ScalarF scale, ScalarI shapeId, Kokkos::View< const ShapeDesc *, CpMem > shapes, bool px, bool py, bool pz, GridView sdfGrid=GridView{}, float globalScale=1.0f)
globalScale multiplies every per-particle scale, exactly as the narrow-phase does (narrowphase....
void writeLammpsDump(const std::string &filename, int step, const std::vector< float > &pos, const std::vector< float > &vel, const std::vector< float > &quat, const std::vector< float > &radii, const float *boxMin, const float *boxMax, bool pbcEnabled)
Definition io.hpp:23
int readInt(Kokkos::View< int, CpMem > v)
peclet::core::Vec3< float > toCoreVec(F3 p)
F3 -> core's Real-templated point type. Layout-identical; this is a type change, not a copy.
CpExec::memory_space CpMem
void demStepHertz(Particles &P, float dt, int nsteps, float skinFrac)
nsteps of the soft-sphere Hertz-Mindlin engine — the single-GPU instantiation of the force-based driv...
void updateGrowthScalesKokkos(int n, Vf scale, Vf targetScale, float factor)
Growth mode: scale = target * factor (when active).
float computeOverlapsKokkos(Particles &P)
Max pair interpenetration on the committed state (faithful to CUDA Simulation::compute_overlaps): cop...
void demStep(Particles &P)
One full XPBD DEM substep over the particle SoA (mirrors simulation.cpp Simulation::step()).
constexpr int kColorUncolourable
std::tuple< double, float, int > restBankStatsKokkos(Kokkos::View< const float *, CpMem > bank, int n)
Poisson-restitution diagnostics: (sum, max, count>0) over the committed owed-impulse store (namespace...
constexpr int kMaxMaterials
Pair-material lookup: flat [K*K*2] table, entry ((a*K + b)*2) = restitution, +1 = friction.
Kokkos::DefaultExecutionSpace CpExec
void writeSdfVti(const std::string &filename, const std::vector< float > &grid, int rx, int ry, int rz, const float *minB, const float *maxB)
Definition io.hpp:86
dem — portable (Kokkos) narrow-phase: SDF point-shell collision + boundary planes.
dem — portable (Kokkos) SDF-grid reconstruction: the get_sdf_grid pipeline.
dem — portable (Kokkos) particle SoA container: the storage the dem flip pivots on.
dem — portable (Kokkos) periodic ghost generation (periodicity.cu / integration.cu).
int MPI_Exscan(const void *a, void *b, int c, MPI_Datatype d, MPI_Op e, MPI_Comm f)
Definition pmpi.cpp:22
dem — the shape registry: the host-side authoritative list of particle shapes (analytic sphere / holl...
dem — portable (host) surface-shell point generators for the analytic shapes.
dem — island sleeping / freezing for the single-GPU PGS statics path.
dem — the shared contact-solve driver: the full modern velocity + position solve sequence (warm-start...
dem — the force-based DEM step driver: explicit soft-contact time stepping (the engine family that co...
dem — portable (Kokkos) Coulomb friction cluster (the single dissipative friction path).
Soft-sphere Hertz–Mindlin DEM (reference force model) for SPHERES.
dem — multilevel (GraphMG-style) momentum-conserving contact stabilization.
dem — portable (Kokkos) XPBD position solve (pure overlap removal).
dem — portable (Kokkos) manifold velocity solve (normal restitution impulse).
dem — the single-rank step drivers over the particle SoA: the XPBD substep (demStep),...
dem — the distributed step drivers (gated PECLET_DEM_MPI): the hook policies that turn the shared sol...
Kokkos::View< WallSdf *, CpMem > walls
Kokkos::View< float *, CpMem > hertzE
Kokkos::View< unsigned char *, CpMem > asleep
Kokkos::View< unsigned char *, CpMem > materialId
Kokkos::View< int, CpMem > manifoldCount
Kokkos::View< int *, CpMem > unitStart
Kokkos::View< ManifoldC *, CpMem > manifolds
Kokkos::View< int, CpMem > contactCount
DebugContactCapture debugCaptured
Kokkos::View< float *, CpMem > pairMaterials
Kokkos::View< ShapeDesc *, CpMem > shapes
Kokkos::View< float *, CpMem > prevRestBank
Kokkos::View< peclet::core::geom::ShapeNode< float > *, CpMem > wallNodes
Kokkos::View< float *, CpMem > hertzNu
struct peclet::dem::Particles::MlLast mlLast
Kokkos::View< float *, CpMem > bodyOrphan
Kokkos::View< int *, CpMem > mlParent
Kokkos::View< float *, CpMem > sdfGrid
Kokkos::View< int *, CpMem > unitColor
Kokkos::View< float, CpMem > maxOverlap
Kokkos::View< long long *, CpMem > mlColorPacked
Kokkos::View< float *, CpMem > wallGrid
Kokkos::View< int, CpMem > orphanClampCount
void allocate(int cap, int maxPairs_, int maxContacts_, int nShapes, int nShell, int nPlanes)
Kokkos::View< ContactC *, CpMem > contacts
Kokkos::View< int *, CpMem > unitContacts
Kokkos::View< PlaneP *, CpMem > planes
Kokkos::View< int *, CpMem > manifoldColor
Kokkos::View< int *, CpMem > slotB
Definition particles.hpp:47
Kokkos::View< int *, CpMem > slotA
Definition particles.hpp:47
The position units of a contact list as a CSR (§4.3): unit u owns the contacts list(start(u)) .
Per-edge slot overrides of a phase's hub copies (docs/contact_solve_framework.md §4....
int slotA(int e, int def) const
int slotB(int e, int def) const
diagnostics.split_stats() (docs/contact_solve_framework.md §WO-4 item 4, §13.5 WO-4b): the last subst...
Definition particles.hpp:66
Static, world-space SDF container/geometry the particles collide against (a drum barrel,...
peclet::core::geom::GridDesc< float > grid