Source code for wbc_mjlab.motion.robot_assets

"""Robot assets for offline motion conversion (scene + debias metadata)."""

from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path

import mujoco
import numpy as np
from mjlab.scene import SceneCfg

from wbc_mjlab.robots.ids import RobotId, resolve_robot_id


[docs] @dataclass(frozen=True) class RobotMotionSpec: """Scene + optional foot metadata for motion NPZ conversion.""" scene_cfg_fn: Callable[[], SceneCfg] """Returns the robot scene used for FK during conversion.""" actuated_joint_names: tuple[str, ...] | None = None """Optional actuated DoF subset.""" foot_body_names: tuple[str, ...] | None = None """Feet used for ground-contact z debias.""" foot_sole_z: float | None = None """Sole offset (m) for z debias.""" mjcf_path: Path | None = None """Optional path to the robot MJCF."""
def _g1_scene() -> SceneCfg: from wbc_mjlab.robots.env import make_wbc_env_cfg return make_wbc_env_cfg("g1").scene def _g1_motion_spec() -> RobotMotionSpec: from wbc_mjlab.robots.g1.constants import ( MOTION_Z_DEBIAS_FOOT_BODY_NAMES, MOTION_Z_DEBIAS_FOOT_SOLE_Z, ) return RobotMotionSpec( scene_cfg_fn=_g1_scene, actuated_joint_names=None, foot_body_names=MOTION_Z_DEBIAS_FOOT_BODY_NAMES, foot_sole_z=MOTION_Z_DEBIAS_FOOT_SOLE_Z, ) _ROBOT_SPECS: dict[str, RobotMotionSpec] = { "g1": _g1_motion_spec(), } _ROBOT_MJCF_PATHS: dict[str, Path] = {} def register_robot_motion_spec(robot_id: str, spec: RobotMotionSpec) -> None: rid = robot_id.strip().lower() _ROBOT_SPECS[rid] = spec if spec.mjcf_path is not None: _ROBOT_MJCF_PATHS[rid] = spec.mjcf_path.resolve() def register_robot_mjcf_path(robot_id: str, path: Path | str) -> None: rid = robot_id.strip().lower() _ROBOT_MJCF_PATHS[rid] = Path(path).expanduser().resolve() def get_robot_mjcf_path(robot_id: str | RobotId) -> Path: """Path to the robot MJCF used for offline vis / body-name lookup.""" key = robot_id.strip().lower() if isinstance(robot_id, str) else robot_id if key in _ROBOT_MJCF_PATHS: return _ROBOT_MJCF_PATHS[key] rid = resolve_robot_id(robot_id) if isinstance(robot_id, str) else robot_id if rid in _ROBOT_MJCF_PATHS: return _ROBOT_MJCF_PATHS[rid] if rid == "g1": from wbc_mjlab.robots.g1.constants import G1_XML return G1_XML raise ValueError( f"No MJCF path registered for robot {rid!r}. " f"Set RobotMotionSpec.mjcf_path or WbcRobotSpec.mjcf_path when registering." ) def get_robot_motion_spec(name: str) -> tuple[str, RobotMotionSpec]: robot_id = resolve_robot_id(name) return robot_id, _ROBOT_SPECS[robot_id] def conversion_scene_cfg(motion_spec: RobotMotionSpec, *, num_envs: int = 1) -> SceneCfg: scene_cfg = motion_spec.scene_cfg_fn() scene_cfg.num_envs = num_envs return scene_cfg def strip_entity_prefix(joint_name: str, entity_name: str = "robot") -> str: prefix = f"{entity_name}/" return joint_name[len(prefix) :] if joint_name.startswith(prefix) else joint_name def normalize_joint_name_list( names: list[str] | tuple[str, ...], *, entity_name: str = "robot", ) -> list[str]: out: list[str] = [] for raw in names: name = strip_entity_prefix(str(raw), entity_name=entity_name) if name in ("floating_base_joint", "freejoint", "root_joint"): continue out.append(name) return out def qpos_dof_joint_names( model: mujoco.MjModel, *, entity_name: str = "robot" ) -> list[str]: names: list[str] = [] for j in range(model.njnt): jtype = int(model.jnt_type[j]) if jtype == mujoco.mjtJoint.mjJNT_FREE: continue name = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_JOINT, j) if name is None: raise RuntimeError(f"Unnamed joint at index {j}") names.append(strip_entity_prefix(name, entity_name=entity_name)) expected = model.nq - 7 if len(names) != expected: expanded: list[str] = [] for j in range(model.njnt): jtype = int(model.jnt_type[j]) if jtype == mujoco.mjtJoint.mjJNT_FREE: continue name = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_JOINT, j) or "" adr = int(model.jnt_qposadr[j]) if j + 1 < model.njnt: next_adr = int(model.jnt_qposadr[j + 1]) else: next_adr = model.nq width = next_adr - adr plain = strip_entity_prefix(name, entity_name=entity_name) if width == 1: expanded.append(plain) else: for k in range(width): expanded.append(f"{plain}__qpos{k}") if len(expanded) != expected: raise RuntimeError( f"Could not derive {expected} qpos tail names from model " f"(got {len(expanded)} expanded, {len(names)} joints)." ) return expanded return names def remap_dof_columns( dof_pos: np.ndarray, source_names: list[str], target_names: list[str], ) -> np.ndarray: if list(source_names) == list(target_names): return dof_pos index = [source_names.index(n) for n in target_names] return dof_pos[:, index] def resolve_dof_joint_names( model: mujoco.MjModel, dof_dim: int, *, source_joint_names: list[str] | None = None, entity_name: str = "robot", ) -> tuple[list[str], list[str] | None]: model_names = qpos_dof_joint_names(model, entity_name=entity_name) if dof_dim != len(model_names): raise ValueError( f"Motion DOF width {dof_dim} != model qpos tail {len(model_names)} " f"(model.nq={model.nq}). Check --robot matches the retargeted asset." ) if source_joint_names is None: return model_names, None names = normalize_joint_name_list(source_joint_names, entity_name=entity_name) if len(names) != dof_dim: raise ValueError( f"Source joint name list length {len(names)} != motion DOF width {dof_dim}" ) return model_names, names