35 min

The simulator is a state machine you advance

Two objects and one function: mjModel holds everything that never changes, mjData holds everything that does, and mj_step turns one into the next.

Where you are. You can read the SO-101’s model file and say what every element in it means. This lesson puts Python in charge of it.

The arm that is nowhere

Load the model. Make the companion object. Print where the fingertip is.

It says [0. 0. 0.]. The origin. A point buried under the table, where no part of the arm has ever been.

Now call one function that takes no new input, changes nothing you gave it, and advances no time. Print the fingertip again: [0.3914 -0.001 0.2463]. Thirty-nine centimetres out in front of the base, exactly where the picture of the arm says it should be.

Nothing moved. What changed is that somebody finally did the arithmetic. The object you are holding is not the arm; it is a block of memory that means nothing until you ask it to mean something.

The idea in one paragraph

MuJoCo hands you two objects and one function. mjModel is everything the XML compiled down to and nothing that ever changes while you run: shapes, masses, joint limits, servo gains, the size of a timestep. mjData is everything that does change: joint angles, joint speeds, your commands, the clock, and every quantity derived from them. mj_step(model, data) reads mjData, consults mjModel, and overwrites mjData with the state five milliseconds later. That is the whole simulator. There is no scheduler, no real-time thread, no background loop. Time passes only because you called the function, and it passes exactly as often as you call it. Once you see the thing as a state transition function rather than a running world, determinism, replay, reset-to-anywhere and thousands of parallel worlds stop being features you go looking for. They are what you already have.

scene.xml read once, at startup mjModel shapes, masses, joint limits, gains, timestep constant while the loop runs mj_step forces, contacts, then one integration of 0.005 s mjData qpos, qvel, ctrl, time everything that changes mjData the next state, plus xpos, sensordata, contacts the same object, overwritten in place one mjModel can back many mjData at once: that is a parallel env nothing here reads a clock; data.time is just another number in the state
mjModel compiles once from the XML and never changes; mj_step reads the mutable mjData, consults the model, and overwrites the same mjData with the next state

Wider than the screen; scroll it sideways.

Why two objects instead of one

Compiling the XML is slow work: parsing, loading meshes, building convex hulls, allocating the constraint machinery. Stepping is fast. Splitting the two means you pay the slow part once.

It also means one mjModel can back many mjData at the same time. Ten mjData objects against one model is ten independent worlds sharing one copy of the geometry, which is exactly what a vectorised training environment is. You will not need that until Module 3, but the reason it is possible is here, in the split.

The state is two arrays, and they do not line up

Joint positions live in data.qpos. Joint velocities live in data.qvel. The obvious assumption is that they are the same length and that slot kk of one matches slot kk of the other. Load scene_box.xml, the scene with a cube in it, and the assumption dies immediately: nq is 13 and nv is 12.

data.qpos nq = 13 0 1 2 3 4 5 6 7 8 9 10 11 12 six joint angles x y z quaternion the arm the cube's free joint: where it is, and how it is turned data.qvel nv = 12 six joint speeds linear angular four position numbers over three velocity numbers: a quaternion carries one redundant number. Slot k of qpos does not correspond to slot k of qvel. Use model.jnt_qposadr and model.jnt_dofadr, never arithmetic.
The thirteen slots of qpos drawn above the twelve slots of qvel: six hinge angles match one to one, but the cube's free joint stores seven position numbers over six velocity numbers

Wider than the screen; scroll it sideways.

The six hinges each take one number in each array. The cube’s free joint takes seven numbers of position - three for where it is, four for the quaternion saying how it is turned - but only six of velocity, three linear and three angular. Quaternions explained why: a quaternion uses four numbers to carry three numbers’ worth of orientation, and the redundancy is the price of not having gimbal lock. The velocity array has no such problem, so it stays at three.

Writing into the state writes into the simulator

data.qpos is not a copy. It is a numpy array pointing straight at the C struct’s memory, so data.qpos[1] = 0.8 moves the shoulder in the simulator, immediately, with no function call.

Immediately, and not at all. This is the hook from the top of the lesson, and it is the single most common way to lose an afternoon.

