MuJoCo actuator_force vs qfrc_actuator: why joint torque differs

Your logged actuator_force values can stay fixed while a hinge’s qfrc_actuator contribution shrinks, reverses or partly cancels. That is not automatically a broken force buffer. In MuJoCo 3.15.0, the two arrays sit on opposite sides of a state-dependent transmission map, and later gravity-compensation or limit stages can change the joint-space result again.

The practical check is to identify the correct output rows, reconstruct their signed sparse mapping, and only then inspect post-map settings. An actual Blender geometry experiment with two prescribed tendon efforts illustrates why: the prescribed actuator-space efforts remained p_A = -10 N and p_B = -6 N, yet their signed hinge contributions changed with crank angle. This illustrates the source-defined mapping; it is not a MuJoCo engine run.

Start with nout, not a dense nu-by-nv guess

The MuJoCo 3.15.0 actuation model separates controls, actuator objects, force outputs and generalized coordinates. The pinned model header names their counts, and the pinned data header defines the sparse moment-row storage.

Buffer or field Dimension in 3.15.0 Question it answers
ctrl nu What scalar controls were supplied?
actuator_force nout What effort did each force-output channel provide?
actuator_moment sparse nout × nv How does each output map into generalized coordinates at this state?
qfrc_actuator nv What generalized actuator contribution remains after mapping and later joint-space handling?

The sparse map uses moment_rownnz, moment_rowadr and moment_colind. A dense nu × nv reshape is not generally valid. nu, nactuator and nout coincide in many simple SISO models, which is precisely why incorrect logging code can appear to work.

This buffer question also differs from deciding whether a task needs kinematics or dynamics. The inverse kinematics versus inverse dynamics guide covers that broader layer choice.

Resolve the tendon sign before comparing numbers

For the scalar spatial tendons used here, actuator length is tendon length multiplied by scalar gear. The gear reference says gear scales length, moment arms, velocity and force mapping. In the pinned transmission implementation, a tendon output row copies the tendon-length Jacobian and scales it by gear[0].

For this positive-gear scalar case, the raw contribution is

tau_k = (d length_k / d q) × p_k.

Positive p acts toward increasing transmission length. A physical tensile force points from the crank attachment toward its anchor and shortens the cable, so the selected tensile inputs use p = -T: -10 N for tendon A and -6 N for tendon B. These are prescribed inputs, not values measured from MuJoCo.

The same sign can be checked geometrically with the hinge-axis component of r × F. That use of a moment arm should not be confused with moving an already known wrench between origins; the latter is covered in the wrench reference-point guide.

The measured mapping changes sign and can cancel

The Blender 5.2.2 LTS experiment used finite crank arms, fixed anchors and evaluated mesh centers in a translated, rotated world frame. It measured cable length at four center angles and at ±0.5° around each center. The centered derivative used a radian denominator, then multiplied by the unchanged prescribed p values. An independent audit compared those results with analytic circular gradients and direct signed r × F torque.

Crank angle Native A torque Native B torque Sampled aggregate Native r × F aggregate
-30° +1.9600 N·m +0.5641 N·m +2.5241 N·m +2.5241 N·m
10° +0.4532 N·m +0.7188 N·m +1.1720 N·m +1.1720 N·m
50° -1.7900 N·m +0.5295 N·m -1.2605 N·m -1.2606 N·m
90° -1.9230 N·m -0.1715 N·m -2.0946 N·m -2.0946 N·m

At 50°, the two outputs act in opposite generalized directions and partially cancel. By 90°, both contributions are negative. The actuator-space efforts did not change; the signed length gradients did.

Across the four center cases, the largest saved centered-gradient difference from the analytic gradient was about 6.87e-6 m/rad, below the predeclared 1e-4 m/rad allowance. The largest sampled-aggregate difference from native geometry torque was about 7.79e-5 N·m, below the 0.0016 N·m allowance. These are geometry and prescribed-force results. MuJoCo, controller dynamics, hardware and force telemetry were not run.

Planar crank and two assigned tendon pulls at 50 degrees, with opposing generalized torque contributions.
At 50°, the assigned pulls act through the audited mesh geometry to produce opposing joint contributions and partial torque cancellation. This is a geometry projection, not measured physical force or MuJoCo output.

Check what happens after the sparse map

