Skip to content

Evaluate the joint PD inside mj_step via MuJoCo actuators - #2

Open
T-K-233 wants to merge 1 commit into
mainfrom
feat/physics-rate-joint-pd
Open

Evaluate the joint PD inside mj_step via MuJoCo actuators#2
T-K-233 wants to merge 1 commit into
mainfrom
feat/physics-rate-joint-pd

Conversation

@T-K-233

@T-K-233 T-K-233 commented Jul 27, 2026

Copy link
Copy Markdown
Member

Problem

MujocoSystem::write() computed the MIT PD itself and injected it as qfrc_applied:

const double cmd = stiffness_command * (position_command - position)
                 + damping_command  * (velocity_command - velocity) + effort_command;
mj_data_->qfrc_applied[joint_state.mj_vel_adr] = clamp(cmd, -max_effort, max_effort);

Two consequences:

  1. The CM rate became the PD rate in sim. On real hardware the RobStride firmware closes its own ~1 kHz loop, so the host rate only sets how often the setpoint is refreshed. In sim the PD was resampled once per controller_manager tick, so lowering the CM rate destabilised joints that are perfectly stable on the robot.
  2. MuJoCo's actuator machinery was bypassed entirely — no ctrl, no gainprm, and critically no implicit integration of the damping term, which is what keeps a stiff PD stable at a coarse timestep.

Change

Each joint's actuator is converted into a position servo at init — biastype=affine, since a plain MJCF <motor> is gaintype=fixed/gainprm=1 with no bias, and biasprm=(0,−kp,−kv) makes it behave exactly like <position kp kv>. write() then updates gainprm/biasprm from the stiffness/damping command interfaces and ctrl from the position command, and MuJoCo re-evaluates the feedback every physics step.

Only the terms that are constant over a tick remain a generalized applied force — the velocity target and torque feedforward, both zero on the RL and standby paths. Force clamping now comes from the MJCF's own forcerange, applied inside mj_step. read() reports qfrc_actuator + qfrc_applied so effort still accounts for both paths.

This is the architecture gz_ros2_control already uses: write() deposits a setpoint and the simulator's own PID runs in PreUpdate, every physics iteration. It is also what mjlab trains against, so it closes a sim/training gap as well as a sim/real one.

Measured

Lite arms sim, worst-joint peak-to-peak after standby settles:

p2p behaviour
200 Hz, PD in mj_step 0.00065 rad quiescent
200 Hz, PD in write() 0.0371 rad 25–37 Hz wrist chatter
400 Hz, PD in write() 0.0024 rad quiescent

Not only chatter-free at the lower rate but 3.6× quieter than the previous best arrangement — and sim and real can now share one CM rate.

Compatibility

A joint whose MJCF declares no actuator falls back to the previous qfrc_applied behaviour with a warning, so models without actuators keep working unchanged.

Depends on MJCFs whose actuators are position servos: Berkeley-Humanoids/Lite-Description#13. Companion CM-rate change: Berkeley-Humanoids/humanoid_control_ros2#24.

🤖 Generated with Claude Code

https://claude.ai/code/session_012QKJQ9hze1hMWXJpdPcjp3

MujocoSystem computed the MIT PD in write() and injected it as
qfrc_applied, so the PD was sampled once per controller_manager tick. That
made the CM rate the PD rate in sim -- unlike real hardware, where the drive
firmware owns the inner loop -- and it bypassed MuJoCo's actuator machinery
entirely, so the integrator never got to treat the damping term implicitly.

Now each joint's actuator is turned into a position servo at init (biastype
affine; a plain MJCF <motor> is gaintype fixed / gainprm 1 with no bias, so
setting biasprm to (0, -kp, -kv) makes it behave exactly like
<position kp kv>), and write() updates gainprm/biasprm from the stiffness and
damping command interfaces plus ctrl from the position command. MuJoCo then
re-evaluates the feedback every physics step.

This is the architecture gz_ros2_control already uses: write() deposits a
setpoint and the simulator's own PID runs in PreUpdate, every physics
iteration. It is also what mjlab trains against, so it closes a sim/training
gap as well as a sim/real one.

Only the terms that are constant over a tick stay a generalized applied
force -- the velocity target and the torque feedforward, both zero on the RL
and standby paths. Force clamping now comes from the MJCF's own forcerange,
applied inside mj_step. read() reports qfrc_actuator + qfrc_applied so effort
still accounts for both paths. A joint whose MJCF declares no actuator falls
back to the old behaviour with a warning, so models without actuators keep
working.

Measured in the Lite arms sim, worst-joint peak-to-peak after standby settles:

  200 Hz, PD in mj_step   0.00065 rad   quiescent
  200 Hz, PD in write()   0.0371  rad   25-37 Hz wrist chatter
  400 Hz, PD in write()   0.0024  rad   quiescent

So the PD is not only chatter-free at the lower rate, it is 3.6x quieter than
the previous best arrangement -- and sim and real can now share one CM rate.

Requires MJCFs whose actuators are position servos (Lite-Description#13).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012QKJQ9hze1hMWXJpdPcjp3
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant