Skip to content

Action Space — 29 Degrees of Freedom

The Unitree G1 humanoid has 29 controllable joints. Every one of them has a name, a physical limit, a default pose, and a place in the hierarchy. Neon knows them all.


The Body

            ┌───────────┐
            │   Head     │  2 DoF (pitch, yaw)
            │  [15-16]   │
            └─────┬─────┘
            ┌─────┴─────┐
   Left Arm │   Torso    │ Right Arm
   7 DoF    │   1 DoF    │ 7 DoF
   [0-6]    │   [14]     │ [7-13]
            └─────┬─────┘
         ┌────────┴────────┐
   Left Leg              Right Leg
   6 DoF                 6 DoF
   [17-22]               [23-28]

Twenty-nine joints. Six groups. One body.


Control Modes — Start Simple, Scale Up

You don't need all 29 joints on day one. Neon gives you three levels:

Mode Joints Action Dim When To Use
arms_only 14 (7+7 arms including grippers) 17 (14 + 3 loco) Tabletop manipulation — reaching, grasping, placing
upper_body 17 (arms + torso + head) 20 (17 + 3 loco) Tasks that need gaze tracking or torso rotation
whole_body 29 (everything) 32 (29 + 3 loco) Full locomotion + manipulation — the endgame

The extra 3 dimensions are always locomotion velocity: (vx, vy, ω) — forward speed, lateral speed, and rotation rate. Even in arms_only mode, the robot can walk.

from neon.data.action_space import G1ActionSpace, ControlMode

# Start with arms
space = G1ActionSpace(mode=ControlMode.ARMS_ONLY, include_locomotion=True)
print(space.action_dim)   # 17
print(space.joint_names)  # ['left_shoulder_pitch', 'left_shoulder_roll', ...]

# Scale to the full body when you're ready
space = G1ActionSpace(mode=ControlMode.WHOLE_BODY, include_locomotion=True)
print(space.action_dim)   # 32

The Arms — 14 DoF

Each arm has 7 joints: 3 shoulder, 1 elbow, 2 wrist, 1 gripper. The left and right mirror each other.

Joint Index Range (rad) What It Does
left_shoulder_pitch 0 [-2.87, 2.87] Forward/backward swing
left_shoulder_roll 1 [-0.34, 3.11] Outward/inward rotation
left_shoulder_yaw 2 [-1.30, 2.18] Internal/external rotation
left_elbow 3 [-1.25, 2.61] Bend
left_wrist_yaw 4 [-1.57, 1.57] Twist
left_wrist_roll 5 [-0.52, 0.52] Tilt
left_gripper 6 [0.00, 1.00] Open (0) → Close (1)

Right arm: indices 7–13, symmetric limits.

The Legs — 12 DoF

Each leg has 6 joints: hip (roll, yaw, pitch), knee, ankle (pitch, roll).

Expand: full leg joint table | Joint | Index | Range (rad) | What It Does | |-------|-------|-------------|-------------| | `left_hip_roll` | 17 | [-0.52, 0.52] | Side-to-side sway | | `left_hip_yaw` | 18 | [-0.52, 0.52] | Rotation | | `left_hip_pitch` | 19 | [-1.57, 0.52] | Forward/backward stride | | `left_knee` | 20 | [0.00, 2.53] | Bend (always positive — knees don't bend backwards) | | `left_ankle_pitch` | 21 | [-0.87, 0.52] | Foot tilt | | `left_ankle_roll` | 22 | [-0.17, 0.17] | Foot roll (narrow — the ankle is precise) | Right leg: indices 23–28, symmetric limits.

Normalization

All actions normalize to [-1, 1] for training stability. The formula uses each joint's physical limits:

$$ a_{normalized} = \frac{a_{raw} - \frac{lo + hi}{2}}{\frac{hi - lo}{2}} $$

import numpy as np

space = G1ActionSpace(mode=ControlMode.ARMS_ONLY)

raw = np.array([0.5, 1.0, -0.3, ...])  # 14 joints in radians
normalized = space.normalize(raw)        # Now in [-1, 1]
recovered = space.denormalize(normalized)
np.testing.assert_allclose(raw, recovered, atol=1e-6)  # ✓ Perfect round-trip

Locomotion has separate limits

Velocity isn't in radians: vx ∈ [-1.5, 1.5] m/s, vy ∈ [-0.5, 0.5] m/s, ω ∈ [-1.0, 1.0] rad/s. Configurable via locomotion_limits.


Group-Based Access

Split any action vector into named groups — for debugging, visualization, or per-group control:

actions = np.random.randn(16, 17)  # 16 timesteps, arms_only mode

split = space.split_action(actions)
split["left_arm"]     # (16, 7) — shoulder + elbow + wrist + gripper
split["right_arm"]    # (16, 7)
split["locomotion"]   # (16, 3) — vx, vy, ω

This is how the separate action heads work internally — each group gets its own MLP decoder, its own loss weight, its own learning dynamics.


Cross-Embodiment Mapping

The ActionMapper converts actions from other robots into G1 space:

from neon.data.data_soup import ActionMapper

mapper = ActionMapper(target_space=space)

# Franka 7-DoF + gripper → G1 right arm
franka_actions = np.random.randn(100, 8)
g1_actions = mapper.map_actions(franka_actions, "franka")
# → (100, 17) — mapped to G1 arms + zero-padded locomotion

Supported embodiments: g1, agibot, franka, so100, gr1. Adding a new one is a dict entry in ActionMapper.EMBODIMENT_MAPS.


Next: Action Chunking — why we predict 16 steps at once