The pinned forward-actuation code performs the sparse transpose multiplication. It can then add gravity compensation classified as actuator force and apply joint-level actuator-force limits. Earlier stages can clamp supplied controls, tendon aggregate force and individual actuator outputs. Current DC-motor mechanical terms can also be added after ordinary output clamping, so forcerange is not a universal promise about every later contribution.

A useful diagnosis order is:

  1. Record nu, nactuator, nout and nv, plus each actuator’s output address and count.
  2. Reconstruct only the populated sparse moment entries for the relevant nout rows.
  3. Confirm the transmission type, gear sign and compatible units.
  4. Compute the raw transpose-map contribution from actuator_force.
  5. Check gravity compensation, tendon limits, actuator limits, joint limits and DC-motor mechanics before judging final qfrc_actuator.

The numeric example is intentionally narrower than the API. It covers two scalar spatial tendons with gear = +1. Zero-length body or site transmissions, multi-output actuators and mixed joint types need their own mapping and units.

Reproduce the geometry calculation

The unchanged script below is pure standard-library Python. It carries the frozen angles, anchors, tensions and signed efforts, then compares analytic gradients, centered differences and r × F. It does not import Blender or MuJoCo and does not generate engine telemetry.

#!/usr/bin/env python3
"""Pure-math reader reproduction for the frozen T056 illustration.

This script does not import or run Blender or MuJoCo. The forces are prescribed
inputs. The resulting torques are geometry-derived values, not engine telemetry.
The file is intentionally unexecuted during source preparation.
"""

import json
import math


TOPIC_ID = "S1-W100-T056"
REVISION = "s1-w100-actuator-transmission-r1"
FROZEN_INPUT_SHA256 = "f4d412ee01f23db76f7ac49da6af87ca51117cade915dc120f0393ae86d4ba87"

ANGLES_DEGREES = (-30.0, 10.0, 50.0, 90.0)
CENTERED_STEP_DEGREES = 0.5
ORIGIN_M = (0.12, -0.08, 0.06)
EULER_XYZ_DEGREES = (11.0, -19.0, 33.0)
CABLES = (
    {
        "name": "A",
        "anchor_local_m": (0.35, 0.10, 0.0),
        "radius_m": 0.20,
        "phase_degrees": 0.0,
        "gear": 1.0,
        "tension_N": 10.0,
        "p_N": -10.0,
    },
    {
        "name": "B",
        "anchor_local_m": (-0.25, 0.20, 0.0),
        "radius_m": 0.12,
        "phase_degrees": 60.0,
        "gear": 1.0,
        "tension_N": 6.0,
        "p_N": -6.0,
    },
)

GRADIENT_TOLERANCE_M_PER_RAD = 0.0001
TORQUE_TOLERANCE_NM = 0.00002
AGGREGATE_FD_TOLERANCE_NM = 0.0016


def add(a, b):
    return tuple(x + y for x, y in zip(a, b))


def sub(a, b):
    return tuple(x - y for x, y in zip(a, b))


def scale(s, v):
    return tuple(s * x for x in v)


def dot(a, b):
    return sum(x * y for x, y in zip(a, b))


def cross(a, b):
    return (
        a[1] * b[2] - a[2] * b[1],
        a[2] * b[0] - a[0] * b[2],
        a[0] * b[1] - a[1] * b[0],
    )


def norm(v):
    return math.sqrt(dot(v, v))


def matmul(a, b):
    return tuple(
        tuple(sum(a[i][k] * b[k][j] for k in range(3)) for j in range(3))
        for i in range(3)
    )


def matvec(m, v):
    return tuple(sum(m[i][j] * v[j] for j in range(3)) for i in range(3))


def euler_xyz_matrix(degrees):
    """Match an XYZ Euler orientation for column vectors: Rz @ Ry @ Rx."""
    x, y, z = (math.radians(value) for value in degrees)
    cx, sx = math.cos(x), math.sin(x)
    cy, sy = math.cos(y), math.sin(y)
    cz, sz = math.cos(z), math.sin(z)
    rx = ((1.0, 0.0, 0.0), (0.0, cx, -sx), (0.0, sx, cx))
    ry = ((cy, 0.0, sy), (0.0, 1.0, 0.0), (-sy, 0.0, cy))
    rz = ((cz, -sz, 0.0), (sz, cz, 0.0), (0.0, 0.0, 1.0))
    return matmul(rz, matmul(ry, rx))