stored state data.qpos data.qvel data.ctrl compute where every body is, what is touching what, what forces result, what acceleration follows derived data.xpos data.sensordata data.qacc integrate new qpos, new qvel, time + 0.005 s mj_forward(model, data) recompute everything derived; data.time does not change mj_step(model, data) the same computation, and then one step of time
The pipeline from stored state through compute to derived values and then integration, with mj_forward covering only the compute stage and mj_step covering both

Wider than the screen; scroll it sideways.

qpos, qvel and ctrl are inputs. data.xpos, data.site_xpos, data.sensordata, the contact list and everything else you actually want to read are derived: computed from the inputs, cached, and not recomputed until something asks. Writing an input marks nothing dirty. There is no invalidation. The derived values simply keep saying what they said last time.

Two functions do the recomputation:

CallWhat it doesdata.time
mujoco.mj_forward(model, data)recompute every derived value from the current inputsunchanged
mujoco.mj_step(model, data)the same computation, then integrate one timestepadvances by timestep

Six numbers in, and nothing else

data.ctrl is the entire control surface of this robot. Six floats, one per actuator, in the order the actuators appear in the model.

data.ctrl[:] = [0.0, -0.6, 1.2, 0.5, 0.0, 0.6]   # target angles, radians
mujoco.mj_step(model, data)

Everything you build for the rest of the module - a scripted pick, a teleoperation link, a trained policy - ends at that one assignment. Every clever thing upstream exists to decide those six numbers before the next step.

Leaving ctrl alone is also a command

Here is the experiment that makes the state machine concrete. Load the arm, never touch data.ctrl, and step for four seconds. Then flip one bit in model.opt to switch the actuators off, and step exactly the same four seconds again.

Simulated timeshoulder_lift, servos onservos off
0.00 s0.000°0.00°
1.00 s0.033°23.88°
2.00 s0.033°23.89°
4.00 s0.033°23.92°

With the servos live the arm sags a thirtieth of a degree and holds there forever. With them off it droops twenty-four degrees and settles, caught by joint damping and friction rather than by any controller.

The difference is not that one run had a command and the other did not. Both ran with ctrl at zero throughout. A position servo asked for zero pushes toward zero with all 2.94 newton-metres it has, so a freshly created mjData is not a limp arm; it is an arm being actively held at its zero pose by six servos you never spoke to.

Reset, and then reset again to exactly the same place

Two ways back to a known state. mujoco.mj_resetData(model, data) returns everything to the model’s defaults. mujoco.mj_resetDataKeyframe(model, data, k) jumps to the k-th <key> in the XML, which is how scene_box.xml hands you an arm already mid-grasp with the cube lifted.

Reset twice, run the same commands, and you get the same answer. Not approximately the same: bit for bit identical, np.array_equal and not np.allclose, largest difference exactly 0.0e+00 across all thirteen state numbers after six hundred steps with the cube in continuous contact with the gripper, ten or so contact points being solved afresh every step.

That is worth more than it sounds. It means a robot behaviour can have a regression test. Pin the starting state, run a fixed number of steps, assert on the final cube position, and you have a test that fails when your controller changes and passes when it does not, which is something the physical arm on your desk will never give you.

Time is just another number in the state

data.time advances because mj_step adds model.opt.timestep to it. Nothing consults a clock. Nothing sleeps. If you want the simulation to run at the speed of the world, you have to insert the waiting yourself, which is exactly what the viewer loop does in teleoperating the sim.

Leave the waiting out and the simulation runs as fast as arithmetic allows. What a simulator does argued why that matters; here you can measure it. Twenty thousand steps of the arm-plus-cube scene is one hundred simulated seconds, and on the elderly laptop this lesson was written on it came back in three to ten seconds of wall clock, depending on what else the machine was doing: somewhere between ten and thirty times real time, on one core, with contacts enabled. Your number will be different. Run it and find out what your own hardware buys you, because that ratio is the budget every experiment in this module is drawn against.

Check yourself

1. You set data.qpos[1] = 0.8 and then read data.site_xpos for the fingertip. It has not moved. What went wrong?

Nothing went wrong; you read a cached value. qpos is an input and site_xpos is derived from it, and MuJoCo does not recompute derived values when you write an input. Call mujoco.mj_forward(model, data) first. Use mj_forward when you want the consequences of a state you set by hand, and mj_step when you want time to pass as well.

2. scene_box.xml reports nq = 13 and nv = 12. Where does the extra number come from, and what does it forbid you from doing?

