# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""Base robot configuration classes.
This module defines the configuration dataclasses for robot morphology, control
parameters, and simulator-specific settings. All robot configurations (SMPL, G1, etc.)
inherit from RobotConfig and specify their robot-specific parameters.
Key Classes:
- RobotConfig: Main robot configuration
- RobotAssetConfig: Robot asset and physics properties
- RobotControlConfig: PD control parameters
- ControlType: Enum for control modes
Key Features:
- Multi-simulator support
- Configurable PD gains and action scaling
- Asset file management
- Body name mappings for observations
- Kinematic structure extraction
"""
import copy
from dataclasses import dataclass, field, fields, replace
from protomotions.components.pose_lib import ControlInfo, KinematicInfo
from protomotions.assets import DEFAULT_ASSET_ROOT, resolve_asset_root
from protomotions.simulator.base_simulator.config import RobotNoiseConfig
from protomotions.simulator.isaacgym.config import (
IsaacGymSimParams,
IsaacGymPhysXParams,
)
from protomotions.simulator.isaaclab.config import (
IsaacLabSimParams,
IsaacLabPhysXParams,
)
from protomotions.simulator.newton.config import NewtonSimParams
from protomotions.simulator.mujoco.config import MujocoSimParams
from typing import List, Optional, Dict, Tuple, Union
from enum import Enum
import os
import re
import torch
# Physics steps per control step, per backend, for a humanoid. fps is this times
# control_hz, so these are the one place the backend sub-stepping lives.
_HUMANOID_STEPS = {"isaacgym": 2, "isaaclab": 4, "newton": 4, "mujoco": 20}
# The humanoid default physics for each backend, as importable templates. fps is
# left at the backend's class default here and filled from control_hz by
# humanoid(), so a robot that needs different physics changes one field off a
# template (dataclasses.replace(HUMANOID_ISAACLAB.physx, num_position_iterations=16))
# instead of restating a whole block, and humanoid() builds its defaults from the
# same templates — one source for the tuning.
HUMANOID_ISAACGYM = IsaacGymSimParams(
physx=IsaacGymPhysXParams(
num_position_iterations=8,
num_velocity_iterations=4,
max_depenetration_velocity=1,
)
)
HUMANOID_ISAACLAB = IsaacLabSimParams(
physx=IsaacLabPhysXParams(
num_position_iterations=8,
num_velocity_iterations=4,
max_depenetration_velocity=1,
)
)
HUMANOID_NEWTON = NewtonSimParams()
HUMANOID_MUJOCO = MujocoSimParams()
@dataclass
class SimulatorParams:
isaacgym: IsaacGymSimParams = field(default_factory=IsaacGymSimParams)
isaaclab: IsaacLabSimParams = field(default_factory=IsaacLabSimParams)
newton: NewtonSimParams = field(default_factory=NewtonSimParams)
mujoco: MujocoSimParams = field(default_factory=MujocoSimParams)
@classmethod
def humanoid(
cls,
control_hz: int,
*,
isaacgym: Optional[IsaacGymSimParams] = None,
isaaclab: Optional[IsaacLabSimParams] = None,
newton: Optional[NewtonSimParams] = None,
mujoco: Optional[MujocoSimParams] = None,
) -> "SimulatorParams":
"""Per-backend physics for a humanoid at ``control_hz`` policy steps/sec.
Each backend starts from its ``HUMANOID_*`` template, unless the robot
passes a block for that backend, in which case the passed block is used
instead. A passed block *replaces* the template wholesale — it is not
merged field by field — so any physics it leaves unset fall back to the
backend's class defaults, not the template's tuning. To change one field
off the tuned humanoid block, pass ``dataclasses.replace(HUMANOID_ISAACLAB,
fps=400)`` (or ``replace(HUMANOID_ISAACLAB.physx, ...)``), not a fresh
block. A block whose ``fps`` is ``None`` gets ``control_hz * steps``; an
explicit fps is kept as-is (and the exact-division rule in RobotConfig
checks it names control_hz and the block), so a block left without an fps
gets the right rate instead of a stale constant. Blocks are copied, so
the shared templates are never mutated.
"""
templates = {
"isaacgym": HUMANOID_ISAACGYM,
"isaaclab": HUMANOID_ISAACLAB,
"newton": HUMANOID_NEWTON,
"mujoco": HUMANOID_MUJOCO,
}
passed = {
"isaacgym": isaacgym,
"isaaclab": isaaclab,
"newton": newton,
"mujoco": mujoco,
}
blocks = {}
for backend, steps in _HUMANOID_STEPS.items():
block = (
passed[backend] if passed[backend] is not None else templates[backend]
)
fps = control_hz * steps if block.fps is None else block.fps
blocks[backend] = replace(copy.deepcopy(block), fps=fps)
return cls(**blocks)
@dataclass
class InitState:
"""Configuration for robot initial state."""
pos: Optional[List[float]] # [x, y, z] in meters
class ControlType(Enum):
"""Enum defining the available control types for the robot.
BUILT_IN_PD: Built-in PD controller (e.g. Isaac Gym's internal PD controller)
VELOCITY: Velocity control using custom PD controller
TORQUE: Direct torque control
PROPORTIONAL: Proportional control using custom PD controller
"""
BUILT_IN_PD = "built_in_pd"
TORQUE = "torque"
PROPORTIONAL = "proportional"
@classmethod
def from_str(cls, value: str) -> "ControlType":
"""Create enum from string, case-insensitive."""
try:
return next(
member for member in cls if member.value.lower() == value.lower()
)
except StopIteration:
raise ValueError(
f"'{value}' is not a valid {cls.__name__}. "
f"Valid values are: {[e.value for e in cls]}"
)
@dataclass
class RobotAssetConfig:
"""Configuration for robot asset properties."""
# Optional fields with defaults
# Stored as a portable, checkout-relative path so that configs pickled into
# resolved_configs*.pt stay valid when a run is moved between machines.
# Resolved to a concrete directory at read time by resolve_asset_root().
asset_root: str = DEFAULT_ASSET_ROOT
self_collisions: bool = True
# Optional fields
asset_file_name: str = None
replace_cylinder_with_capsule: Optional[bool] = None
thickness: Optional[float] = None
max_angular_velocity: Optional[float] = None
max_linear_velocity: Optional[float] = None
density: Optional[float] = None
angular_damping: Optional[float] = None
linear_damping: Optional[float] = None
disable_gravity: Optional[bool] = None
fix_base_link: Optional[bool] = None
def __post_init__(self):
"""Validate that asset_file_name is set."""
if not self.asset_file_name or not self.asset_file_name.endswith(".xml"):
raise ValueError(
f"RobotAssetConfig.asset_file_name ('{self.asset_file_name}') "
f"must be a valid path to an .xml MJCF file to extract kinematic info. "
"if you are using URDF, convert it to MJCF first"
)
@dataclass
class ControlConfig:
"""Configuration for robot control parameters."""
# Control info overrides for specific joints instead of the values from the MJCF asset
override_control_info: Optional[Dict[str, ControlInfo]] = None
# Can be "built_in_pd" or "proportional"/"velocity"/"torque" for Proportional, Velocity, Torque control
control_type: ControlType = ControlType.BUILT_IN_PD
# the positional limits used for rewards
soft_pos_limit: float = 0.9
# Regex patterns of joints that deliberately keep the MJCF's control values
# (no override). extract_control_info checks every other joint is claimed by
# an override pattern; this lists the exceptions.
asset_default_joints: Tuple[str, ...] = ()
# The following field is loaded post-init and populated from the MJCF asset
# Note: Using Field(init=False) to exclude from __init__ signature
control_info: Dict[str, ControlInfo] = field(init=False)
def __post_init__(self):
"""Refuse override_control_info values that are not ControlInfo."""
if self.override_control_info is not None:
for value in self.override_control_info.values():
if not isinstance(value, ControlInfo):
raise TypeError(
f"override_control_info values must be ControlInfo, not "
f"{type(value).__name__}"
)
def initialize_control_info(self, asset: RobotAssetConfig):
"""Initialize control info from asset configuration."""
if not hasattr(self, "control_info"):
from protomotions.components.pose_lib import extract_control_info
self.control_info = extract_control_info(
mjcf_path=os.path.join(
resolve_asset_root(asset.asset_root), asset.asset_file_name
),
override_control_info=self.override_control_info,
asset_default_joints=self.asset_default_joints,
)
[docs]
@dataclass
class RobotConfig:
"""Configuration for robot morphology and control parameters.
Defines all robot-specific parameters including asset files, body names,
control settings, and physical properties. Each robot (SMPL, G1, etc.)
should subclass this and provide robot-specific values.
Key configuration areas:
- **Asset**: Robot mesh/URDF files and physical properties
- **Body Names**: Named body parts for tracking and observations
- **Control**: PD gains, action scales, and control modes
- **Kinematic Info**: Joint structure extracted from MJCF/URDF
- **Simulator Params**: Simulator-specific overrides
Example::
config = RobotConfig(
semantic_forward_axis_xy=(1.0, 0.0),
asset=RobotAssetConfig(asset_file_name="robot.xml"),
control=ControlConfig(control_type=ControlType.BUILT_IN_PD)
)
"""
# Required nested config (subclasses must override with their own default_factory)
asset: RobotAssetConfig
common_naming_to_robot_body_names: Dict[str, List[str]] = field(
default_factory=lambda: {}
)
default_root_height: float = 1
semantic_forward_axis_xy: Optional[Tuple[float, float]] = None
"""Robot-local xy axis that points in the anatomical facing direction.
This is intentionally 2D, not 3D. ProtoMotions assumes Z-up humanoid
assets, so heading should only choose the horizontal anatomical forward
direction. Keeping the field in xy avoids using a forward vector with a
vertical component that would require re-orienting the robot to stand
upright before heading logic can be applied.
Every robot config declares this explicitly: ``__post_init__`` refuses
``None``, and a saved robot subtree without the field fails to load, since
``from_tree`` rebuilds every dataclass through its constructor. To identify
the axis for a new robot, run ``python scripts/identify_robot_facing_axis.py
--robots <robot-name> --view``.
"""
default_dof_pos: Optional[Union[List[float], torch.Tensor, Dict[str, float]]] = (
None # Default joint positions for resets (if None, uses zeros).
# Can be a Dict[str, float] mapping regex patterns to values (e.g.,
# {".*_knee_joint": 0.669}). Patterns are resolved against dof_names
# in __post_init__. Unmatched DOFs default to 0.0.
)
# Privileged contact reporting: measured robot rows and "contact against"
# partner columns. These are simulator truth, not deployable sensor readings.
# "all" selects every body; None or [] disables contact reporting.
contact_bodies: Optional[Union[List[str], str]] = None
# Exact names/globs: "terrain", "robot/*", "object/7", "projectile/*".
contact_partners: Union[List[str], str] = field(default_factory=lambda: ["terrain"])
trackable_bodies_subset: Union[List[str], str] = "all"
non_termination_contact_bodies: Union[List[str], str] = "all"
init_state: Optional[InitState] = None
reset_noise: Optional[RobotNoiseConfig] = None
mimic_small_marker_bodies: Optional[List[str]] = None
# Anchor body for observations/rewards (None = root body at index 0). This is
# the only anchor slot: anchor_body_index and resolved_anchor_body_name are
# read-only properties derived from it, so assigning the name (directly or via
# update_fields(anchor_body_name=...)) re-derives the index on the next read.
anchor_body_name: Optional[str] = None
# Physical IMU placement is independent of the observation anchor.
imu_body_name: Optional[str] = None
imu_mount_quat: Tuple[float, float, float, float] = (0.0, 0.0, 0.0, 1.0)
"""Unit xyzw rotation from sensor axes to the carrying body's axes."""
# Optional with default
contact_pairs_multiplier: int = 16
control: ControlConfig = field(default_factory=ControlConfig)
# Policy control rate in Hz (steps per second). Subclasses declare it
# (e.g. control_hz=50). Dataclass field ordering forces a default, so None
# means "not declared" and __post_init__ raises. control_dt is derived.
control_hz: int = field(default=None)
# The following fields are loaded post-init and populated from the MJCF asset
# Note: Using Field(init=False) to exclude from __init__ signature
kinematic_info: KinematicInfo = field(init=False)
number_of_actions: int = field(init=False)
# Per-backend simulation parameters. None resolves to the humanoid default
# built from control_hz (SimulatorParams.humanoid). A block a robot passes
# for a backend replaces that backend's template wholesale (fields it omits
# are class defaults, not the humanoid tuning), so to change one field off the
# tuning use dataclasses.replace(HUMANOID_<BACKEND>, ...), not a fresh block.
simulation_params: Optional[SimulatorParams] = None
@property
def control_dt(self) -> float:
"""Seconds per policy control step (1 / control_hz)."""
return 1.0 / self.control_hz
def _check_sim_fps_divisible(self) -> None:
"""Every backend's physics fps must be a positive whole multiple of control_hz."""
for backend in ("isaacgym", "isaaclab", "newton", "mujoco"):
fps = getattr(self.simulation_params, backend).fps
if fps is None:
raise ValueError(
f"{type(self).__name__}: simulation_params.{backend}.fps is not "
f"set; declare it or let SimulatorParams.humanoid derive it from "
f"control_hz"
)
if fps < 1:
raise ValueError(
f"{type(self).__name__}: simulation_params.{backend}.fps={fps} "
f"must be a positive integer"
)
if fps % self.control_hz != 0:
raise ValueError(
f"{type(self).__name__}: simulation_params.{backend}.fps={fps} "
f"is not a multiple of control_hz={self.control_hz}"
)
[docs]
def __post_init__(self):
"""Compute derived fields after initialization."""
if self.control_hz is None:
raise ValueError(
"RobotConfig.control_hz must be declared: policy steps per second"
)
# Remember whether the physics was derived (no block declared) so an
# override of control_hz re-derives it rather than keeping a stale rate.
self._simulation_params_derived = self.simulation_params is None
if self.simulation_params is None:
self.simulation_params = SimulatorParams.humanoid(self.control_hz)
self._check_sim_fps_divisible()
self._normalize_semantic_forward_axis_xy()
from protomotions.components.pose_lib import extract_kinematic_info
self.kinematic_info = extract_kinematic_info(
os.path.join(
resolve_asset_root(self.asset.asset_root), self.asset.asset_file_name
)
)
self._anchor_cache = (
self.anchor_body_name,
self._anchor_index_for(self.anchor_body_name),
)
# Initialize control info in the control config
self.control.initialize_control_info(self.asset)
self.number_of_actions = self.kinematic_info.num_dofs
# Initialize default_dof_pos: use provided values or zeros
if self.default_dof_pos is None:
# Default to zero joint positions
self.default_dof_pos = torch.zeros(
self.kinematic_info.num_dofs, dtype=torch.float32
)
elif isinstance(self.default_dof_pos, dict):
# Resolve regex pattern dict to tensor
dof_pos = torch.zeros(self.kinematic_info.num_dofs, dtype=torch.float32)
for pattern, value in self.default_dof_pos.items():
for i, name in enumerate(self.kinematic_info.dof_names):
if re.fullmatch(pattern, name):
dof_pos[i] = value
self.default_dof_pos = dof_pos
else:
# Convert to tensor if needed and validate
if not isinstance(self.default_dof_pos, torch.Tensor):
self.default_dof_pos = torch.tensor(
self.default_dof_pos, dtype=torch.float32
)
assert (
len(self.default_dof_pos) == self.kinematic_info.num_dofs
), f"default_dof_pos length {len(self.default_dof_pos)} != num_dofs {self.kinematic_info.num_dofs}"
self.mimic_small_marker_bodies = abstract_names_to_body_names(
self.mimic_small_marker_bodies, self
)
self.contact_bodies = abstract_names_to_body_names(self.contact_bodies, self)
self.trackable_bodies_subset = abstract_names_to_body_names(
self.trackable_bodies_subset, self
)
required_abstract_names = [
"all_left_foot_bodies",
"all_right_foot_bodies",
"all_left_hand_bodies",
"all_right_hand_bodies",
"head_body_name",
"torso_body_name",
]
for name in required_abstract_names:
assert (
name in self.common_naming_to_robot_body_names.keys()
), f"RobotConfig.common_naming_to_robot_body_names must contain {name}"
def _anchor_index_for(self, name: Optional[str]) -> int:
"""The body index for an anchor name (None means the root body at 0)."""
if name is None:
return 0
body_names = self.kinematic_info.body_names
if name not in body_names:
raise ValueError(
f"anchor_body_name {name!r} not found in body_names: {body_names}"
)
return body_names.index(name)
@property
def anchor_body_index(self) -> int:
"""Body index observations/rewards anchor on (0 = root), derived from the name.
There is one anchor slot, anchor_body_name. Assigning it re-derives the
index on the next read, so the two cannot drift apart; there is no stored
index to set (update_fields and the override funnel refuse it). The cache
is rebuilt when it is absent too, so a config unpickled from a
pre-property bundle (which has no cache) re-derives from the name rather
than raising AttributeError on the first read.
"""
cache = getattr(self, "_anchor_cache", None)
if cache is None or cache[0] != self.anchor_body_name:
self._anchor_cache = (
self.anchor_body_name,
self._anchor_index_for(self.anchor_body_name),
)
return self._anchor_cache[1]
@property
def resolved_anchor_body_name(self) -> str:
"""The anchored body's name, resolved through anchor_body_index."""
return self.kinematic_info.body_names[self.anchor_body_index]
[docs]
def body_indices(self, *names: str) -> Tuple[int, ...]:
"""Indices for body names, 'root', 'all' or semantic groups, in declaration order.
Each name is 'root', 'all', a key of common_naming_to_robot_body_names, or
a body name; the expansion is abstract_names_to_body_names', with an
unknown name and a body selected twice refused (both of which that
function silently allows).
"""
body_names = self.kinematic_info.body_names
indices: List[int] = []
seen = set()
for name in names:
for body in abstract_names_to_body_names(name, self):
if body not in body_names:
raise ValueError(
f"{name!r} is neither a body of {type(self).__name__} nor "
f"a semantic group; groups: "
f"{sorted(self.common_naming_to_robot_body_names)}"
)
if body in seen:
raise ValueError(f"body {body!r} selected twice")
seen.add(body)
indices.append(body_names.index(body))
return tuple(indices)
[docs]
def bodies_matching(self, pattern: str) -> Tuple[int, ...]:
"""Indices of bodies whose name fully matches ``pattern`` (regex), in body order."""
body_names = self.kinematic_info.body_names
indices = tuple(
i for i, name in enumerate(body_names) if re.fullmatch(pattern, name)
)
if not indices:
raise ValueError(
f"pattern {pattern!r} matches no body of {type(self).__name__}"
)
return indices
def _normalize_semantic_forward_axis_xy(self) -> None:
"""Validate and normalize the declared anatomical forward axis."""
if self.semantic_forward_axis_xy is None:
raise ValueError(
"RobotConfig.semantic_forward_axis_xy must be declared explicitly "
"as the robot-local xy axis that points in the anatomical "
"forward direction, e.g. (1.0, 0.0) or (0.0, -1.0). "
"The axis is 2D because ProtoMotions assumes Z-up robots and "
"uses this field only for horizontal heading, not upright pose "
"re-orientation. Run `python scripts/identify_robot_facing_axis.py "
"--robots <robot-name> --view` to identify and visualize it."
)
axis = torch.as_tensor(self.semantic_forward_axis_xy, dtype=torch.float32)
if axis.shape != (2,):
raise ValueError(
"RobotConfig.semantic_forward_axis_xy must be a non-zero 2D "
"axis. The field is xy-only because ProtoMotions assumes Z-up "
"robots and separates horizontal heading from standing upright."
)
norm = axis.norm()
if norm <= 1e-6:
raise ValueError(
"RobotConfig.semantic_forward_axis_xy must be a non-zero 2D "
"axis. The field is xy-only because ProtoMotions assumes Z-up "
"robots and separates horizontal heading from standing upright."
)
axis = axis / norm
self.semantic_forward_axis_xy = (float(axis[0]), float(axis[1]))
[docs]
def update_fields(self, **kwargs):
"""Update robot config fields and reprocess derived fields.
This is useful when you need to modify fields after initialization
without manually calling __post_init__.
Args:
**kwargs: Fields to update on the robot config
"""
for key in kwargs:
if not hasattr(self, key):
raise ValueError(f"RobotConfig has no field '{key}'")
if _is_derived_field(type(self), key):
source = (
"anchor_body_name" if key == "anchor_body_index" else "the asset"
)
raise ValueError(
f"RobotConfig.{key} is derived; set the field it is derived "
f"from ({source})"
)
for key, value in kwargs.items():
setattr(self, key, value)
if "semantic_forward_axis_xy" in kwargs:
self._normalize_semantic_forward_axis_xy()
# anchor_body_index re-derives lazily on read via its property, so an
# updated anchor_body_name needs no explicit recompute here.
if "simulation_params" in kwargs:
self._simulation_params_derived = kwargs["simulation_params"] is None
if "control_hz" in kwargs or "simulation_params" in kwargs:
# A robot whose physics was derived re-derives it at the new rate; an
# explicit block is kept and the divisibility check names it.
if self.simulation_params is None or (
"control_hz" in kwargs and self._simulation_params_derived
):
self.simulation_params = SimulatorParams.humanoid(self.control_hz)
self._check_sim_fps_divisible()
# Reprocess fields that depend on the updated values
self.mimic_small_marker_bodies = abstract_names_to_body_names(
self.mimic_small_marker_bodies, self
)
self.contact_bodies = abstract_names_to_body_names(self.contact_bodies, self)
self.trackable_bodies_subset = abstract_names_to_body_names(
self.trackable_bodies_subset, self
)
def abstract_names_to_body_names(
names: Union[List[str], str], robot_config: RobotConfig
):
if names is None:
return None
if names == "all":
return robot_config.kinematic_info.body_names
if isinstance(names, list):
parsed_names = []
for name in names:
parsed_names.extend(abstract_names_to_body_names(name, robot_config))
return parsed_names
if names == "root":
return [robot_config.kinematic_info.body_names[0]]
elif names in robot_config.common_naming_to_robot_body_names.keys():
return robot_config.common_naming_to_robot_body_names[names]
else:
return [names]
def _is_derived_field(cls, key: str) -> bool:
"""True if ``key`` is a derived member: a property or an ``init=False`` field."""
if isinstance(getattr(cls, key, None), property):
return True
return any(f.name == key and not f.init for f in fields(cls))