peclet-dem 1.1.0
Performance-portable XPBD Discrete Element Method (Kokkos + ArborX)
Loading...
Searching...
No Matches
dem_bindings.cpp
Go to the documentation of this file.
1
26#include <nanobind/nanobind.h>
27#include <nanobind/ndarray.h>
28#include <nanobind/stl/array.h>
29#include <nanobind/stl/optional.h>
30#include <nanobind/stl/pair.h>
31#include <nanobind/stl/string.h>
32#include <nanobind/stl/tuple.h>
33#include <nanobind/stl/vector.h>
34
35#include <array>
36#include <cstdint>
37#include <Kokkos_Core.hpp>
38#include <optional>
39#include <stdexcept>
40#include <string>
41#include <tuple>
42#include <vector>
43
44#ifdef PECLET_DEM_MPI
45#include <mpi.h>
46#endif
47
48#include "peclet/core/python/kokkos_teardown.hpp"
49#include "peclet/core/python/ndarray_interop.hpp"
50#include "sim.hpp"
51
52namespace nb = nanobind;
54
55namespace {
56
58using F3Arg = std::array<float, 3>;
59peclet::dem::F3 f3(const F3Arg& a) {
60 return peclet::dem::F3{a[0], a[1], a[2]};
61}
62
66using Grid3 = nb::ndarray<float, nb::ndim<3>, nb::f_contig>;
67
68// A contiguous numpy array -> flat C-order host vector (the bridge handles host copy / device
69// wrap).
70std::vector<float> to_vec(nb::ndarray<float, nb::c_contig> a) {
71 return peclet::core::python::ndarray_to_vector<float>(nb::ndarray<>(a));
72}
73std::vector<float> grid_to_vec(Grid3 g) {
74 return peclet::core::python::ndarray_to_vector<float>(nb::ndarray<>(g));
75}
76
80std::vector<float> rows_of(nb::ndarray<float, nb::c_contig> a, int cols, const Simulation& s,
81 const char* who) {
82 if (a.ndim() != 2 || static_cast<int>(a.shape(1)) != cols)
83 throw std::invalid_argument(std::string(who) + ": expected an (N, " + std::to_string(cols) +
84 ") array");
85 if (static_cast<int>(a.shape(0)) != s.numParticles())
86 throw std::invalid_argument(std::string(who) + ": " + std::to_string(a.shape(0)) +
87 " rows but num_particles = " + std::to_string(s.numParticles()) +
88 " (set_positions sizes the particle set; call it first)");
89 return to_vec(a);
90}
91std::vector<float> flat_of(nb::ndarray<float, nb::c_contig> a, const Simulation& s,
92 const char* who) {
93 if (a.ndim() != 1)
94 throw std::invalid_argument(std::string(who) + ": expected an (N,) array");
95 if (static_cast<int>(a.shape(0)) != s.numParticles())
96 throw std::invalid_argument(std::string(who) + ": " + std::to_string(a.shape(0)) +
97 " entries but num_particles = " + std::to_string(s.numParticles()) +
98 " (set_positions sizes the particle set; call it first)");
99 return to_vec(a);
100}
101
102// A flat C-order vector -> (N,cols) numpy array, moved into the array's backing store (no extra
103// copy).
104nb::ndarray<nb::numpy, float> rows(std::vector<float>&& v, int cols) {
105 const std::size_t n = v.size() / static_cast<std::size_t>(cols);
106 return peclet::core::python::vector_to_ndarray(std::move(v), {n, (std::size_t)cols},
107 {(std::int64_t)cols, 1});
108}
109nb::ndarray<nb::numpy, float> flat(std::vector<float>&& v) {
110 const std::size_t n = v.size();
111 return peclet::core::python::vector_to_ndarray(std::move(v), {n}, {1});
112}
113
115int shape_kind(const std::string& name, const char* who) {
116 if (name == "sphere")
117 return peclet::dem::SPHERE;
118 if (name == "hollow_cylinder")
120 if (name == "box")
121 return peclet::dem::BOX;
122 throw std::invalid_argument(
123 std::string(who) + ": expected 'sphere', 'hollow_cylinder' or 'box', got '" + name + "'");
124}
125
128struct Diagnostics {
129 Simulation* s;
130};
131
132} // namespace
133
134NB_MODULE(_dem, m) {
135 m.attr("__doc__") = "peclet.dem (Kokkos + ArborX): performance-portable XPBD granular dynamics";
136
137 // Kokkos init + the release-then-finalize atexit hook + finalize() + execution_space: the
138 // suite-wide pattern (file comment; peclet/core/python/kokkos_teardown.hpp). Simulation's own
139 // registry (sim.hpp) is released through a hook; the get_*_view capsules register themselves.
140 peclet::core::python::add_release_hook(&Simulation::releaseAll);
141 peclet::core::python::install(m);
142
143 nb::class_<Diagnostics>(
144 m, "Diagnostics",
145 "Developer tier of a Simulation, reached as `sim.diagnostics`: instruments, ablations and "
146 "execution-policy switches. Nothing here is needed to set up, run or read out a "
147 "simulation; the setters that CHANGE RESULTS say so in their docstring.")
148 .def(
149 "set_stabilization",
150 [](Diagnostics& d, const std::string& mode) { d.s->setStabilizationMode(mode); },
151 nb::arg("mode"),
152 "The full stabilization mode set: the three production modes of "
153 "Simulation.set_stabilization plus the two measurement modes -- 'escalate' (extra "
154 "symmetric sweeps up to 256; diagnostic/fallback) and 'ordered' (level-ordered symmetric "
155 "sweeps; known insufficient for deep columns).")
156 .def(
157 "set_velocity_solver",
158 [](Diagnostics& d, const std::string& name) {
159 if (name == "gauss_seidel")
160 d.s->setVelocityUseGS(true);
161 else if (name == "jacobi")
162 d.s->setVelocityUseGS(false);
163 else
164 throw std::invalid_argument(
165 "set_velocity_solver: expected 'gauss_seidel' or 'jacobi', got '" + name + "'");
166 },
167 nb::arg("name"),
168 "A/B switch of the collision solves: 'gauss_seidel' (default; colored Gauss-Seidel, "
169 "correct multi-contact dissipation) or 'jacobi' (mass-split Jacobi: every contact is "
170 "its own copy, solved against masses m/count, true-mass deltas summed -- conservative, "
171 "and at a fixed iteration budget more dissipative than Gauss-Seidel). CHANGES RESULTS.")
172 .def_prop_ro(
173 "velocity_solver",
174 [](Diagnostics& d) {
175 return std::string(d.s->velocityUseGS() ? "gauss_seidel" : "jacobi");
176 },
177 "'gauss_seidel' or 'jacobi'.")
178 .def(
179 "set_cuda_graphs", [](Diagnostics& d, bool on) { d.s->setCudaGraphs(on); },
180 nb::arg("enabled"),
181 "CUDA-graph replay of the solver's iteration loops (default True): capture collapses "
182 "each iteration's launch storm into one replay. Results are bit-identical either way; "
183 "inert on non-CUDA backends and on the distributed step.")
184 .def_prop_ro(
185 "cuda_graphs", [](Diagnostics& d) { return d.s->cudaGraphs(); },
186 "Whether CUDA-graph replay of the solver loops is enabled.")
187 .def(
188 "set_fused_sweeps",
189 [](Diagnostics& d, const std::string& mode) { d.s->setFusedSweeps(mode); },
190 nb::arg("mode"),
191 "Fused color sweeps (CUDA): a whole sweep -- and where eligible the whole adaptive "
192 "iteration loop -- as ONE kernel behind software grid barriers. 'auto' (default) uses "
193 "them exactly where graph replay is unavailable (the distributed step, or "
194 "set_cuda_graphs(False)); 'on'/'off' force. Bit-identical results either way.")
195 .def_prop_ro(
196 "fused_sweeps", [](Diagnostics& d) { return d.s->fusedSweeps(); },
197 "Fused-sweep policy: 'auto', 'on' or 'off'.")
198 .def(
199 "coloring_conflicts", [](Diagnostics& d) { return d.s->debugColoringConflicts(); },
200 "(velocity, position) graph-coloring-invariant violations in the last substep; a valid "
201 "coloring returns (0, 0). Test hook for the incremental warm-start path.")
202 .def(
203 "coloring_leftovers", [](Diagnostics& d) { return d.s->debugColoringLeftovers(); },
204 "(velocity, position) edges -- manifolds, position units -- the last substep's "
205 "colourings left uncoloured (single-rank). Non-zero means a colouring exhausted its "
206 "arbitration rounds or palette.")
207 .def(
208 "multilevel_coloring_conflicts",
209 [](Diagnostics& d) { return d.s->debugMultilevelColoringConflicts(); },
210 "Same-colour pairs at an aggregate of the last multilevel hierarchy, summed over its "
211 "levels; a valid coarse colouring returns 0 (0 also when no hierarchy was built).")
212 .def(
213 "split_stats",
214 [](Diagnostics& d) {
215 const auto st = d.s->debugSplitStats();
216 nb::dict r;
217 r["vel_hub_copies"] = st.velHubCopies;
218 r["pos_hub_copies"] = st.posHubCopies;
219 r["light_hubs"] = st.lightHubs;
220 r["split_bodies_vel"] = st.splitBodiesVel;
221 r["split_bodies_pos"] = st.splitBodiesPos;
222 r["unfired_split_contacts"] = st.unfiredSplitContacts;
223 r["drift_migrations"] = st.driftMigrations;
224 r["ml_hub_aggregated"] = st.mlHubAggregated;
225 r["vel_iters_used"] = st.velItersUsed;
226 r["pos_iters_used"] = st.posItersUsed;
227 r["orphan_clamps"] = st.orphanClamps;
228 return r;
229 },
230 "Dict of the last substep's body copies (docs/contact_solve_framework.md §4.4): hub "
231 "copy slots per phase, light hubs (hub mass < 10x its partners' mean), bodies solved "
232 "through more than one slot per phase (hubs; periodic images in the single-rank "
233 "position phase), and (0 until implemented) unfired split contacts and drift "
234 "migrations; ml_hub_aggregated: split velocity vertices in a multi-member level-1 "
235 "multilevel group (max over the last step call's substeps); vel_iters_used / "
236 "pos_iters_used: iterations of the last substep's main velocity and position loops "
237 "(-1: a device-side loop, whose count is read back only with the C++ diagnostics "
238 "switch debugIterationCounters); orphan_clamps: clamp hits of the distributed Poisson "
239 "orphan balance over the last step_mpi call (must stay 0).")
240 .def(
241 "rest_orphan_stats", [](Diagnostics& d) { return d.s->restOrphanStats(); },
242 "Poisson-restitution instrument: (sum, max, n_bodies>0) of the per-body orphaned event "
243 "budget (physical impulse units).")
244 .def(
245 "rest_bank_stats", [](Diagnostics& d) { return d.s->restBankStats(); },
246 "Poisson-restitution instrument: (sum, max, n_pairs>0) of the per-pair owed separation "
247 "impulse committed last substep (physical impulse units).")
248 .def(
249 "wall_sdf_at",
250 [](Diagnostics& d, int wall_index, nb::ndarray<float, nb::c_contig> pts) {
251 if (pts.ndim() != 2 || pts.shape(1) != 3)
252 throw std::invalid_argument("wall_sdf_at: points must be an (M, 3) array");
253 return flat(d.s->wallSdfAt(wall_index, to_vec(pts)));
254 },
255 nb::arg("wall_index"), nb::arg("points"),
256 "The wall's signed distance at world points: (M,3) in -> (M,) out, POSITIVE in the void "
257 "where the grains live -- exactly what the narrow phase reads, so it is the honest check "
258 "of a set_wall_transform placement and the way to draw a stirrer.")
259 .def(
260 "profiling_info",
261 [](Diagnostics& d) {
262 nb::dict r;
263 r["num_particles"] = d.s->numParticles();
264 r["num_contacts"] = d.s->numContacts();
265 r["num_manifolds"] = d.s->numManifolds();
266 r["max_overlap"] = d.s->maxOverlap();
267 return r;
268 },
269 "Dict of the particle / broad-phase pair / manifold counts and the last-step max "
270 "overlap (the same four values the Simulation properties carry).")
271#ifdef PECLET_DEM_MPI
272 .def_prop_ro(
273 "mpi_rebuilds", [](Diagnostics& d) { return d.s->mpiRebuilds(); },
274 "Cumulative halo topology-rebuild count (Verlet-skin path); with mpi_gathers this is "
275 "the ghost-reuse ratio.")
276 .def_prop_ro(
277 "mpi_gathers", [](Diagnostics& d) { return d.s->mpiGathers(); },
278 "Cumulative ghost gather() count across distributed steps.")
279#endif
280 ;
281
282 nb::class_<Simulation>(
283 m, "Simulation",
284 "One granular-dynamics simulation (XPBD impulse engine + explicit Hertz-Mindlin engine, "
285 "the same particle set). Set up shapes -> domain -> positions -> materials, `set_dt`, then "
286 "`step(n)`; read out with the `get_*` arrays. Developer instruments live on "
287 "`.diagnostics`.")
288 .def(nb::init<int>(), nb::arg("capacity"),
289 "Allocate for up to `capacity` particles (owned + periodic ghost slots); set_positions "
290 "refuses more.")
291 .def_prop_ro("capacity", &Simulation::capacity, "The particle capacity this was built with.")
292 .def_prop_ro(
293 "diagnostics", [](Simulation& s) { return Diagnostics{&s}; }, nb::keep_alive<0, 1>(),
294 "The developer tier (Diagnostics) of this simulation.")
295 // ---- shapes -------------------------------------------------------------------------
296 .def(
297 "initialize_shape",
298 [](Simulation& s, const std::string& shape, float radius, float height, float thickness) {
299 s.initializeShape(shape_kind(shape, "initialize_shape"), radius, height, thickness);
300 },
301 nb::arg("shape"), nb::arg("radius"), nb::arg("height") = 0.0f,
302 nb::arg("thickness") = 0.0f,
303 "Select the single particle shape and its dimensions: 'sphere' (radius), "
304 "'hollow_cylinder' (radius, height, thickness) or 'box' (half-extent = radius). RESETS "
305 "the shape registry to this one shape (use add_shape for a mixture) and records the "
306 "unit-mass inverse inertia that set_positions applies to every particle -- so call it "
307 "BEFORE set_positions.")
308 .def(
309 "set_sdf_shape",
310 [](Simulation& s, Grid3 grid, F3Arg origin, F3Arg spacing,
311 nb::ndarray<float, nb::c_contig> shell, F3Arg inv_inertia, float bounding_radius) {
312 const int nx = (int)grid.shape(0), ny = (int)grid.shape(1), nz = (int)grid.shape(2);
313 s.setSdfShape(grid_to_vec(grid), nx, ny, nz, f3(origin), f3(spacing), to_vec(shell),
314 f3(inv_inertia), bounding_radius);
315 },
316 nb::arg("grid"), nb::arg("origin"), nb::arg("spacing"), nb::arg("shell"),
317 nb::arg("inv_inertia"), nb::arg("bounding_radius"),
318 "Import a general particle: `grid` is the body-frame signed distance on an (nx, ny, nz) "
319 "array indexed [x, y, z] (negative inside), sampled at nodes origin + (x, y, z) * "
320 "spacing; `shell` the (M,3) surface point shell (EMPTY = sample the field's own zero "
321 "level set); `inv_inertia` the unit-mass principal diagonal inverse inertia; "
322 "`bounding_radius` the canonical enclosing radius. RESETS the registry to this one "
323 "shape. See peclet.dem.build_particle for the SDF -> (grid, shell, inertia) helper.")
324 .def(
325 "add_shape",
326 [](Simulation& s, const std::string& shape, float radius, float height, float thickness) {
327 return s.addShape(shape_kind(shape, "add_shape"), radius, height, thickness);
328 },
329 nb::arg("shape"), nb::arg("radius"), nb::arg("height") = 0.0f,
330 nb::arg("thickness") = 0.0f,
331 "Append an analytic shape ('sphere' | 'hollow_cylinder' | 'box', dimensions as "
332 "initialize_shape) to the registry and return its index, for a simulation with a "
333 "MIXTURE of shapes. initialize_shape stays the single-shape entry point (it RESETS the "
334 "registry). Assign the returned index with set_shape_ids.")
335 .def(
336 "add_sdf_shape",
337 [](Simulation& s, Grid3 grid, F3Arg origin, F3Arg spacing,
338 nb::ndarray<float, nb::c_contig> shell, F3Arg inv_inertia, float bounding_radius) {
339 const int nx = (int)grid.shape(0), ny = (int)grid.shape(1), nz = (int)grid.shape(2);
340 return s.addSdfShape(grid_to_vec(grid), nx, ny, nz, f3(origin), f3(spacing),
341 to_vec(shell), f3(inv_inertia), bounding_radius);
342 },
343 nb::arg("grid"), nb::arg("origin"), nb::arg("spacing"), nb::arg("shell"),
344 nb::arg("inv_inertia"), nb::arg("bounding_radius"),
345 "Append a grid-SDF shape (arguments as set_sdf_shape) and return its index. "
346 "set_sdf_shape stays the single-shape entry point (it RESETS the registry).")
347 .def(
348 "add_scene_shape",
349 [](Simulation& s, nb::ndarray<int, nb::c_contig> ni, nb::ndarray<float, nb::c_contig> nr,
350 int root, nb::ndarray<float, nb::c_contig> shell, F3Arg inv_inertia,
351 float bounding_radius) {
352 return s.addSceneShape(std::vector<int>(ni.data(), ni.data() + ni.size()),
353 std::vector<float>(nr.data(), nr.data() + nr.size()), root,
354 std::vector<float>(shell.data(), shell.data() + shell.size()),
355 f3(inv_inertia), bounding_radius);
356 },
357 nb::arg("node_ints"), nb::arg("node_reals"), nb::arg("root"), nb::arg("shell"),
358 nb::arg("inv_inertia"), nb::arg("bounding_radius"),
359 "Register a COMPOSED analytic particle shape from core's flat node encoding (the arrays "
360 "peclet.geom.SceneBuilder.encode() returns; CSG of the full leaf vocabulary): the "
361 "collision field is the exact tree, evaluated in canonical body space. shell: (M,3) "
362 "surface probe points (bake the tree and run the shell path -- the point-shell model "
363 "still needs probes). inv_inertia: unit-mass principal diagonal inverse inertia; "
364 "bounding_radius: canonical enclosing radius. THE CANONICAL FRAME MUST BE THE PRINCIPAL "
365 "INERTIA FRAME (SceneBuilder.principal_frame emits exactly that); a non-principal tree "
366 "runs the diagonal-inertia rotational update on the wrong frame, silently. Returns the "
367 "shape id for set_shape_ids.")
368 .def(
369 "set_shape_ids",
370 [](Simulation& s, nb::ndarray<int, nb::c_contig> ids) {
371 s.setShapeIds(std::vector<int>(ids.data(), ids.data() + ids.size()));
372 },
373 nb::arg("ids"),
374 "Per-particle shape index (one int per particle, each < num_shapes). Refreshes each "
375 "particle's inverse inertia from its new shape. Call it AFTER set_positions: "
376 "set_positions resets every particle to shape 0 (and sizes the particle set this call "
377 "is checked against).")
378 .def_prop_ro("num_shapes", &Simulation::numShapes, "Number of registered shapes.")
379 // ---- domain ---------------------------------------------------------------------------
380 // Corner overload: set_domain(min, max) 3-tuples (arbitrary origin); keeps the current
381 // periodicity.
382 .def(
383 "set_domain",
384 [](Simulation& s, F3Arg mn, F3Arg mx) { s.setDomainMinMax(f3(mn), f3(mx)); },
385 nb::arg("min"), nb::arg("max"),
386 "Set the domain by (min, max) corner tuples (arbitrary origin); keeps current "
387 "periodicity.")
388 // The suite-canonical form (suite/docs/NAMING.md 1.1): keyword `extent` / `origin` /
389 // `periodic`, the same three names flow.Solver, peclet.voro and the AMR octree take. Bound
390 // AFTER the corner overload, so a two-positional call still resolves to (min, max);
391 // `extent=` selects this one.
392 .def(
393 "set_domain",
394 [](Simulation& s, F3Arg extent, F3Arg origin, std::array<bool, 3> periodic) {
395 s.setDomainCanonical(f3(extent), f3(origin), periodic[0], periodic[1], periodic[2]);
396 },
397 nb::arg("extent"), nb::arg("origin") = F3Arg{0, 0, 0},
398 nb::arg("periodic") = std::array<bool, 3>{true, true, false},
399 "Set the domain the suite-canonical way: `extent` is the box SIZE (not the far corner), "
400 "`origin` the lower corner, `periodic` the per-axis flags. Equivalent to "
401 "`set_domain(min=origin, max=origin+extent)` plus `set_periodic(*periodic)`. Either "
402 "form resets the broad-phase skin to 0.1 x the global scale (as set_global_scale does).")
403 .def("set_periodic", &Simulation::enablePeriodicity, nb::arg("x"), nb::arg("y"), nb::arg("z"),
404 "Set periodic boundaries per axis (x, y, z); read back with the `periodic` property "
405 "(suite/docs/NAMING.md 1.4).")
406 .def_prop_ro("origin", &Simulation::domainOrigin,
407 "The domain's lower corner (x, y, z) — read-only; set it with `set_domain`.")
408 .def_prop_ro("extent", &Simulation::domainExtent,
409 "The domain's SIZE (Lx, Ly, Lz) — read-only, and note it is a size and not the "
410 "far corner, which is `origin + extent` (suite/docs/NAMING.md 1.1).")
411 .def_prop_ro("periodic", &Simulation::domainPeriodic,
412 "Per-axis periodicity (x, y, z) — read-only; set it with `set_periodic`.")
413 // ---- physics settings ---------------------------------------------------------------
414 .def(
415 "set_gravity", [](Simulation& s, F3Arg g) { s.setGravity(g[0], g[1], g[2]); },
416 nb::arg("gravity"),
417 "Set the gravitational acceleration (gx, gy, gz) -- any sequence of three floats. The "
418 "default is ZERO.")
419 .def_prop_ro("gravity", &Simulation::gravity, "The gravitational acceleration (gx, gy, gz).")
420 .def("set_thermostat", &Simulation::setThermostat, nb::arg("temperature"), nb::arg("tau"),
421 nb::arg("kB") = 1.0f,
422 "Enable a Berendsen-style velocity thermostat (target temperature, coupling time tau; "
423 "tau = 0 disables).")
424 .def("set_solver_iterations", &Simulation::setSolverIterations, nb::arg("pos"),
425 nb::arg("vel"),
426 "Set the XPBD position- and velocity-solve iteration counts. `vel` defaults to 0 (no "
427 "velocity solve, hence no restitution).")
428 .def("set_hertz_material", &Simulation::setHertzMaterial, nb::arg("mat"), nb::arg("youngs"),
429 nb::arg("poisson"),
430 "Per-material Young's modulus and Poisson ratio for the soft-sphere Hertz-Mindlin "
431 "engine (material ids as in set_material_ids).")
432 .def(
433 "set_stabilization",
434 [](Simulation& s, const std::string& mode) {
435 if (mode != "off" && mode != "onesided" && mode != "multilevel")
436 throw std::invalid_argument(
437 "set_stabilization: expected 'off', 'onesided' or 'multilevel', got '" + mode +
438 "' ('escalate' / 'ordered' are measurement modes: "
439 "diagnostics.set_stabilization)");
440 s.setStabilizationMode(mode);
441 },
442 nb::arg("mode"),
443 "Stabilization pass of the staged velocity solve: 'off' (pure symmetric PGS -- exact "
444 "ballistic response, but a deep static column mid-collapse cannot be arrested within the "
445 "iteration budget), 'onesided' (default: held-lower-side grounded impulses -- arrests "
446 "any collapse but is a momentum sink), 'multilevel' (GraphMG contact-graph aggregation: "
447 "coarse inelastic solves at super-body masses -- momentum-conserving transport "
448 "acceleration).")
449 .def_prop_ro("stabilization", &Simulation::stabilizationMode,
450 "The stabilization mode: 'off', 'onesided', 'multilevel' (or a diagnostics "
451 "mode).")
452 .def("set_restitution_model", &Simulation::setRestitutionModel, nb::arg("model"),
453 "Restitution model of the PGS velocity solve: 'newton' (default; per-substep "
454 "restitution with Moreau's target, -e x the pre-solve normal velocity on every closed "
455 "contact above the resting threshold, which cannot create kinetic energy for a uniform "
456 "e) or 'poisson' (event-level: each pair banks its kinetic compression impulse and "
457 "releases e x the bank as a budget-capped separation-velocity target during unloading "
458 "-- restores the multi-substep-impact rebound per-substep restitution cannot return; "
459 "it keeps Newton's per-substep targets, -e x the approach on approaching contacts "
460 "only).")
461 .def_prop_ro("restitution_model", &Simulation::restitutionModel, "'newton' or 'poisson'.")
462 .def("set_global_scale", &Simulation::setGlobalScale, nb::arg("scale"),
463 "Set a global length scale applied to all particles (effective radius = scale x "
464 "global_scale x base radius). Resets the broad-phase skin, as set_domain does -- call "
465 "it BEFORE set_domain if you rely on the skin.")
466 .def_prop_ro("global_scale", &Simulation::globalScale, "The global length scale.")
467 .def("set_dt", &Simulation::setDt, nb::arg("dt"),
468 "Set the time step (> 0) every stepper uses: step, step_hertz, step_mpi, "
469 "step_hertz_mpi. There is no default -- a step before set_dt raises.")
470 .def_prop_ro("dt", &Simulation::dt, "The time step set by set_dt.")
471 .def("set_material_params", &Simulation::setMaterialParams, nb::arg("restitution_normal"),
472 nb::arg("restitution_tangent") = 0.0f, nb::arg("friction") = 0.0f,
473 "Set the BODY-BODY normal/tangential restitution and Coulomb friction coefficient. The "
474 "default friction is ZERO, and add_analytic_wall / add_sdf_wall set the particle-WALL "
475 "material only -- so a bed more than a few layers deep run with the defaults behaves "
476 "like a liquid: it transmits full hydrostatic pressure to the container and the "
477 "position solve squeezes grains through the boundary. That failure is silent and looks "
478 "like a solver-convergence bug (raising the position iterations and halving dt do not "
479 "move it). Set a non-zero friction for any deep bed.")
480 .def("set_material_ids", &Simulation::setMaterialIds, nb::arg("ids"),
481 "Per-particle material ids (0..7). Pair (e, mu) values come from set_pair_material; "
482 "without any set_pair_material call the global material applies everywhere.")
483 .def("set_pair_material", &Simulation::setPairMaterial, nb::arg("a"), nb::arg("b"),
484 nb::arg("restitution"), nb::arg("friction"),
485 "Symmetric pair material (restitution, friction) for material ids (a, b). The first "
486 "call seeds every pair from the current global material.")
487 .def("set_wall_material_id", &Simulation::setWallMaterialId, nb::arg("wid"), nb::arg("mat"),
488 "Give an SDF wall a material id so particle-wall (e, mu) resolves via the pair table "
489 "instead of the wall's binary material.")
490 // ---- walls ----------------------------------------------------------------------------
491 .def(
492 "add_analytic_wall",
493 [](Simulation& s, nb::ndarray<int, nb::c_contig> node_ints,
494 nb::ndarray<float, nb::c_contig> node_reals, int root, bool invert, float restitution,
495 float friction) {
496 return s.addAnalyticWall(
497 std::vector<int>(node_ints.data(), node_ints.data() + node_ints.size()),
498 to_vec(node_reals), root, invert, restitution, friction);
499 },
500 nb::arg("node_ints"), nb::arg("node_reals"), nb::arg("root"), nb::arg("invert"),
501 nb::arg("restitution") = 0.0f, nb::arg("friction") = 0.0f,
502 "Add an ANALYTIC wall from a core shape tree in the flat node encoding (3 ints + 16 "
503 "reals per node). Exact at every scale, with no voxel grid to replicate per rank. "
504 "invert=False for a stirrer/obstacle (grains outside the solid); invert=True for a "
505 "container, built from a SOLID body (a solid cylinder for a drum). The wall is "
506 "positioned by the node TRANSFORM -- an identity transform sits at the origin. Returns "
507 "the wall index.")
508 .def(
509 "add_plane",
510 [](Simulation& s, F3Arg p, F3Arg n) { s.addPlane(p[0], p[1], p[2], n[0], n[1], n[2]); },
511 nb::arg("point"), nb::arg("normal"),
512 "Add an infinite boundary wall plane through `point`, with `normal` pointing into the "
513 "half-space the grains occupy (both 3-sequences). XPBD engine only; the Hertz engine "
514 "takes SDF walls.")
515 .def(
516 "add_sdf_wall",
517 [](Simulation& s, Grid3 grid, F3Arg origin, F3Arg spacing, float restitution,
518 float friction) {
519 const int nx = (int)grid.shape(0), ny = (int)grid.shape(1), nz = (int)grid.shape(2);
520 return s.addSdfWall(grid_to_vec(grid), nx, ny, nz, f3(origin), f3(spacing), restitution,
521 friction);
522 },
523 nb::arg("grid"), nb::arg("origin"), nb::arg("spacing"), nb::arg("restitution") = 0.0f,
524 nb::arg("friction") = 0.0f,
525 "Add a static world-space SDF wall/container (drum barrel, hopper, vibrating tray): "
526 "`grid` is the signed distance on an (nx, ny, nz) array indexed [x, y, z] at world nodes "
527 "origin + (x, y, z) * spacing -- POSITIVE in the void where grains live, NEGATIVE in the "
528 "solid wall; restitution / friction are the binary particle-wall material. Returns the "
529 "wall index (for set_wall_velocity). See peclet.dem.build_wall_sdf for the SDF -> "
530 "(grid, origin, spacing) helper.")
531 // Rigid-body surface-velocity field of a wall: v(x) = linVel + angVel × (x − center).
532 // Rotating drum: set angVel about the axis point `center`. Vibrating wall: drive linVel each
533 // step.
534 .def(
535 "set_wall_velocity",
536 [](Simulation& s, int wall_index, F3Arg lin, F3Arg ang, F3Arg center) {
537 s.setWallVelocity(wall_index, f3(lin), f3(ang), f3(center));
538 },
539 nb::arg("wall_index"), nb::arg("lin_vel") = F3Arg{0, 0, 0},
540 nb::arg("ang_vel") = F3Arg{0, 0, 0}, nb::arg("center") = F3Arg{0, 0, 0},
541 "Set a wall's rigid-body surface velocity v(x) = lin_vel + ang_vel × (x − center) (felt "
542 "by grains in contact even though the geometry is static). Cheap; call every step for a "
543 "vibrating wall.")
544 .def(
545 "set_wall_transform",
546 [](Simulation& s, int wall_index, F3Arg translation, std::array<float, 4> quat) {
547 s.setWallTransform(wall_index, f3(translation), quat[0], quat[1], quat[2], quat[3]);
548 },
549 nb::arg("wall_index"), nb::arg("translation") = F3Arg{0, 0, 0},
550 nb::arg("quat") = std::array<float, 4>{0, 0, 0, 1},
551 "Place an ANALYTIC wall (add_analytic_wall) rigidly in the world: quat (x,y,z,w) then "
552 "translation, composed onto the AUTHORED root transform, so calls are absolute and never "
553 "compound. This moves the GEOMETRY -- a stirrer blade sweeps -- where set_wall_velocity "
554 "only gives the static surface a velocity field (enough for an axisymmetric drum). Drive "
555 "both together each step: integrate ang_vel into the quaternion here and pass the same "
556 "ang_vel to set_wall_velocity. Grid-SDF walls have no tree and are refused.")
557 // ---- particle state -------------------------------------------------------------------
558 .def(
559 "set_positions",
560 [](Simulation& s, nb::ndarray<float, nb::c_contig> a) {
561 if (a.ndim() != 2 || (a.shape(1) != 3 && a.shape(1) != 4))
562 throw std::invalid_argument("set_positions: expected an (N, 3) or (N, 4) array");
563 const int n = (int)a.shape(0), k = (int)a.shape(1);
564 if (n > s.capacity())
565 throw std::invalid_argument(
566 "set_positions: " + std::to_string(n) +
567 " particles exceed Simulation(capacity=" + std::to_string(s.capacity()) + ")");
568 const std::vector<float> buf = to_vec(a);
569 std::vector<float> xyz((size_t)n * 3), im;
570 const bool hasMass = (k == 4);
571 if (hasMass)
572 im.resize(n);
573 for (int i = 0; i < n; ++i) {
574 xyz[3 * i] = buf[k * i];
575 xyz[3 * i + 1] = buf[k * i + 1];
576 xyz[3 * i + 2] = buf[k * i + 2];
577 if (hasMass) {
578 const float w = buf[k * i + 3];
579 im[i] = (w == 0.0f) ? 1.0f : w;
580 }
581 }
582 s.setPositions(xyz);
583 if (hasMass)
584 s.setInvMass(im);
585 },
586 nb::arg("positions"),
587 "Set particle positions from an (N, 3) array, or (N, 4) whose column 3 is the INVERSE "
588 "mass (0 is remapped to 1; use set_inv_mass for a fixed body). N may not exceed "
589 "`capacity`. This (re)sizes the particle set and RESETS every particle's quaternion, "
590 "scale, inverse mass, shape id (to 0), velocities and gid -- so every other per-particle "
591 "setter goes AFTER it.")
592 .def(
593 "set_velocities",
594 [](Simulation& s, nb::ndarray<float, nb::c_contig> a) {
595 s.setVelocities(rows_of(a, 3, s, "set_velocities"));
596 },
597 nb::arg("velocities"), "Set particle velocities from an (N, 3) array.")
598 .def(
599 "set_external_forces",
600 [](Simulation& s, nb::ndarray<float, nb::c_contig> a) {
601 s.setExternalForces(rows_of(a, 3, s, "set_external_forces"));
602 },
603 nb::arg("forces"),
604 "Set the per-particle external FORCE (e.g. fluid drag) from an (N, 3) array. Applied "
605 "each step as dv = F*invMass*dt; persists until re-set or cleared.")
606 .def("clear_external_forces", &Simulation::clearExternalForces,
607 "Zero all per-particle external forces.")
608 .def(
609 "set_external_torques",
610 [](Simulation& s, nb::ndarray<float, nb::c_contig> a) {
611 s.setExternalTorques(rows_of(a, 3, s, "set_external_torques"));
612 },
613 nb::arg("torques"),
614 "Set the per-particle external TORQUE in the WORLD frame from an (N, 3) array (the "
615 "resolved-CFD-DEM hydrodynamic torque, a magnetic couple, ...). Applied each step in the "
616 "angular predictor as Euler's equation in the body frame, dw = invI*(tau_body - w x I "
617 "w)*dt, alongside the gyroscopic term that is already there; persists until re-set or "
618 "cleared. Only bodies with a finite inertia respond -- a torque on a body whose "
619 "invInertia is zero is inert, exactly as the gyroscopic term is. Sleeping is disabled "
620 "while a torque is set, as it is for external forces.")
621 .def("clear_external_torques", &Simulation::clearExternalTorques,
622 "Zero all per-particle external torques (and re-enable island sleeping).")
623 .def(
624 "set_quaternions",
625 [](Simulation& s, nb::ndarray<float, nb::c_contig> a) {
626 s.setQuaternions(rows_of(a, 4, s, "set_quaternions"));
627 },
628 nb::arg("quaternions"),
629 "Set particle orientation quaternions (x, y, z, w) from an (N, 4) array.")
630 .def(
631 "set_angular_velocities",
632 [](Simulation& s, nb::ndarray<float, nb::c_contig> a) {
633 s.setAngularVelocities(rows_of(a, 3, s, "set_angular_velocities"));
634 },
635 nb::arg("angular_velocities"),
636 "Set particle angular velocities (body frame) from an (N, 3) array.")
637 .def(
638 "set_inv_inertia",
639 [](Simulation& s, nb::ndarray<float, nb::c_contig> a) {
640 s.setInvInertia(rows_of(a, 3, s, "set_inv_inertia"));
641 },
642 nb::arg("inv_inertia"),
643 "Set the per-particle principal-frame diagonal inverse inertia from an (N, 3) array.")
644 .def(
645 "set_inv_mass",
646 [](Simulation& s, nb::ndarray<float, nb::c_contig> a) {
647 s.setInvMass(flat_of(a, s, "set_inv_mass"));
648 },
649 nb::arg("inv_mass"), "Set per-particle inverse mass, an (N,) array (0 = fixed body).")
650 .def("set_scales_uniform", &Simulation::setScalesUniform, nb::arg("scale"),
651 "Set a single uniform scale for all particles.")
652 .def(
653 "set_scales",
654 [](Simulation& s, nb::ndarray<float, nb::c_contig> a) {
655 s.setScales(flat_of(a, s, "set_scales"));
656 },
657 nb::arg("scales"), "Set per-particle scales from an (N,) array (the growth target).")
658 .def("set_growth_params", &Simulation::setGrowthParams, nb::arg("rate"),
659 nb::arg("new_factor") = -1.0f,
660 "Set the particle growth rate (factor *= exp(rate*dt) per step, capped at 1) and, if "
661 "new_factor > 0, the current growth factor (a negative value keeps it; 0.01 if growth "
662 "was inactive).")
663 .def_prop_ro("growth_factor", &Simulation::growthFactor,
664 "The current particle growth factor (-1 = growth inactive).")
665 .def_prop_ro("growth_rate", &Simulation::getGrowthRate, "The particle growth rate.")
666 .def(
667 "get_masses", [](const Simulation& s) { return flat(s.getMasses()); },
668 "Return per-particle masses (1 / inverse mass; 0 for fixed bodies) as an (N,) array.")
669 .def(
670 "get_positions", [](const Simulation& s) { return rows(s.getPositions(), 3); },
671 "Return particle positions as an (N, 3) numpy array.")
672 .def(
673 "get_velocities", [](const Simulation& s) { return rows(s.getVelocities(), 3); },
674 "Return particle velocities as an (N, 3) numpy array.")
675 .def(
676 "get_angular_velocities",
677 [](const Simulation& s) { return rows(s.getAngularVelocities(), 3); },
678 "Return particle angular velocities (body frame) as an (N, 3) numpy array.")
679 .def(
680 "get_inv_inertia", [](const Simulation& s) { return rows(s.getInvInertia(), 3); },
681 "Return the per-particle principal-frame diagonal inverse inertia as an (N, 3) array.")
682 .def(
683 "get_quaternions", [](const Simulation& s) { return rows(s.getQuaternions(), 4); },
684 "Return particle orientation quaternions (x, y, z, w) as an (N, 4) numpy array.")
685 .def(
686 "get_scales", [](const Simulation& s) { return flat(s.getScales()); },
687 "Return per-particle scales as an (N,) numpy array.")
688 // Zero-copy device export (H2): the returned (N,3) array REFERENCES the device particle Views
689 // — a NumPy view on a host backend, a DLPack/__cuda_array_interface__ array (consume with
690 // cupy.from_dlpack / torch.from_dlpack) on CUDA/HIP — so a GPU-resident analysis chain never
691 // pays the device->host copy. The array keeps the (ref-counted) View alive.
692 .def(
693 "get_positions_view",
694 [](const Simulation& s) {
695 return peclet::core::python::view_to_ndarray(Kokkos::subview(
696 s.positionsView(), Kokkos::make_pair(0, s.numParticles()), Kokkos::ALL));
697 },
698 "Zero-copy (N,3) device array of positions (NumPy view on host, DLPack/CuPy on GPU).")
699 .def(
700 "get_velocities_view",
701 [](const Simulation& s) {
702 return peclet::core::python::view_to_ndarray(Kokkos::subview(
703 s.velocitiesView(), Kokkos::make_pair(0, s.numParticles()), Kokkos::ALL));
704 },
705 "Zero-copy (N,3) device array of velocities (NumPy view on host, DLPack/CuPy on GPU).")
706 .def(
707 "get_external_forces_view",
708 [](const Simulation& s) {
709 return peclet::core::python::view_to_ndarray(Kokkos::subview(
710 s.externalForcesView(), Kokkos::make_pair(0, s.numParticles()), Kokkos::ALL));
711 },
712 "Zero-copy (N,3) device array of the per-particle external force (NumPy view on host, "
713 "DLPack/CuPy on GPU) — write fluid drag here directly to avoid a host round-trip.")
714 .def(
715 "get_inv_mass_view",
716 [](const Simulation& s) {
717 return peclet::core::python::view_to_ndarray(
718 Kokkos::subview(s.invMassView(), Kokkos::make_pair(0, s.numParticles())));
719 },
720 "Zero-copy (N,) device array of per-particle inverse mass (NumPy view on host, "
721 "DLPack/CuPy on GPU) — read-only use; needed for stiff-safe drag integration.")
722 // ---- stepping -------------------------------------------------------------------------
723 .def("step", &Simulation::step, nb::arg("n") = 1,
724 "Advance `n` XPBD substeps of the time step set by set_dt (raises if set_dt was never "
725 "called).")
726 .def("relax", &Simulation::relax, nb::arg("n") = 1,
727 "Run `n` dynamics-free RELAXATION substeps: overlap removal only, no gravity and no "
728 "velocity update -- the growth-packing protocol's settle move (dt = 0 inside; the "
729 "stored dt is untouched and set_dt is not required).")
730 .def("step_hertz", &Simulation::stepHertz, nb::arg("substeps") = 1,
731 nb::arg("skin_frac") = 0.3f,
732 "Advance `substeps` explicit soft-sphere Hertz-Mindlin steps of the time step set by "
733 "set_dt (spheres, SDF walls, non-periodic): viscoelastic Hertz normal force + Mindlin "
734 "shear-history spring, Coulomb-clamped; (e, mu) from the pair-material tables, "
735 "stiffness from set_hertz_material. skin_frac is the Verlet pair-list skin as a "
736 "fraction of the radius.")
737 // ---- read-out -------------------------------------------------------------------------
738 .def_prop_ro("num_particles", &Simulation::numParticles, "The number of particles.")
739 .def_prop_ro(
740 "num_contacts", [](Simulation& s) { return s.numContacts(); },
741 "Broad-phase candidate pairs found in the last step (ArborX BVH query).")
742 .def_prop_ro(
743 "num_manifolds", [](Simulation& s) { return s.numManifolds(); },
744 "Narrow-phase contact manifolds (touching pairs) resolved in the last step.")
745 .def_prop_ro(
746 "max_overlap", [](Simulation& s) { return s.maxOverlap(); },
747 "Maximum pair interpenetration recorded by the position solver in the last step (its "
748 "last-iteration residual, so it under-reports the committed overlap -- see "
749 "docs/archive/packing_investigation.md). compute_overlaps() measures the committed "
750 "state.")
751 .def("compute_overlaps", &Simulation::computeOverlaps,
752 "Measure and return the maximum pair interpenetration of the current committed state.")
753 .def(
754 "get_sdf_grid",
755 [](Simulation& s, std::tuple<int, int, int> res) {
756 auto [rx, ry, rz] = res;
757 // x-fastest samples -> an (rx, ry, rz) array indexed [x, y, z] (Fortran order), the
758 // suite's grid layout and what set_sdf_shape / add_sdf_wall take back.
759 return peclet::core::python::vector_to_ndarray(
760 s.getSdfGrid(rx, ry, rz), {(std::size_t)rx, (std::size_t)ry, (std::size_t)rz},
761 {1, (std::int64_t)rx, (std::int64_t)rx * ry});
762 },
763 nb::arg("resolution"),
764 "Reconstruct the packed-bed SDF (negative inside solid) over the domain on an (rx, ry, "
765 "rz) grid; returns an (rx, ry, rz) array indexed [x, y, z].")
766 .def(
767 "export_sdf",
768 [](Simulation& s, const std::string& filename, std::tuple<int, int, int> res) {
769 auto [rx, ry, rz] = res;
770 s.exportSdf(filename, rx, ry, rz);
771 },
772 nb::arg("filename"), nb::arg("resolution"),
773 "Reconstruct and write the packed-bed SDF on an (rx, ry, rz) grid to a VTI file.")
774 .def("write_vtp", &Simulation::writeVtp, nb::arg("filename"),
775 "Write particle state to a VTP file (ParaView/Ovito).")
776 .def("export_lammps", &Simulation::exportLammps, nb::arg("filename"), nb::arg("step"),
777 "Export particle state to a LAMMPS dump file.")
778 // ---- execution policy -----------------------------------------------------------------
779 .def("set_sleeping", &Simulation::setSleeping, nb::arg("enabled"),
780 nb::arg("threshold_scale") = 2.0f, nb::arg("consecutive") = 64,
781 nb::arg("wake_scale") = 40.0f, nb::arg("wake_on_lost_contact") = false,
782 nb::arg("immovable_frac") = 0.01f,
783 "Island sleeping (single-GPU statics, default ON): freeze grounded bodies whose motion "
784 "stays below threshold_scale x the resting floor for `consecutive` substeps; wake only "
785 "above wake_scale x that floor (hysteresis vs residual jitter). wake_on_lost_contact "
786 "additionally wakes a sleeper whose support disappeared; immovable_frac is a sleeper's "
787 "effective inverse-mass fraction in the solve (0 = exactly immovable). Requires gravity "
788 "and no external force; inert under MPI.")
789 .def_prop_ro("sleeping", &Simulation::sleeping, "Whether island sleeping is enabled.")
790 .def_prop_ro(
791 "num_asleep", [](Simulation& s) { return s.numAsleep(); },
792 "Number of currently-sleeping real bodies.")
793 .def("set_verlet_skin", &Simulation::setVerletSkin, nb::arg("skin_frac"),
794 "Enable the Verlet-cached impulse broadphase (single-GPU, non-periodic, default OFF, "
795 "i.e. skin_frac = 0): skip the ArborX rebuild while nothing moved more than skin/2 "
796 "(skin = skin_frac x max grain radius).")
797 .def_prop_ro("verlet_skin", &Simulation::verletSkin,
798 "Broadphase-skin fraction of the max grain radius (0 = rebuild every step).")
799 .def("set_incremental_coloring", &Simulation::setIncrementalColoring, nb::arg("enabled"),
800 "Incremental (warm-started) graph coloring of the contact manifolds (default True): "
801 "reuse last substep's colors and repair only the conflicts. This CHANGES RESULTS -- "
802 "the coloring fixes the Gauss-Seidel sweep order -- so False reproduces the "
803 "pre-incremental behaviour rather than merely running slower.")
804 .def_prop_ro("incremental_coloring", &Simulation::incrementalColoring,
805 "Whether incremental (warm-started) coloring is enabled.")
806 .def("set_shell_detection", &Simulation::setShellDetection, nb::arg("mode"),
807 "Narrow-phase shell detection of the XPBD engine (step, step_mpi, relax, "
808 "compute_overlaps; step_hertz is unaffected). 'one_way' (default): only the "
809 "lower-index body's shell points are tested against the other body's SDF, so a thin "
810 "rim, wall or edge pressed into a flat face between those points can go unseen. "
811 "'two_way': shell pairs are also probed the other way round, and a sphere-shell pair "
812 "always uses the exact sphere probe. Enable it for thin-walled or non-convex shells "
813 "(tubes, rings), sharp edges and corners against faces, and large size ratios where "
814 "the smaller body's features are finer than the larger body's shell spacing; not "
815 "needed for spheres or smooth convex bodies of similar size with dense shells. Costs "
816 "up to 2x the narrow phase and the contacts of shell pairs (docs/solver_details.md). "
817 "CHANGES RESULTS for non-sphere bodies.")
818 .def_prop_ro("shell_detection", &Simulation::shellDetection,
819 "The narrow-phase shell detection: 'one_way' or 'two_way'.")
820#ifdef PECLET_DEM_MPI
821 // ---- distributed step; built only with -DPECLET_DEM_MPI=ON ---------------------------
822 .def(
823 "init_mpi",
824 [](Simulation& s, std::tuple<double, double, double> origin,
825 std::tuple<double, double, double> extent, std::tuple<long, long, long> cells,
826 std::tuple<bool, bool, bool> periodic) {
827 int inited = 0;
828 MPI_Initialized(&inited);
829 if (!inited) {
830 int argc = 0;
831 char** argv = nullptr;
832 MPI_Init(&argc, &argv);
833 }
834 s.initMpi(origin, extent, cells, periodic, MPI_COMM_WORLD);
835 },
836 nb::arg("origin"), nb::arg("extent"), nb::arg("cells"), nb::arg("periodic"),
837 "Set up the ORB block decomposition + core particle halo for the distributed step on "
838 "the global domain (`origin` lower corner, `extent` SIZE, `cells` per axis for the ORB "
839 "grid, `periodic` per axis -- the suite's domain quartet).")
840 .def("enable_mpi_step", &Simulation::enableMpiStep, nb::arg("rcut") = 0.0,
841 nb::arg("sync_every") = 1, nb::arg("forward_rotation") = true,
842 nb::arg("rebalance_every") = 0, nb::arg("verlet_skin") = 0.0,
843 "Enable the distributed step: ghost-band width rcut (a lower bound -- step_mpi widens "
844 "it to the contact reach plus a drift slack and the prediction allowance, "
845 "max(rcut, 2.1 x the global maximum radius + 0.25 x it + P), so the default 0 means "
846 "exactly that), sync_every (the owner/ghost reconciliation interval; conservation is "
847 "exact at any value -- a body updated from several places is solved through copies, "
848 "mass-split for the projection-form phases and exclusively held by one rank per sync "
849 "interval for the g = 0 one-shot restitution sweep -- larger sync_every means more lag, "
850 "fewer messages), rotation forwarding, the load-rebalance interval in steps (0 = fixed "
851 "decomposition; every step also votes, in the same reduction as the band, whether any "
852 "body has drifted more than the slack outside its owner's block, and migrates it if so "
853 "-- ownership is therefore not fixed across a run, and ghost selection uses the "
854 "domain-clamped position on non-periodic axes), and the Verlet ghost-reuse skin (0 = "
855 "rebuild the halo topology every substep; >0 = reuse it until a particle moves > skin, "
856 "and P above is the skin instead of the predicted displacement).")
857 .def("step_mpi", &Simulation::stepMpi, nb::arg("n") = 1,
858 "Advance the distributed (MPI) simulation by `n` steps of the time step set by set_dt, "
859 "with halo exchange.")
860 .def("step_hertz_mpi", &Simulation::stepHertzMpi, nb::arg("substeps") = 1,
861 nb::arg("skin_frac") = 0.3f,
862 "Advance `substeps` distributed explicit Hertz-Mindlin (force-based) steps of the time "
863 "step set by set_dt — the MPI counterpart of step_hertz on the init_mpi/enable_mpi_step "
864 "decomposition. rebalance_every counts CALLS of this method; migration carries the "
865 "Mindlin history.")
866 .def("rebalance", &Simulation::rebalance,
867 "Re-decompose by particle count and migrate ownership now; returns this rank's new "
868 "owned count.")
869 .def("migrate_to_weights", &Simulation::migrateToWeights, nb::arg("weights"),
870 nb::arg("align") = 1,
871 "Co-rebalance: migrate particle ownership onto the weighted ORB of per-cell weights "
872 "(global x-fastest over the init_mpi cell grid), so each rank owns the particles in a "
873 "block of near-equal total weight -- the SAME partition the coupled flow solver "
874 "redistributes onto from the same weight field. `align` (a power of two, default 1) "
875 "puts every block boundary on a multiple of `align` cells; 1 is the plain weighted "
876 "ORB. A coupled run passes the alignment flow's rebalance_by_weights returned, "
877 "which it chose for its pressure multigrid; dem alone has no reason to align. Raises "
878 "ValueError when `align` is not a power of two, does not divide the cell grid, or "
879 "`weights` does not cover it. Returns this rank's new owned count.")
880 .def_prop_ro("rank", &Simulation::rank, "This rank's MPI index.")
881 .def_prop_ro("num_ghost", &Simulation::numGhost,
882 "The number of ghost particles on this rank.")
883#endif
884 ;
885
886 // Module-level LAMMPS dump writer from raw arrays: export_lammps(filename, step, pos, vel,
887 // quats, radii, box_min, box_max, periodic).
888 m.def(
889 "export_lammps",
890 [](const std::string& filename, int step, nb::ndarray<float, nb::c_contig> pos,
891 nb::ndarray<float, nb::c_contig> vel, nb::ndarray<float, nb::c_contig> quats,
892 nb::ndarray<float, nb::c_contig> radii, std::optional<F3Arg> box_min,
893 std::optional<F3Arg> box_max, bool periodic) {
894 const float* pmn = box_min ? box_min->data() : nullptr;
895 const float* pmx = box_max ? box_max->data() : nullptr;
896 peclet::dem::writeLammpsDump(filename, step, to_vec(pos), to_vec(vel), to_vec(quats),
897 to_vec(radii), pmn, pmx, periodic);
898 },
899 nb::arg("filename"), nb::arg("step"), nb::arg("pos"), nb::arg("vel"), nb::arg("quats"),
900 nb::arg("radii"), nb::arg("box_min") = std::nullopt, nb::arg("box_max") = std::nullopt,
901 nb::arg("periodic") = false,
902 "Module-level LAMMPS dump writer from raw arrays (filename, step, pos, vel, quats, radii, "
903 "box corners, periodic flag).");
904}
int addSdfShape(const std::vector< float > &grid, int nx, int ny, int nz, F3 origin, F3 spacing, const std::vector< float > &shellFlat, F3 invInertia, float boundingRadius)
Append a grid-SDF shape (the general non-spherical particle) to the mixture and return its index.
int addShape(int shape_type, float radius, float height, float thickness)
Append an analytic shape and return its index, for simulations with a MIXTURE of shapes.
int addSceneShape(const std::vector< int > &nodeInts, const std::vector< float > &nodeReals, int root, const std::vector< float > &shellFlat, F3 invInertia, float boundingRadius)
void setSdfShape(const std::vector< float > &grid, int nx, int ny, int nz, F3 origin, F3 spacing, const std::vector< float > &shellFlat, F3 invInertia, float boundingRadius)
void initializeShape(int shape_type, float radius, float height, float thickness)
void setShapeIds(const std::vector< int > &ids)
Per-particle shape assignment.
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
std::vector< float > getMasses() const
Definition sim.hpp:748
void setThermostat(float temperature, float tau, float kB)
Definition sim.hpp:205
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
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
void enablePeriodicity(bool x, bool y, bool z)
Definition sim.hpp:178
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
std::tuple< float, float, float > domainExtent() const
Definition sim.hpp:195
std::tuple< float, float, float > domainOrigin() const
Definition sim.hpp:192
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 setExternalTorques(const std::vector< float > &t)
Definition sim.hpp:672
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
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
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
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
NB_MODULE(_dem, m)
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
C int MPI_Init(int *a, char ***b)
Definition pmpi.cpp:28
dem — the Simulation facade: the host-facing, binding-agnostic std::vector API the nanobind module (d...