Where you are. You can write six numbers into data.ctrl and step the simulator. This lesson is about what those numbers mean, who decided, and which number the simulator hands you that your real arm never will.
Two runs that agree on everything they can measure
Take a single joint with a weight on the end. Command it to ninety degrees and step for six seconds. It settles at 76.35° and stops.
Now put a block in the way. Same model, same joint, same command, same six seconds. It settles at 28.39° and stops.
Print everything you have. data.ctrl[0] reads 1.5708 in both runs, because that is what you wrote and nothing rewrote it. data.qpos[0] differs, obviously. data.qvel[0] is zero in both: both runs are finished, at rest, stable. Nothing raised an exception. Nothing logged a warning. Each run looks like a joint that went somewhere and stayed.
That pair of arrays, the command and the measured angle, is exactly what a real SO-101 gives you back over its serial bus. From those numbers alone the second run is indistinguishable from a run where you asked for a different angle.
One array in MjData knows better. data.actuator_force[0] reads 1.907 in the free run and 3.000 in the blocked one, and 3.000 is not a coincidence: it is the ceiling written into the model file. The joint is not resting. It is straining at its limit, forever, and only that one number says so.
The idea in one paragraph
A MuJoCo actuator is not a motor. It is a tiny controller you configure in XML, and the type you give it decides what the number in data.ctrl means. <motor> means the number is a force. <position> means it is a target angle, and MuJoCo runs a proportional-derivative loop for you every timestep. <velocity> means a target speed. Same joint, same array, three different contracts, and nothing stored in the array records which one is in force. That choice also decides what you are allowed to know. Commanding force makes force an input you own; commanding position makes force an outcome, computed inside the actuator, which the simulator will cheerfully let you read and a hobby servo will never report. So picking an actuator type is two decisions at once: the vocabulary you get to command in, and the signal you quietly go blind to.
One number, three contracts
Wider than the screen; scroll it sideways.
Write 1.0 into data.ctrl[0] and run the same joint for twenty seconds under each type. A motor holds one newton-metre of twist, and the joint swings up and rings down to 30.7°, the angle where gravity’s pull exactly matches it. A position actuator treats 1.0 as one radian and comes to rest at 47.0°, short of the 57.3 degrees a radian is worth, because gravity buys back the difference. A velocity actuator treats it as one radian per second and never arrives anywhere at all: twenty seconds in it has wound past 934 degrees and is still turning.
The types you will actually meet, all of which compile today:
| Element | ctrl means | What it computes | Reach for it when |
|---|---|---|---|
<motor> | a force or torque | gear · ctrl, unchanged | you want to command effort directly |
<position> | a target angle | kp · (ctrl − qpos) − kv · qvel | you are driving a servo-style arm |
<velocity> | a target speed | kv · (ctrl − qvel) | conveyors, wheels, jogging a joint |
<intvelocity> | a target speed | position control on an integrated target | you want smooth position moves from speed commands |
<damper> | a damping coefficient | −ctrl · kv · qvel, resistance only | you want commandable drag, never motion |
<adhesion> | a suction strength | a pulling force on nearby contacts | suction cups and magnets |
<general> | whatever you configure | the full gain and bias form | none of the presets fit |
They are all the same object
Load any of them and read model.actuator_gainprm and model.actuator_biasprm. Every actuator computes the same expression: a gain term on ctrl plus an affine bias built from the joint’s own position and velocity. The named types are presets that fill those two arrays for you.
A <position kp="8" kv="1.2"> compiles to gainprm = [8, 0, 0] and biasprm = [0, −8, −1.2]. Read those three bias numbers back as a formula and you get , which is the whole of position control. A <velocity kv="4"> compiles to gainprm = [4, 0, 0], biasprm = [0, 0, −4]: the position term is simply zero. A <motor> has an all-zero bias, which is why nothing about the joint’s current state affects what it does.
The command you wrote is not the command that ran
Wider than the screen; scroll it sideways.
Write 99.0 into data.ctrl[0] on a joint whose actuator has ctrlrange="-3.2 3.2" and forcerange="-3 3". Step once. Read data.ctrl[0] back and it still says 99.0. Read data.actuator_force[0] and it says 3.0, because MuJoCo clamped the command to 3.2, multiplied by the gain to get 25.6, and then clamped that to the force ceiling.
Every clamp happens on a copy. The array you wrote into keeps your intention, unchanged, forever.
Two more places the number changes on its way in. gear multiplies the actuator’s scalar force into joint torque, so with gear="20" and ctrl="0.25" you get data.actuator_force = 0.25 and data.qfrc_actuator = 5.0. And if the model omits ctrlrange altogether, there is no clamp at all: your 99.0 is used in full, which is a fast way to launch a link into orbit.
The position actuator is your PID, minus one term
Wider than the screen; scroll it sideways.
MJCF anatomy told you a <position> actuator is a PD controller living inside the simulator. Here is that claim as an experiment rather than a sentence. Every step, read qpos and qvel, compute yourself in Python, clip it to the force range, and compare against what MuJoCo put in data.actuator_force. Over six thousand steps the largest disagreement is 1.78e-15 newton-metres, which is floating-point rounding and nothing else.
The exercise below has you write that four-line function. It is worth doing, because the payoff arrives immediately.
The bench in this lesson is deliberately the same plant as the pendulum in PID: half a kilogram at forty centimetres, commanded to hold horizontal, with and . Your hand-written PD controller settled at 76.35° instead of 90. MuJoCo’s position actuator, running its own C code inside a real physics engine, settles at 76.3454°. The holding torque it reports, 1.9065 N·m, is the gravity torque at that angle to four decimal places.
What position control costs you
Look again at the blocked run. The command is unchanged, the joint is stationary, and data.actuator_force is pinned at 3.000 from the moment of contact to the end of the run. That is the unbounded retry loop from the machine itself, except that here you can watch it happen, because the simulator publishes the number.
That publication is the trap.
Choosing an action space, once
Simulation makes torque control free. Swap <position> for <motor>, write newton-metres, and you have direct force control on a hobby-class arm, which is a thing that costs thousands of dollars in hardware. It is tempting.
Resist it, for the same reason you would not design an API around a field the client cannot send. The arm on your desk accepts angles. Every demonstration you record, every dataset you publish, and every policy you train has to speak in numbers the hardware can receive, or the whole pipeline stops at the last step. The action space is a contract with the physical robot, and it is chosen once, early, and lived with.
Check yourself
1. A colleague hands you a script that writes 0.5 into every slot of data.ctrl and reports that “the arm barely moves”. What is the first file you open, and why?
The model XML, specifically the <actuator> block. 0.5 has no meaning until you know the actuator type: it is half a newton-metre for a <motor>, half a radian for a <position>, half a radian per second for a <velocity>. “Barely moves” is the expected behaviour of a small torque and the expected behaviour of a small angle target, and the script cannot tell you which. Nothing in data.ctrl records the units.
2. You write data.ctrl[0] = 50 and read it back after a step. It still says 50. Did the simulator apply a command of 50?
Almost certainly not. ctrlrange clamps the value on the way into the actuator, and the resulting force is clamped again by forcerange, but both clamps operate on a copy. data.ctrl keeps your intention unchanged. To find out what the simulator actually did, read data.actuator_force, which is post-clamp, or data.qfrc_actuator, which is that force converted into joint torque through gear.
3. Your Python PD calculation matches data.actuator_force to 1.78e-15 N·m. What has that proved, and what has it not?
It proves the position actuator’s law is exactly clipped to the force range, with the gains from the XML and no hidden terms, no integral action and no filtering. It does not prove any of that resembles the servo on your desk. A real STS3215 has quantised feedback, a discrete update rate, gearbox friction, backlash and a current limit, none of which appear in those four multiplications.
4. The joint is stationary, the command is unchanged, and actuator_force sits at the force range limit for a thousand steps. What is happening, and which real-world failure does it correspond to?
The actuator is stalled against something. The position error is not shrinking, so the PD law keeps demanding more force than the ceiling allows, and the clamp holds it there indefinitely. On hardware this is the servo drawing maximum current at zero speed, converting every watt into heat in the windings. It is how hobby servos die, and in simulation it costs nothing, which is precisely why it is easy to ship a controller that does it.
5. Why is data.actuator_force safe to use in a success check but not in a policy’s observation vector?
A success check runs in simulation only and is thrown away afterwards, so it may use anything the simulator knows. A policy’s observations must exist at deployment time. The SO-101 reports the angle it reached and nothing about the force its internal loop chose, so a policy that learned to condition on force has no way to compute its input on the real arm. The rule is not “avoid privileged numbers”, it is “avoid them in anything that crosses to hardware”.
6. Simulation gives you torque control for free. Name the reason not to take it, in one sentence.
Because the arm you are going to deploy on accepts angles, and an action space is a contract with the hardware: a dataset recorded in newton-metres cannot be replayed on a servo that only understands degrees.
Do this
Finish code/actuator_modes.py. Two # TODO(you) markers, one small model, about twenty minutes. Run it with python; no viewer and no mjpython needed.
1. run(model, ctrl, seconds) - make an MjData, write ctrl once, and step. Four lines. Part A then prints the same command interpreted three ways, and you should be able to predict the direction of each result before you look.
2. pd_force(target, q, qd) - return , clipped to N·m. Part B compares your function against data.actuator_force at every step and asserts the difference is below 1e-9. If the assertion fires, you have the sign of the derivative term backwards, which is a mistake worth making once.
Then three variations, five minutes each:
- Set
kv="0"in thePOSITIONstring andKV = 0.0in the constants, then re-run part B. The assertion still passes, and the joint is at68.9°and still moving at 23 degrees per second when the run ends. Give it forty seconds and it comes to rest at76.346°, the same place as before. The derivative term changes the journey, never the destination, which is the finding from PID reproduced by a different implementation. - Change
forcerangeto-1 1and re-run part C. The blocked run barely changes. The free run now stalls too, and at six seconds it has not finished moving; extend it to twenty and it rests just under 31 degrees, exactly where gravity’s torque equals the new ceiling. A force limit is not a safety feature bolted on the side. It is part of the reachable workspace. - Swap
POSITIONforMOTORin part C and command1.9instead. Free, the joint goes over the top and spins forever, because past horizontal gravity stops resisting. Blocked,actuator_forcereads+1.9000and stays there. This is the whole lesson in one comparison: against the same wall the position actuator escalated to3.000and the motor did exactly what it was told.
What you can now do
You can name the MuJoCo actuator types, say what data.ctrl means under each, and find the gain and bias arrays that define any of them. You can trace a written command through the control clamp, the gain, the force clamp and the gear, and explain why the array you wrote never records what happened. You can prove that a position actuator is the PD controller you built in Module 1 and that it inherits the same steady-state droop. And you can say precisely what position control hides: a force signal that simulation publishes for free and the hardware never sends, which makes it the most tempting way to train a policy that cannot leave your laptop.