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