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.
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 of one matches slot 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.
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.
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:
| Call | What it does | data.time |
|---|---|---|
mujoco.mj_forward(model, data) | recompute every derived value from the current inputs | unchanged |
mujoco.mj_step(model, data) | the same computation, then integrate one timestep | advances 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 time | shoulder_lift, servos on | servos off |
|---|---|---|
| 0.00 s | 0.000° | 0.00° |
| 1.00 s | 0.033° | 23.88° |
| 2.00 s | 0.033° | 23.89° |
| 4.00 s | 0.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 of qpos is not slot 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
-
demo_gravity- run four seconds twice, once normally and once withmodel.opt.disableflags |= int(mujoco.mjtDisableBit.mjDSBL_ACTUATION), and print the twoshoulder_lifttraces side by side. You should see roughly 0.03° against roughly 24°. -
demo_state- loadscene_box.xmland print, for each joint, the slice ofqposand the slice ofqvelit owns. Then reset to keyframe 0 and printdata.time. It is not zero. -
demo_forward- print the fingertip position four times: on a freshMjData, aftermj_forward, after writingqpos[1] = 0.8, and after a secondmj_forward. Two of the four are equal, and which two is the lesson. -
demo_replay- reset to keyframe 0, zero the time, step six hundred times with a fixedctrl, and returndata.qpos.copy(). Do it twice and compare withnp.array_equal, notnp.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.