Skip to content

Randomizing inertia

67 · Domain randomization

Five balanced paddles on vertical hinges, held at zero by a torsional drive and released from 57 degrees. At 1 s the script scales each world's body_inertia by a sampled factor at fixed mass. The oscillation period grows with the square root of the inertia, matching 2 pi sqrt(I / ke). The second run uses update_mass_matrix_interval=10000, longer than the whole run, and skips the notification: the mass matrix keeps the old inertia, and all five paddles swing with the unrandomized period.

0.00 s

What to look at

  • With notify: the measured periods spread from 0.76 s to 1.41 s, as predicted.
  • With interval 10000 and no notify: all five swing together with the unrandomized 0.89 s period.

Key parameter

update_mass_matrix_interval

With an interval above 1 the mass matrix is rebuilt only every few steps; a BODY_INERTIAL_PROPERTIES notification forces a rebuild on the next step.

Try changing

  • Scale only the hinge-axis component of body_inertia.
  • Add the notify call to the second run and confirm it matches the first.

Concept

When Steel sees a randomized value

What it is

Notify with the matching flags after changing these model arrays so Steel refreshes all derived data consistently. Some inputs are cached until notification; mass, inertia and drive gains can also be picked up by periodic mass-matrix rebuilds, so their behavior without notification depends on update_mass_matrix_interval.

In this scene

With interval 10000 and no notify: all five swing together with the unrandomized 0.89 s period.

Considerations

Cost
Call only, default solver, six-link chain and four boxes per world, 1024 worlds (1.12 ms per collide + step): BODY_INERTIAL_PROPERTIES 31 µs (17 µs captured). Flags measured one at a time; combined calls and feature validation can cost more. Body-inertial and joint-DOF notifications also request a mass-matrix refresh.
Graph capture
These flags can be captured in a reset graph. Kinematic-flag changes, sleeping, and shape flags with sparse factors plus restitution run eagerly; captured calls skip some value checks.
Interactions
world_mask limits which worlds lose their warm start; cached data is refreshed for every world.
Use it when
Rotational dynamics that matter more than mass, such as wheels or paddles.

The script

cd tutorials && python dr_inertia.py prints each variant's final metric; --record out.json also writes the replay. Every tutorial imports replay.py from the same folder.

tutorials/dr_inertia.py
# SPDX-FileCopyrightText: Copyright (c) 2026 Skild AI and the Steel contributors
# SPDX-License-Identifier: Apache-2.0
"""Inertia randomization: per-world body_inertia at fixed mass changes the natural frequency."""
import math
import newton
import numpy as np
import warp as wp
from newton.solvers import SolverSteel
from replay import Recorder
DT = 1.0 / 240.0
SUBSTEPS = 8
WORLDS = 5
KE = 0.5 # torsional spring (a drive to 0): omega = sqrt(KE / I)
def tf(x, y, z):
return wp.transform((x, y, z), wp.quat_identity())
def build():
paddle = newton.ModelBuilder()
link = paddle.add_link(xform=tf(0.0, 0.0, 0.5))
paddle.add_shape_box(
link, hx=0.12, hy=0.02, hz=0.02
) # hinged at its center, so no gravity torque
paddle.add_shape_sphere(link, xform=tf(0.12, 0.0, 0.0), radius=0.04)
paddle.add_shape_sphere(link, xform=tf(-0.12, 0.0, 0.0), radius=0.04)
j = paddle.add_joint_revolute(
-1, link, axis=newton.Axis.Z, parent_xform=tf(0.0, 0.0, 0.5), target_ke=KE
)
paddle.add_articulation([j])
paddle.joint_q[-1] = 1.0
builder = newton.ModelBuilder()
builder.replicate(paddle, WORLDS, spacing=(0.0, 0.4, 0.0))
return builder.finalize()
def simulate(rec, label, scale, notify, **options):
model = build()
solver = SolverSteel(model, **options)
state_0, state_1, control = model.state(), model.state(), model.control()
newton.eval_fk(model, model.joint_q, model.joint_qd, state_0)
run = rec.variant(label, model)
for frame in range(rec.frames(5.0)):
if frame == rec.fps: # randomize at 1 s, as an episode reset would
s = scale[model.body_world.numpy()][:, None, None]
model.body_inertia.assign(model.body_inertia.numpy() * s)
model.body_inv_inertia.assign(model.body_inv_inertia.numpy() / s)
if notify:
solver.notify_model_changed(newton.ModelFlags.BODY_INERTIAL_PROPERTIES)
q = np.degrees(state_0.joint_q.numpy())
run.frame(state_0, **{f"inertia x{v:.2f}": a for v, a in zip(scale, q)})
for _ in range(SUBSTEPS):
solver.step(state_0, state_1, control, None, DT)
state_0, state_1 = state_1, state_0
run.check(solver)
if __name__ == "__main__":
rng = np.random.default_rng(2)
scale = np.sort(rng.uniform(0.5, 3.0, WORLDS))
rec = Recorder()
simulate(rec, "inertia scaled per world + notify", scale, True)
# the run is shorter than the interval, so without notify the mass matrix keeps the old inertia
simulate(
rec,
"update_mass_matrix_interval=10000, no notify",
scale,
False,
update_mass_matrix_interval=10000,
)
rec.save()
i_z = build().body_inertia.numpy()[0, 2, 2]
print("expected period, s:", np.round(2.0 * math.pi * np.sqrt(scale * i_z / KE), 2))

Recorded with SolverSteel on Newton 1.9.0.dev0, Warp 1.19.0, NVIDIA RTX PRO 6000 Blackwell Server Edition. Replays read the state back every frame, so they are not a speed benchmark. Script · page text