Note
Go to the end to download the full example code.
Clumps (Rigid Bodies)#
A clump is a rigid body made of several spheres that move together.
Every sphere in the same clump shares its center-of-mass position
(pos_c), orientation (q), velocity (vel), angular velocity
(ang_vel), mass, and inertia. The remaining fields are stored
per-sphere: the body-frame offset (pos_p), the radius (rad),
the volume (volume), and the ID fields (mat_id, species_id,
bond_id, clump_id) — see the table below.
This guide covers:
The data model: which fields are shared and which are per-sphere.
Creating clumps manually and with
add_clump().Computing clump mass, inertia, and center of mass with
compute_clump_properties().How colliders, force aggregation, and integrators handle clumps.
Practical tips and common pitfalls.
The Clump Data Model#
Each particle slot in the state stores its own copy of the shared
fields. clump_id encodes clump membership: every slot with the same
clump_id belongs to the same rigid body.
Field |
Shared by clump |
Per-sphere |
|---|---|---|
|
✓ |
|
|
✓ |
|
|
✓ |
|
|
✓ |
|
|
✓ |
|
|
✓ |
|
|
✓ |
|
|
✓ |
|
|
✓ |
|
|
✓ |
|
|
✓ |
|
|
✓ |
|
|
✓ |
|
|
✓ |
|
|
✓ |
|
|
✓ |
Note
The state stores volume per sphere: State.create defaults it to each
sphere’s own hypersphere volume, while
add_clump() broadcasts a provided clump
volume to every member sphere. Packing-fraction utilities read one
value per clump (via a segment max).
The actual position of each sphere in the lab frame is a computed property:
For a lone sphere pos_p = 0, so pos == pos_c.
import jax.numpy as jnp
import jaxdem as jdem
Creating a Clump Manually#
The simplest way is to create individual spheres and assign the same
clump_id to those that form a rigid body. We also set their shared
pos_c (center of mass) and per-sphere pos_p (offset from the
center of mass in the body frame).
# Two spheres forming a dumbbell clump, plus one free sphere.
pos_c = jnp.array(
[
[2.0, 0.0], # sphere 0 — dumbbell COM
[2.0, 0.0], # sphere 1 — same COM
[6.0, 0.0], # sphere 2 — free sphere
]
)
pos_p = jnp.array(
[
[-0.5, 0.0], # sphere 0 is 0.5 to the left of COM
[0.5, 0.0], # sphere 1 is 0.5 to the right of COM
[0.0, 0.0], # sphere 2 — lone sphere, no offset
]
)
clump_id = jnp.array([0, 0, 1]) # 0,0 → same clump, 1 → separate clump
state = jdem.State.create(
pos=pos_c,
pos_p=pos_p,
rad=jnp.array([0.6, 0.6, 1.0]),
clump_id=clump_id,
)
print("clump_id:", state.clump_id)
print("pos_c :", state.pos_c)
print("pos_p :", state.pos_p)
print("pos :", state.pos)
clump_id: [0 0 1]
pos_c : [[2. 0.]
[2. 0.]
[6. 0.]]
pos_p : [[-0.5 0. ]
[ 0.5 0. ]
[ 0. 0. ]]
pos : [[1.5 0. ]
[2.5 0. ]
[6. 0. ]]
state.pos gives the true lab-frame position of each
sphere. In the dumbbell (clump 0) the two spheres sit at different
locations even though they share the same pos_c.
Using add_clump()#
A more convenient way to append a clump to an existing state is
add_clump(). It broadcasts shared fields
(velocity, mass, material, …) to all spheres automatically and assigns
a single clump_id to the whole group.
state_base = jdem.State.create(
pos=jnp.array([[0.0, 0.0]]),
rad=jnp.array([1.0]),
)
print("Before add_clump: N =", state_base.N, " clump_ids =", state_base.clump_id)
state_with_clump = jdem.State.add_clump(
state_base,
pos=jnp.array([[3.0, 0.0], [3.0, 0.0]]), # COM for each sphere
pos_p=jnp.array([[-0.4, 0.0], [0.4, 0.0]]),
rad=jnp.array([0.5, 0.5]),
vel=jnp.array([1.0, 0.0]), # broadcast to both spheres
mass=jnp.array(2.0), # broadcast to both spheres
)
print(
"After add_clump: N =",
state_with_clump.N,
" clump_ids =",
state_with_clump.clump_id,
)
print("Velocities:\n", state_with_clump.vel)
Before add_clump: N = 1 clump_ids = [0]
After add_clump: N = 3 clump_ids = [0 1 1]
Velocities:
[[0. 0.]
[1. 0.]
[1. 0.]]
Computing Clump Properties#
When you define a clump by placing overlapping spheres at arbitrary positions, you must compute the correct center of mass, total mass, and inertia tensor. This is not trivial because spheres may overlap. Summing individual volumes would over-count shared regions.
compute_clump_properties() solves this with a
Monte-Carlo integration. It scatters sample points inside the bounding
box of each clump, checks which spheres contain each point, and uses
the resulting density field to compute:
total mass (accounting for overlap)
center of mass (
pos_c)principal moments of inertia (
inertia)principal-axes orientation (
q)body-frame offsets (
pos_p) relative to the new center of mass
Important
compute_clump_properties requires a
MaterialTable because the mass
computation depends on material density.
mat = jdem.Material.create("elastic", density=2.0, young=1e4, poisson=0.3)
mat_table = jdem.MaterialTable.from_materials([mat])
# Place two overlapping spheres at known positions
clump_state = jdem.State.create(
pos=jnp.array([[0.0, 0.0], [0.8, 0.0]]),
rad=jnp.array([0.5, 0.5]),
clump_id=jnp.array([0, 0]),
mat_table=mat_table,
)
print("Before compute_clump_properties:")
print(" pos_c:", clump_state.pos_c)
print(" pos_p:", clump_state.pos_p)
print(" mass :", clump_state.mass)
clump_state = jdem.utils.compute_clump_properties(clump_state, mat_table)
print("\nAfter compute_clump_properties:")
print(" pos_c:", clump_state.pos_c)
print(" pos_p:", clump_state.pos_p)
print(" mass :", clump_state.mass)
print(" inertia:", clump_state.inertia)
Before compute_clump_properties:
pos_c: [[0. 0. ]
[0.8 0. ]]
pos_p: [[0. 0.]
[0. 0.]]
mass : [1.57079633 1.57079633]
After compute_clump_properties:
pos_c: [[4.00143476e-01 1.20629407e-04]
[4.00143476e-01 1.20629407e-04]]
pos_p: [[-4.00143483e-01 -9.49897590e-05]
[ 3.99856515e-01 -1.46250668e-04]]
mass : [2.97672025 2.97672025]
inertia: [[0.89121455]
[0.89121455]]
Collision Detection and Clumps#
All colliders automatically skip interactions between spheres
that belong to the same clump.
valid_interaction_mask() handles this:
is_bonded = jnp.any(bond_id_i == idx_j[..., None], axis=-1)
mask = (clump_i != clump_j) * (~is_bonded | interact_same_bond_id)
So spheres inside a clump never exert contact forces on each
other — they are a rigid assembly by construction. Colliders mask
bonded pairs (connected via bond_id) by default. Bonded pairs
interact when
interact_same_bond_id is True.
The colliders always mask same-clump pairs, regardless of that flag.
There are no special collider requirements: clumps work with all colliders.
clump_id vs bond_id#
These two identifiers serve different purposes:
clump_id— rigid body grouping. Spheres with the sameclump_idare physically fused: they share velocity, position, and orientation and never collide with each other.bond_id— connectivity masking. The colliders disable (mask out) pairwise interactions between spheres connected by a bond. Thebond_idarray holds the unique IDs of the spheres each sphere is connected to. Settinginteract_same_bond_idtoTruere-enables contact forces between bonded pairs (same-clump pairs stay masked either way).
In short: clump_id controls rigid-body aggregation and
collision masking, while bond_id controls localized connection masking.
See Deformable Particles for details on
deformable particles.
Force Aggregation#
The ForceManager handles clump-level force
aggregation in its apply step. The pipeline is:
The collider writes per-sphere contact forces/torques into
state.force/state.torque.The force manager adds external forces (gravity, custom functions).
Particle-frame forces induce extra torque via the lever arm \(\tau_i = r_i \times F_i\), where \(r_i = R(q) \cdot pos\_p_i\).
The force manager sums forces and torques over each clump with
jax.ops.segment_sum, usingclump_idas the segment key.It broadcasts the aggregated values back, so every sphere in the clump sees the same total force and torque.
This makes the clump accelerate and rotate as a single rigid body.
Running a Simulation with Clumps#
Let’s build a small example: a dumbbell clump falling under gravity toward a fixed sphere.
# Fixed floor sphere
pos_floor = jnp.array([[0.0, 0.0]])
rad_floor = jnp.array([1.0])
state_sim = jdem.State.create(pos=pos_floor, rad=rad_floor, mat_table=mat_table)
state_sim.fixed = jnp.array([True])
# Add a dumbbell clump above the floor
state_sim = jdem.State.add_clump(
state_sim,
pos=jnp.array([[0.0, 4.0], [0.0, 4.0]]),
pos_p=jnp.array([[-0.4, 0.0], [0.4, 0.0]]),
rad=jnp.array([0.4, 0.4]),
)
print("N:", state_sim.N, " clump_ids:", state_sim.clump_id)
# Compute clump mass and inertia
state_sim = jdem.utils.compute_clump_properties(state_sim, mat_table)
system_sim = jdem.System.create(
state_sim.shape,
dt=1e-4,
force_model_type="spring",
mat_table=mat_table,
force_manager_kw={"gravity": jnp.array([0.0, -9.81])},
)
# Run a few steps
state_sim, system_sim = system_sim.step(state_sim, system_sim, n=4)
print("Dumbbell COM:", state_sim.pos_c[1])
print("Floor position (unchanged): ", state_sim.pos[0])
N: 3 clump_ids: [0 1 1]
Dumbbell COM: [2.57398179e-05 3.99995701e+00]
Floor position (unchanged): [0. 0.]
Integration and Clumps#
The linear and rotational integrators act on pos_c, vel, q,
and ang_vel. Because all spheres in a clump share these fields
(with the same values broadcast to every slot), the integrator moves
the entire clump as one rigid body without any special branching.
state.pos derives the actual sphere positions from pos_c and
pos_p on each access.
Reflective Domains and Clumps#
When a sphere inside a clump hits a reflective boundary, JaxDEM aggregates the velocity correction over the whole clump before applying it. This stops individual spheres from escaping while the correction pulls the rest of the body back, and preserves rigid-body integrity.
Common Pitfalls#
Forgetting to call ``compute_clump_properties``. If you build a clump manually and skip this step, the mass, inertia, and center of mass will be those of a single sphere. The simulation will not be physically correct.
Overlapping clumps with the same
clump_id. If two separate bodies accidentally share aclump_id, JaxDEM treats them as one rigid body. They do not interact through contact forces and they move together.Non-contiguous
clump_idvalues. The aggregation usesjax.ops.segment_sumwithnum_segments = N. Large gaps inclump_idwaste memory but are functionally correct. The defaultcreate()assigns sequential IDs.Shared fields must be identical across a clump. If you manually set
vel,pos_c,q, etc., make sure all slots belonging to the same clump receive the same value.add_clump()handles this automatically by broadcasting.
Total running time of the script: (0 minutes 3.909 seconds)