The cube’s free joint. It stores three numbers of position plus a four-number quaternion for orientation, so seven position numbers, but a rigid body has only six velocities, three linear and three angular. The quaternion uses four numbers to carry three numbers’ worth of information. The consequence is that slot kk of qpos is not slot kk of qvel, so index both through model.jnt_qposadr and model.jnt_dofadr rather than by arithmetic.

3. Two runs from the same keyframe with the same commands produce bit-identical results. Why is that useful, and what is the one thing it does not tell you?

It makes robot behaviour testable: fix the starting state, run a fixed number of steps, assert on the outcome, and you have a regression test that a physical arm can never provide. What it does not tell you is whether the behaviour is correct. A deterministic simulator reproduces its own answer perfectly, including the parts where its contact model disagrees with reality. Determinism buys you reproducibility, not truth.

4. A colleague creates a fresh MjData for this arm, steps for ten seconds without writing to ctrl, and reports that “gravity is not working.” What is actually happening?

Gravity is working; the servos are beating it. ctrl starts at zero and a position actuator asked for zero drives toward zero with up to 2.94 N·m, so the arm holds its zero pose with about 0.03 degrees of droop. There is no neutral setting for a position actuator: not writing to ctrl still commands whatever ctrl currently holds. Disable actuation through model.opt.disableflags and the same arm sags about twenty-four degrees.

5. Why does MuJoCo split the world into mjModel and mjData instead of one object holding everything?

Because one is expensive and constant and the other is cheap and changing. Compiling the XML parses meshes and builds hulls; stepping does none of that. Separating them means you pay the compile once, and it means several mjData objects can share a single mjModel, which is exactly how a vectorised training environment runs many worlds against one copy of the geometry. It also draws a clean line for you: anything in mjModel is a fact about the robot, anything in mjData is a fact about right now.

6. Your loop runs mj_step as fast as Python allows and data.time reaches 100 seconds in 5 seconds of wall clock. Is the simulation running too fast?

No. Nothing in MuJoCo consults a wall clock; data.time only advances because mj_step adds the timestep to it. Running twenty times faster than reality is the normal and desirable case, and it is the entire economic argument for simulation. You only need to slow down when a human has to watch, which is what the sleep in the viewer loop is for, and that pacing lives in your code rather than in the physics.

Do this

Open module-02-simulation/code/step_loop.py. It has four functions marked # TODO(you); --hold and --speed are written for you as worked examples of the two shapes you will use all module.

cd module-02-simulation/code
python step_loop.py --gravity
python step_loop.py --state
python step_loop.py --forward
python step_loop.py --replay
  1. demo_gravity - run four seconds twice, once normally and once with model.opt.disableflags |= int(mujoco.mjtDisableBit.mjDSBL_ACTUATION), and print the two shoulder_lift traces side by side. You should see roughly 0.03° against roughly 24°.

  2. demo_state - load scene_box.xml and print, for each joint, the slice of qpos and the slice of qvel it owns. Then reset to keyframe 0 and print data.time. It is not zero.

  3. demo_forward - print the fingertip position four times: on a fresh MjData, after mj_forward, after writing qpos[1] = 0.8, and after a second mj_forward. Two of the four are equal, and which two is the lesson.

  4. demo_replay - reset to keyframe 0, zero the time, step six hundred times with a fixed ctrl, and return data.qpos.copy(). Do it twice and compare with np.array_equal, not np.allclose.

Then break it on purpose. In demo_replay, change one run to take one extra step, or to write ctrl one step later than the other. Watch the comparison fail and note how large the difference has grown by the end. A physics simulation amplifies a small divergence; that is why the comparison has to be exact, and why “close enough” is not a test.

What you can now do

You can load a MuJoCo model, build its state, advance it under your own control, and read anything the simulator knows. You can explain what belongs in mjModel and what belongs in mjData, and why the split makes parallel environments possible. You know that writing state does not recompute anything, that mj_forward and mj_step differ only by the passage of time, that qpos and qvel do not line up, and that leaving ctrl untouched is a command like any other. And you can reset a world to a known state and replay it exactly, which is the property the rest of this module is built on.

What you can now do

You can drive MuJoCo from a Python loop, read and write simulator state directly, and explain why the same state always produces the same next state.