35 min

Actuators: what you may say, and what you may know

The actuator type decides what the number in data.ctrl means, and choosing position control turns force from something you command into something you can only watch - for free in simulation, and not at all on the real arm.

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

data.ctrl[0] = 1.0 <motor> ctrl is a torque, N·m <position> ctrl is a target angle, rad <velocity> ctrl is a target speed, rad/s settles at 30.7° 1 N·m balances gravity at 30.6°, reached slowly settles at 47.0° 1 rad is 57.3°; gravity buys back the difference past 934°, still turning no angle was ever asked for, so none is ever reached
The same value written to data.ctrl, sent into three different actuator types on the same joint, producing three unrelated outcomes

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:

Elementctrl meansWhat it computesReach for it when
<motor>a force or torquegear · ctrl, unchangedyou want to command effort directly
<position>a target anglekp · (ctrl − qpos) − kv · qvelyou are driving a servo-style arm
<velocity>a target speedkv · (ctrl − qvel)conveyors, wheels, jogging a joint
<intvelocity>a target speedposition control on an integrated targetyou want smooth position moves from speed commands
<damper>a damping coefficient−ctrl · kv · qvel, resistance onlyyou want commandable drag, never motion
<adhesion>a suction strengtha pulling force on nearby contactssuction cups and magnets
<general>whatever you configurethe full gain and bias formnone 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 8(ctrlqpos)1.2qvel8(\text{ctrl} - \text{qpos}) - 1.2\,\text{qvel}, 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

data.ctrl you write 99.0 ctrlrange clip to ±3.2 → 3.2 gain, bias the PD line → 25.6 forcerange clip to ±3 → 3.0 gear × 1 here → 3.0 qfrc _actuator at the joint data.ctrl still reads 99.0 afterwards. Every clamp happens on a copy, so the array you inspect is your intention, never the simulator's action.
A written control value passes through the ctrl range clamp, the gain and bias arithmetic, the force range clamp and the gear before it reaches the joint

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

data.ctrl[0] the angle you asked for data.qpos[0] the angle right now data.qvel[0] the speed right now kp · (ctrl − qpos) − kv · qvel the same P and D terms you wrote by hand, gains read from the XML clip to forcerange ±3 N·m here; the only ceiling on contact force runs every timestep, in code you did not write data.actuator_force[0] free to read in simulation; no wire carries it on the real arm
Inside a position actuator: the commanded angle, the current angle and the current speed feed one PD expression, the result is clipped to the force range, and the output lands in data.actuator_force

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 kp(ctrlq)kvq˙k_p(\text{ctrl} - q) - k_v\dot q 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 kp=8k_p = 8 and kd=1.2k_d = 1.2. 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 kp(ctrlq)kvq˙k_p(\text{ctrl} - q) - k_v\dot q 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 kp(targetq)kvq˙k_p(\text{target} - q) - k_v\dot q, clipped to ±3\pm 3 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 the POSITION string and KV = 0.0 in the constants, then re-run part B. The assertion still passes, and the joint is at 68.9° and still moving at 23 degrees per second when the run ends. Give it forty seconds and it comes to rest at 76.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 forcerange to -1 1 and 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 POSITION for MOTOR in part C and command 1.9 instead. Free, the joint goes over the top and spins forever, because past horizontal gravity stops resisting. Blocked, actuator_force reads +1.9000 and stays there. This is the whole lesson in one comparison: against the same wall the position actuator escalated to 3.000 and 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.

What you can now do

You can pick a MuJoCo actuator type deliberately, read the force it is applying, and say why that number is safe to use in a script and dangerous to use in a policy.