ROTATION = euler_xyz_matrix(EULER_XYZ_DEGREES)
AXIS_WORLD = matvec(ROTATION, (0.0, 0.0, 1.0))
PIVOT_WORLD = ORIGIN_M


def point_world(local_point):
    return add(ORIGIN_M, matvec(ROTATION, local_point))


def cable_state(cable, q_rad):
    angle = q_rad + math.radians(cable["phase_degrees"])
    radius = cable["radius_m"]
    attachment_local = (radius * math.cos(angle), radius * math.sin(angle), 0.0)
    derivative_local = (-radius * math.sin(angle), radius * math.cos(angle), 0.0)
    attachment = point_world(attachment_local)
    anchor = point_world(cable["anchor_local_m"])
    derivative = matvec(ROTATION, derivative_local)
    anchor_to_attachment = sub(attachment, anchor)
    tendon_length = norm(anchor_to_attachment)
    actuator_length = cable["gear"] * tendon_length
    length_gradient = cable["gear"] * dot(
        scale(1.0 / tendon_length, anchor_to_attachment), derivative
    )
    tensile_force = scale(
        cable["tension_N"] / tendon_length, sub(anchor, attachment)
    )
    lever = sub(attachment, PIVOT_WORLD)
    geometric_torque = dot(AXIS_WORLD, cross(lever, tensile_force))
    mapped_torque = length_gradient * cable["p_N"]
    return {
        "actuator_length_m": actuator_length,
        "length_gradient_m_per_rad": length_gradient,
        "mapped_torque_Nm": mapped_torque,
        "geometric_torque_Nm": geometric_torque,
    }


def finite_difference_gradient(cable, q_rad, step_rad):
    plus = cable_state(cable, q_rad + step_rad)["actuator_length_m"]
    minus = cable_state(cable, q_rad - step_rad)["actuator_length_m"]
    return (plus - minus) / (2.0 * step_rad)


def reproduce():
    step_rad = math.radians(CENTERED_STEP_DEGREES)
    rows = []
    for angle_degrees in ANGLES_DEGREES:
        q_rad = math.radians(angle_degrees)
        contributions = []
        aggregate_mapped = 0.0
        aggregate_geometric = 0.0
        aggregate_finite = 0.0
        for cable in CABLES:
            state = cable_state(cable, q_rad)
            finite_gradient = finite_difference_gradient(cable, q_rad, step_rad)
            finite_torque = finite_gradient * cable["p_N"]
            if abs(finite_gradient - state["length_gradient_m_per_rad"]) > GRADIENT_TOLERANCE_M_PER_RAD:
                raise AssertionError("centred length-gradient check failed")
            if abs(state["mapped_torque_Nm"] - state["geometric_torque_Nm"]) > TORQUE_TOLERANCE_NM:
                raise AssertionError("mapped and r-cross-F torque check failed")
            aggregate_mapped += state["mapped_torque_Nm"]
            aggregate_geometric += state["geometric_torque_Nm"]
            aggregate_finite += finite_torque
            contributions.append(
                {
                    "cable": cable["name"],
                    "prescribed_tension_N": cable["tension_N"],
                    "prescribed_p_N": cable["p_N"],
                    **state,
                    "finite_gradient_m_per_rad": finite_gradient,
                    "finite_torque_Nm": finite_torque,
                }
            )
        if abs(aggregate_mapped - aggregate_finite) > AGGREGATE_FD_TOLERANCE_NM:
            raise AssertionError("aggregate centred-difference torque check failed")
        rows.append(
            {
                "angle_degrees": angle_degrees,
                "contributions": contributions,
                "aggregate_mapped_torque_Nm": aggregate_mapped,
                "aggregate_geometric_torque_Nm": aggregate_geometric,
                "aggregate_finite_torque_Nm": aggregate_finite,
            }
        )
    return {
        "topic_id": TOPIC_ID,
        "revision": REVISION,
        "frozen_input_sha256": FROZEN_INPUT_SHA256,
        "scope": "pure math; prescribed inputs; no Blender or MuJoCo runtime",
        "angles": rows,
    }


if __name__ == "__main__":
    print(json.dumps(reproduce(), indent=2, sort_keys=True))