135 m.attr(
"__doc__") =
"peclet.dem (Kokkos + ArborX): performance-portable XPBD granular dynamics";
141 peclet::core::python::install(m);
143 nb::class_<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.")
150 [](Diagnostics& d,
const std::string& mode) { d.s->setStabilizationMode(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).")
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);
164 throw std::invalid_argument(
165 "set_velocity_solver: expected 'gauss_seidel' or 'jacobi', got '" + 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.")
175 return std::string(d.s->velocityUseGS() ?
"gauss_seidel" :
"jacobi");
177 "'gauss_seidel' or 'jacobi'.")
179 "set_cuda_graphs", [](Diagnostics& d,
bool on) { d.s->setCudaGraphs(on); },
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.")
185 "cuda_graphs", [](Diagnostics& d) {
return d.s->cudaGraphs(); },
186 "Whether CUDA-graph replay of the solver loops is enabled.")
189 [](Diagnostics& d,
const std::string& mode) { d.s->setFusedSweeps(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.")
196 "fused_sweeps", [](Diagnostics& d) {
return d.s->fusedSweeps(); },
197 "Fused-sweep policy: 'auto', 'on' or 'off'.")
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.")
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.")
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).")
215 const auto st = d.s->debugSplitStats();
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;
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).")
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).")
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).")
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)));
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.")
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();
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).")
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.")
277 "mpi_gathers", [](Diagnostics& d) {
return d.s->mpiGathers(); },
278 "Cumulative ghost gather() count across distributed steps.")
282 nb::class_<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 "
288 .def(nb::init<int>(), nb::arg(
"capacity"),
289 "Allocate for up to `capacity` particles (owned + periodic ghost slots); set_positions "
293 "diagnostics", [](
Simulation& s) {
return Diagnostics{&s}; }, nb::keep_alive<0, 1>(),
294 "The developer tier (Diagnostics) of this simulation.")
298 [](
Simulation& s,
const std::string& shape,
float radius,
float height,
float thickness) {
299 s.
initializeShape(shape_kind(shape,
"initialize_shape"), radius, height, thickness);
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.")
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);
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.")
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);
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.")
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);
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).")
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);
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.")
370 [](
Simulation& s, nb::ndarray<int, nb::c_contig> ids) {
371 s.
setShapeIds(std::vector<int>(ids.data(), ids.data() + ids.size()));
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).")
385 nb::arg(
"min"), nb::arg(
"max"),
386 "Set the domain by (min, max) corner tuples (arbitrary origin); keeps current "
394 [](
Simulation& s, F3Arg extent, F3Arg origin, std::array<bool, 3> periodic) {
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).")
404 "Set periodic boundaries per axis (x, y, z); read back with the `periodic` property "
405 "(suite/docs/NAMING.md 1.4).")
407 "The domain's lower corner (x, y, z) — read-only; set it with `set_domain`.")
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).")
412 "Per-axis periodicity (x, y, z) — read-only; set it with `set_periodic`.")
417 "Set the gravitational acceleration (gx, gy, gz) -- any sequence of three floats. The "
419 .def_prop_ro(
"gravity", &
Simulation::gravity,
"The gravitational acceleration (gx, gy, gz).")
421 nb::arg(
"kB") = 1.0f,
422 "Enable a Berendsen-style velocity thermostat (target temperature, coupling time tau; "
423 "tau = 0 disables).")
426 "Set the XPBD position- and velocity-solve iteration counts. `vel` defaults to 0 (no "
427 "velocity solve, hence no restitution).")
430 "Per-material Young's modulus and Poisson ratio for the soft-sphere Hertz-Mindlin "
431 "engine (material ids as in set_material_ids).")
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)");
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 "
450 "The stabilization mode: 'off', 'onesided', 'multilevel' (or a diagnostics "
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 "
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.")
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.")
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.")
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.")
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.")
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.")
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,
497 std::vector<int>(node_ints.data(), node_ints.data() + node_ints.size()),
498 to_vec(node_reals), root, invert, restitution, friction);
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 "
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 "
517 [](
Simulation& s, Grid3 grid, F3Arg origin, F3Arg spacing,
float restitution,
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,
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.")
536 [](
Simulation& s,
int wall_index, F3Arg lin, F3Arg ang, F3Arg center) {
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 "
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]);
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.")
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);
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);
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];
578 const float w = buf[k * i + 3];
579 im[i] = (w == 0.0f) ? 1.0f : w;
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.")
594 [](
Simulation& s, nb::ndarray<float, nb::c_contig> a) {
597 nb::arg(
"velocities"),
"Set particle velocities from an (N, 3) array.")
599 "set_external_forces",
600 [](
Simulation& s, nb::ndarray<float, nb::c_contig> a) {
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.")
607 "Zero all per-particle external forces.")
609 "set_external_torques",
610 [](
Simulation& s, nb::ndarray<float, nb::c_contig> a) {
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.")
622 "Zero all per-particle external torques (and re-enable island sleeping).")
625 [](
Simulation& s, nb::ndarray<float, nb::c_contig> a) {
628 nb::arg(
"quaternions"),
629 "Set particle orientation quaternions (x, y, z, w) from an (N, 4) array.")
631 "set_angular_velocities",
632 [](
Simulation& s, nb::ndarray<float, nb::c_contig> a) {
635 nb::arg(
"angular_velocities"),
636 "Set particle angular velocities (body frame) from an (N, 3) array.")
639 [](
Simulation& s, nb::ndarray<float, nb::c_contig> a) {
642 nb::arg(
"inv_inertia"),
643 "Set the per-particle principal-frame diagonal inverse inertia from an (N, 3) array.")
646 [](
Simulation& s, nb::ndarray<float, nb::c_contig> a) {
649 nb::arg(
"inv_mass"),
"Set per-particle inverse mass, an (N,) array (0 = fixed body).")
651 "Set a single uniform scale for all particles.")
654 [](
Simulation& s, nb::ndarray<float, nb::c_contig> a) {
655 s.
setScales(flat_of(a, s,
"set_scales"));
657 nb::arg(
"scales"),
"Set per-particle scales from an (N,) array (the growth target).")
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 "
664 "The current particle growth factor (-1 = growth inactive).")
668 "Return per-particle masses (1 / inverse mass; 0 for fixed bodies) as an (N,) array.")
671 "Return particle positions as an (N, 3) numpy array.")
674 "Return particle velocities as an (N, 3) numpy array.")
676 "get_angular_velocities",
678 "Return particle angular velocities (body frame) as an (N, 3) numpy array.")
681 "Return the per-particle principal-frame diagonal inverse inertia as an (N, 3) array.")
684 "Return particle orientation quaternions (x, y, z, w) as an (N, 4) numpy array.")
687 "Return per-particle scales as an (N,) numpy array.")
693 "get_positions_view",
695 return peclet::core::python::view_to_ndarray(Kokkos::subview(
698 "Zero-copy (N,3) device array of positions (NumPy view on host, DLPack/CuPy on GPU).")
700 "get_velocities_view",
702 return peclet::core::python::view_to_ndarray(Kokkos::subview(
705 "Zero-copy (N,3) device array of velocities (NumPy view on host, DLPack/CuPy on GPU).")
707 "get_external_forces_view",
709 return peclet::core::python::view_to_ndarray(Kokkos::subview(
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.")
717 return peclet::core::python::view_to_ndarray(
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.")
724 "Advance `n` XPBD substeps of the time step set by set_dt (raises if set_dt was never "
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).")
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.")
741 "Broad-phase candidate pairs found in the last step (ArborX BVH query).")
744 "Narrow-phase contact manifolds (touching pairs) resolved in the last step.")
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 "
752 "Measure and return the maximum pair interpenetration of the current committed state.")
755 [](
Simulation& s, std::tuple<int, int, int> res) {
756 auto [rx, ry, rz] = res;
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});
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].")
768 [](
Simulation& s,
const std::string& filename, std::tuple<int, int, int> res) {
769 auto [rx, ry, rz] = res;
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.")
775 "Write particle state to a VTP file (ParaView/Ovito).")
777 "Export particle state to a LAMMPS dump file.")
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.")
792 "Number of currently-sleeping real bodies.")
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).")
798 "Broadphase-skin fraction of the max grain radius (0 = rebuild every step).")
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.")
805 "Whether incremental (warm-started) coloring is enabled.")
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.")
819 "The narrow-phase shell detection: 'one_way' or 'two_way'.")
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) {
828 MPI_Initialized(&inited);
831 char** argv =
nullptr;
834 s.initMpi(origin, extent, cells, periodic, MPI_COMM_WORLD);
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 "
866 .def(
"rebalance", &Simulation::rebalance,
867 "Re-decompose by particle count and migrate ownership now; returns this rank's new "
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.")
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;
897 to_vec(radii), pmn, pmx, periodic);
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).");