Mass says how hard a body is to accelerate. The inertia tensor says how hard it is to spin, and it depends on the axis: a pencil is easy to spin about its length and hard to spin end over end. For a rigid body with density $\rho$ over volume $V$, with positions $\mathbf r$ measured from the centre of mass,
\[I = \int_V \rho(\mathbf r)\left(\|\mathbf r\|^2\,\mathbb 1 - \mathbf r\mathbf r^{\top}\right)dV ,\]a symmetric positive-definite $3 \times 3$ matrix. Every such matrix can be diagonalized by a rotation: there are three perpendicular principal axes about which the body spins without wobbling, with principal moments $I_1, I_2, I_3$.
[!established] How MuJoCo stores inertia For each body,
model.body_massis the mass,model.body_iposthe centre of mass in the body frame,model.body_inertiathe three principal moments, andmodel.body_iquatthe rotation from the body frame to the principal axes. MuJoCo computes these at compile time from the geoms (or from<inertial>, Lesson 2.2) and never stores a full $3 \times 3$ tensor per body.
The box in the lab has three different principal moments and no gravity. Spin it about each axis. About its long axis and about its short axis, it spins steadily. About the middle axis, it spins for a while and then flips end over end, again and again, although nothing pushes it. Watch the body-frame angular velocity components trade places in the plot while the total angular momentum stays put.
```lab simlab {“dock”: true, “title”: “The intermediate-axis flip”, “model”: “tumbling_box”, “key”: 0, “height”: 240, “camera”: {“azimuth”: -70, “elevation”: 20, “distance”: 0.8, “target”: [0, 0, 0.5]}, “toggles”: [“frames”], “overlays”: {“frames”: true}, “controls”: [ {“type”: “button”, “label”: “Spin about x (smallest moment), 6 rad/s”, “run”: “data.qvel.fill(0); mj.mj_resetData(model, data); data.qvel[3] = 6; data.qvel[4] = 0.001”}, {“type”: “button”, “label”: “Spin about y (intermediate), 6 rad/s”, “run”: “data.qvel.fill(0); mj.mj_resetData(model, data); data.qvel[4] = 6; data.qvel[3] = 0.001”}, {“type”: “button”, “label”: “Spin about z (largest moment), 6 rad/s”, “run”: “data.qvel.fill(0); mj.mj_resetData(model, data); data.qvel[5] = 6; data.qvel[4] = 0.001”}, {“type”: “select”, “label”: “integrator”, “set”: “model.opt.integrator”, “options”: [[“RK4”, 1], [“Euler”, 0], [“implicitfast”, 3]], “value”: 1} ], “readouts”: [ {“label”: “kinetic energy (J)”, “expr”: “lib.energy().kinetic”, “digits”: 5}, {“label”: “wx, body (rad/s)”, “expr”: “data.qvel[3]”, “digits”: 3}, {“label”: “wy, body (rad/s)”, “expr”: “data.qvel[4]”, “digits”: 3}, {“label”: “wz, body (rad/s)”, “expr”: “data.qvel[5]”, “digits”: 3}], “plots”: [{“ylabel”: “ω, body axes (rad/s)”, “window”: 10, “traces”: [ {“label”: “wx (rad/s)”, “expr”: “data.qvel[3]”}, {“label”: “wy (rad/s)”, “expr”: “data.qvel[4]”}, {“label”: “wz (rad/s)”, “expr”: “data.qvel[5]”}]}], “note”: “Each button resets the state and gives the box a 6 rad/s spin about one body axis plus a 0.001 rad/s nudge about another. The blue marker sits at the end of the box’s long (x) axis. Press Play first.”}
## Mathematics
### Parallel axis and composition
If a body is made of parts with masses $m_k$, centres $\mathbf c_k$ and inertias $I_k$ about their own centres (in a common frame), its centre of mass and inertia about it are
$$\mathbf c = \frac{\sum_k m_k \mathbf c_k}{\sum_k m_k}, \qquad I = \sum_k \left(I_k + m_k\left(\|\mathbf d_k\|^2 \mathbb 1 - \mathbf d_k\mathbf d_k^\top\right)\right), \quad \mathbf d_k = \mathbf c_k - \mathbf c .$$
The second term is the **parallel-axis theorem** in tensor form. Diagonalizing $I = Q\,\mathrm{diag}(I_1,I_2,I_3)\,Q^\top$ gives the principal axes as the columns of $Q$, which is what `body_iquat` encodes.
> [!derivation] The triangle inequality for inertias
> For principal moments, $I_1 = \int \rho\,(y^2 + z^2)$ and so on, so $I_1 + I_2 = \int \rho\,(x^2 + y^2 + 2z^2) \ge I_3$. Every physical body satisfies $I_a + I_b \ge I_c$ for every ordering; equality holds only for a body flat in one plane. This is the check behind MuJoCo's compile error `inertia must satisfy A + B >= C` (Lesson 2.2).
### Euler's equations
In the principal-axis frame of a torque-free body, with body-frame angular velocity $\boldsymbol\omega$, the rotational dynamics are Euler's equations:
$$I_1 \dot\omega_1 = (I_2 - I_3)\,\omega_2\omega_3,\qquad I_2 \dot\omega_2 = (I_3 - I_1)\,\omega_3\omega_1,\qquad I_3 \dot\omega_3 = (I_1 - I_2)\,\omega_1\omega_2 .$$
> [!derivation] Why only the middle axis is unstable
> Spin about axis 2 with a small perturbation: $\omega_2 \approx \Omega$, $\omega_1, \omega_3$ small. Linearizing the first and third equations gives $\ddot\omega_1 = \frac{(I_2 - I_3)(I_1 - I_2)}{I_1 I_3}\,\Omega^2\,\omega_1$. With $I_1 < I_2 < I_3$ both factors in the numerator are negative, so their product is **positive** and $\omega_1$ grows exponentially: the spin is unstable. Spinning about axis 1 instead gives the coefficient $\frac{(I_3 - I_1)(I_1 - I_2)}{I_2 I_3}\Omega^2$, and about axis 3 the coefficient $\frac{(I_2 - I_3)(I_3 - I_1)}{I_1 I_2}\Omega^2$; in both, the two factors have opposite signs, the coefficient is negative, and small perturbations oscillate: the spin is stable. The flip itself is a large excursion that the linearization does not describe; the angular momentum vector stays fixed in the world throughout, and the body turns over around it.
### Conservation
With no external torque, the angular momentum $\mathbf L = R\,I\,\boldsymbol\omega$ (in world coordinates) is constant, and so is the kinetic energy $\tfrac12 \boldsymbol\omega^\top I \boldsymbol\omega$. MuJoCo reports the first as `data.subtree_angmom` (after `mujoco.mj_subtreeVel`) and the second in `data.energy[1]` (with the energy flag enabled). Both are tests of the simulator, and the script runs them.
## Implementation
```io
INPUT: an L-shaped body of two boxes; `tumbling_box.xml`
PROCESS: compose the L's inertia by hand and compare with MuJoCo; count flips about two axes; measure energy and momentum drift for four integrators
OUTPUT: printed comparisons
```python file=examples/l4_2_inertia.py “"”Lesson 4.2: the inertia tensor, principal axes, and the intermediate-axis flip.
INPUT an L-shaped body built from two boxes (written below); tumbling_box.xml PROCESS (1) compute the L’s centre of mass and inertia tensor by hand with the parallel-axis theorem, diagonalize it, compare with MuJoCo’s body_ipos, body_inertia and body_iquat; (2) spin the tumbling box about each principal axis and count flips; (3) compare how four integrators conserve energy and angular momentum OUTPUT printed comparisons
Run: python examples/l4_2_inertia.py “””
import mujoco import numpy as np
from mjcourse import model_path, spatial
L_SHAPE = “””
”””
def box_inertia(m: float, half: np.ndarray) -> np.ndarray: a, b, c = half return m / 3 * np.diag([b * b + c * c, a * a + c * c, a * a + b * b])
def l_shape() -> None: parts = [(2.0, np.array([0.10, 0, 0]), np.array([0.10, 0.02, 0.02])), (1.0, np.array([0.02, 0.08, 0]), np.array([0.02, 0.06, 0.02]))] mass = sum(m for m, _, _ in parts) com = sum(m * c for m, c, _ in parts) / mass inertia = np.zeros((3, 3)) for m, c, half in parts: d = c - com inertia += box_inertia(m, half) + m * (d @ d * np.eye(3) - np.outer(d, d)) # parallel-axis theorem moments, axes = np.linalg.eigh(inertia) # principal moments, ascending
model = mujoco.MjModel.from_xml_string(L_SHAPE)
b = model.body("L")
mj_axes = spatial.quat_to_mat(b.iquat) # columns: principal axes
print(f" centre of mass: by hand {np.round(com, 6)}, MuJoCo body_ipos {np.round(b.ipos, 6)}")
print(f" principal moments: by hand {np.round(moments, 8)}, MuJoCo body_inertia {np.round(b.inertia, 8)}")
for k in range(3): # match MuJoCo's moment ordering, compare axes up to sign
j = int(np.argmin(np.abs(moments - b.inertia[k])))
print(f" axis for {b.inertia[k]:.6f}: |cos| between hand and MuJoCo = {abs(axes[:, j] @ mj_axes[:, k]):.9f}")
def flips(key: str, seconds: float = 20.0) -> int: model = mujoco.MjModel.from_xml_path(str(model_path(“tumbling_box”))) data = mujoco.MjData(model) mujoco.mj_resetDataKeyframe(model, data, model.key(key).id) mujoco.mj_forward(model, data) axis = 1 if key == “intermediate” else 2 mujoco.mj_subtreeVel(model, data) momentum = data.subtree_angmom[1].copy() sign, count = np.sign(data.xmat[1].reshape(3, 3)[:, axis] @ momentum), 0 for _ in range(round(seconds / model.opt.timestep)): mujoco.mj_step(model, data) s = np.sign(data.xmat[1].reshape(3, 3)[:, axis] @ momentum) count += int(s != sign) sign = s return count
def conservation() -> None: for code, name in ((1, “RK4”), (0, “Euler”), (3, “implicitfast”), (2, “implicit”)): model = mujoco.MjModel.from_xml_path(str(model_path(“tumbling_box”))) model.opt.integrator = code model.opt.enableflags |= mujoco.mjtEnableBit.mjENBL_ENERGY data = mujoco.MjData(model) mujoco.mj_resetDataKeyframe(model, data, 0) mujoco.mj_forward(model, data) mujoco.mj_subtreeVel(model, data) l0, e0 = data.subtree_angmom[1].copy(), data.energy[1] for _ in range(round(20.0 / model.opt.timestep)): mujoco.mj_step(model, data) mujoco.mj_subtreeVel(model, data) print(f” {name:<13s} kinetic energy {100 * (data.energy[1] - e0) / e0:+7.3f} % “ f”|angular momentum change| {np.linalg.norm(data.subtree_angmom[1] - l0):.1e} kg m^2/s”)
if name == “main”: print(“an L-shaped body from two boxes:”) l_shape() print(“tumbling box, 20 s at 6 rad/s, axis flips:”) print(f” about the largest-moment axis (z): {flips(‘major’)} flips”) print(f” about the intermediate axis (y): {flips(‘intermediate’)} flips”) print(“conservation over 20 s of tumbling, by integrator:”) conservation()
Output:
```text
an L-shaped body from two boxes:
centre of mass: by hand [0.073333 0.026667 0. ], MuJoCo body_ipos [0.073333 0.026667 0. ]
principal moments: by hand [0.00376854 0.01383146 0.0168 ], MuJoCo body_inertia [0.0168 0.01383146 0.00376854]
axis for 0.016800: |cos| between hand and MuJoCo = 1.000000000
axis for 0.013831: |cos| between hand and MuJoCo = 1.000000000
axis for 0.003769: |cos| between hand and MuJoCo = 1.000000000
tumbling box, 20 s at 6 rad/s, axis flips:
about the largest-moment axis (z): 0 flips
about the intermediate axis (y): 4 flips
conservation over 20 s of tumbling, by integrator:
RK4 kinetic energy -0.000 % |angular momentum change| 3.9e-11 kg m^2/s
Euler kinetic energy +6.641 % |angular momentum change| 6.9e-04 kg m^2/s
implicitfast kinetic energy -6.040 % |angular momentum change| 6.4e-04 kg m^2/s
implicit kinetic energy -6.040 % |angular momentum change| 6.4e-04 kg m^2/s
Two things in this output are easy to miss.
The order of principal moments is not a convention you can rely on. NumPy’s eigh returns them ascending; MuJoCo returned them in the opposite order for this body (and in the box’s own axis order for the tumbling box, whose principal axes coincide with its geom axes). Match axes by value, as the script does, never by position.
[!established] The integrator table matches the documentation The documentation’s section on gyroscopic derivatives says that integrating gyroscopic forces explicitly “can lead to energy gain” of spinning bodies (here
Euler: +6.6% in 20 s), that for standalone free bodiesimplicitfastandimplicit“compute identical updates” (here: identical to the last digit), that tumbling under them is “mildly damped” (here: -6.0%), and that bodies needing long-horizon energy conservation should useRK4(here: conserved to $10^{-11}$). This is whytumbling_box.xmlsetsintegrator="RK4".
A free object in a manipulation scene spins up by itself. Explicit gyroscopic integration (Euler) with a thin, fast-spinning object. Switch to implicitfast.
A mesh object behaves as if it were much heavier at the edges. Its inertia was computed from a mesh with the wrong scale or a non-watertight surface. Compare body_inertia with the inertia of a box of the same size and mass.
Angular momentum is “not conserved” in a check you wrote. Either the body touches something (contacts apply torques), gravity acts about a point other than the centre of mass you measured about, or you forgot mj_subtreeVel before reading subtree_angmom, which is computed lazily.
Compute by hand the principal moments of a solid cylinder of radius 0.03 m, half-length 0.1 m and mass 0.5 kg, about its centre. Then attach it with fromto at 45 degrees inside a body, compile, and check body_inertia and body_iquat.
Find, by simulation, the period of the intermediate-axis flip for the tumbling box as a function of the nudge size (from $10^{-6}$ to $10^{-1}$ rad/s), and show that the period grows like the logarithm of one over the nudge. Explain the logarithm from the linearized growth rate derived above.
Inertial parameters are among the least reliable numbers in robot models and among the most consequential for model-based control. Identifying them (Level 18) is easier with the structure shown here: mass, centre of mass and a symmetric positive-definite inertia satisfying the triangle inequalities, ten numbers per body. Parameterizations that respect this structure, such as the pseudo-inertia form MuJoCo’s sysid toolbox uses (Rucker and Wensing, 2022, cited in its README), cannot return physically impossible bodies.
{"id": "4.2-check", "title": "Knowledge check", "questions": [
{"kind": "mcq", "q": "Which of these uniform objects can show the intermediate-axis flip?",
"options": ["A cube", "A solid cylinder", "A rectangular plate with three different side lengths", "A sphere"],
"answer": 2,
"explain": "<p>The instability needs three distinct principal moments, $I_1 < I_2 < I_3$. A cube and a sphere have three equal moments; a cylinder has two equal ones. Only the plate has a strictly intermediate axis.</p>"},
{"kind": "numeric", "q": "Two point masses of 1 kg sit at x = -0.1 m and x = +0.3 m. What is their moment of inertia (kg m²) about a z axis through their centre of mass?",
"answer": 0.08, "tol": 0.0001, "unit": "kg m^2",
"explain": "<p>Centre of mass at x = 0.1 m; distances 0.2 m each; $I = 2 \\times 1 \\times 0.2^2 = 0.08$.</p>"},
{"kind": "mcq", "q": "Which integrator should a long-horizon simulation of a tumbling satellite (no contacts, no damping) use, according to MuJoCo's documentation and this lesson's measurement?",
"options": ["Euler", "implicitfast", "RK4", "discrete"],
"answer": 2,
"explain": "<p>RK4 conserved energy and angular momentum to about $10^{-11}$; the single-step methods drifted by about 6% in 20 s.</p>"}
]}
Lesson 4.3 writes Newton’s and Euler’s laws for a single body in MuJoCo’s own spatial notation and checks momentum and energy through collisions.