Rate this Page

Source code for torchrl.envs.custom.mujoco.menagerie

# Copyright (c) Meta Platforms, Inc. and affiliates.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
"""Any MuJoCo Menagerie robot as a TorchRL env.

`MuJoCo Menagerie <https://github.com/google-deepmind/mujoco_menagerie>`_ is
Google DeepMind's collection of curated robot models: arms, hands, grippers,
quadrupeds, bipeds, humanoids, drones and mobile manipulators. It ships models
and scenes, not tasks. :class:`MenagerieEnv` loads any of them by name on the
:class:`~torchrl.envs.MujocoEnv` physics backends, resets to the model's
``home`` keyframe and exposes the raw simulator state; :class:`MenagerieTask`
holds the few task parameters that make sense for every robot, and a
:class:`~torchrl.envs.Transform` supplies the reward of a task of your own.
"""

from __future__ import annotations

import importlib.util
import os
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any, ClassVar

import torch
from tensordict import TensorDict, TensorDictBase
from torchrl.data.tensor_specs import Composite, Unbounded
from torchrl.envs.custom.mujoco._backends import BackendName
from torchrl.envs.custom.mujoco.base import _MujocoMeta, MujocoEnv

_has_mujoco_menagerie = importlib.util.find_spec("mujoco_menagerie") is not None

MENAGERIE_ENV_VAR = "TORCHRL_MUJOCO_MENAGERIE_PATH"


[docs] @dataclass(frozen=True) class MenagerieTask: """Task parameters of :class:`MenagerieEnv`. Menagerie ships robots, not tasks, so the defaults describe the bare simulator: reset around the model's ``home`` keyframe, observe the state, never terminate before the horizon and pay no reward. Non-zero weights turn the built-in reward terms on; leave them at zero to let a :class:`~torchrl.envs.Transform` write ``("next", "reward")`` instead. :meth:`MenagerieEnv.hold_pose_task` is the preset that turns them on. Args: keyframe (str, optional): name of the MJCF keyframe whose ``qpos`` and ``qvel`` the reset state is drawn around. ``None`` (default) uses the ``home`` keyframe when the model defines one and the model's ``qpos0`` at rest otherwise; a name the model does not define raises ``KeyError`` at construction. site_names (Sequence[str], optional): MuJoCo sites whose world positions are exposed as the ``site_positions`` observation, shaped ``(num_envs, len(site_names), 3)`` in this order. Empty (default) omits the entry. terminate_below_height (float, optional): if set, the episode terminates once the height (world ``z``) of the floating base drops below this value, in meters. The base is the first free joint in model order, the robot's in Menagerie scenes that carry one; a scene whose only free joint belongs to an object (a cube on a table) would track that object instead. ``None`` (default) never terminates on height. pose_weight (float, optional): weight of the pose term, ``exp(-mean((q - q_key)^2) / pose_std^2)`` over the hinge and slide joints, where ``q_key`` is the reset keyframe. ``0.0`` (default) turns the term off. pose_std (float, optional): scale of the pose term, in the joints' units. Defaults to ``0.5``. control_cost_weight (float, optional): weight of the control cost, minus the mean squared action after mapping each actuator's control range onto ``[-1, 1]``. ``0.0`` (default) turns the term off. alive_bonus (float, optional): constant paid at every step that does not terminate on height. Defaults to ``0.0``. Examples: >>> from dataclasses import replace >>> from torchrl.envs import MenagerieEnv, MenagerieTask >>> task = MenagerieTask(site_names=("imu",), terminate_below_height=0.15) >>> task.pose_weight, task.site_names (0.0, ('imu',)) >>> standing = replace(MenagerieEnv.hold_pose_task(), alive_bonus=0.5) >>> standing.pose_weight, standing.alive_bonus (1.0, 0.5) >>> env = MenagerieEnv("unitree_go2", download=True, task=standing) # doctest: +SKIP """ keyframe: str | None = None site_names: Sequence[str] = () terminate_below_height: float | None = None pose_weight: float = 0.0 pose_std: float = 0.5 control_cost_weight: float = 0.0 alive_bonus: float = 0.0 def __post_init__(self): if isinstance(self.site_names, str): raise TypeError("site_names must be a sequence of site names, not a str.") object.__setattr__(self, "site_names", tuple(self.site_names)) if not self.pose_std > 0: raise ValueError(f"pose_std must be positive, got {self.pose_std}.")
class _MenagerieMeta(_MujocoMeta): """Resolve, and if requested download, the model once before batching. :class:`~torchrl.envs.custom.mujoco.base._MujocoMeta` builds one env per worker for the native backend; resolving the XML here hands the workers a local path, so they never download concurrently. """ def __call__( cls, robot: str, *args: Any, entry: str | None = None, menagerie_path: str | Path | None = None, download: bool = False, **kwargs: Any, ): xml = cls.resolve_model( robot, entry=entry, menagerie_path=menagerie_path, download=download ) return super().__call__( robot, *args, entry=entry, menagerie_path=xml, download=False, **kwargs )
[docs] class MenagerieEnv(MujocoEnv, metaclass=_MenagerieMeta): r"""A MuJoCo Menagerie robot, loaded by name. Menagerie ships robots, not tasks, so this env is the bare simulator of one of its models. The action is the model's actuator control vector (position targets for most arms and hands, torques for most legged robots, in the units and ranges of the MJCF). The observation is the raw state: ``qpos``, ``qvel``, the model's ``sensordata`` when it defines sensors, and the world positions of the sites named in the task under ``site_positions``. As after MuJoCo's own ``mj_step``, ``sensordata``, ``site_positions`` and contacts are computed before the last physics substep of the env step, so they trail ``qpos`` and ``qvel`` by one substep; a reset returns them consistent. A reset starts from the model's ``home`` keyframe plus ``reset_noise_scale`` uniform noise. The episode ends at ``max_episode_steps``, when the state stops being finite or, if the task asks for it, when a floating base drops below a height. The reward is the weighted sum of the :class:`MenagerieTask` terms, all off by default; a task of your own is a :class:`~torchrl.envs.Transform` that writes ``("next", "reward")`` from the observation. The model is resolved from ``menagerie_path`` (a ``mujoco_menagerie`` checkout, the robot's directory inside one, or the XML itself), then from the :data:`MENAGERIE_ENV_VAR` environment variable, then from the cache of the ``mujoco-menagerie`` package (``pip install mujoco-menagerie``), which ``download=True`` lets fetch the robot. The package pins every robot to one Menagerie commit; a checkout is whatever revision it holds. The resolved XML is :attr:`model_path`, next to :attr:`robot`, :attr:`entry` and :attr:`task`; :attr:`reset_state` is the keyframe the reset draws around, which position-controlled robots also use as their home targets. ``examples/menagerie/ppo.py`` trains PPO to hold the pose of any robot or to walk a quadruped, and plays the policy back with ``rlrender``. Menagerie's ``scene`` entry points put the robot on a floor with lights and are meant for the ``"mujoco"`` backend, the default here. The models Menagerie maintains for MJX (``scene_mjx``, ``mjx_scene`` and the like, with primitive collision geoms) also run on the ``"mujoco-torch"`` and ``"mjx"`` backends, which vectorize ``num_envs`` simulators on an accelerator; the other scenes use collision pairs those engines do not implement. Those engines may also evaluate acceleration-stage sensors (accelerometers, force and torque sensors) differently from MuJoCo. Args: robot (str): the Menagerie model directory, for example ``"unitree_go2"``, ``"franka_emika_panda"`` or ``"shadow_hand"``. Keyword Args: entry (str, optional): the top-level XML to load, by file stem: ``"scene"`` (the robot on a floor with lights), ``"scene_mjx"`` where Menagerie provides one, or the robot alone (``"go2"``). ``None`` (default) loads ``scene.xml`` from a checkout and the registry's default scene from the package. menagerie_path (str or Path, optional): a ``mujoco_menagerie`` checkout, the robot's directory inside one, or the XML itself. Defaults to the :data:`MENAGERIE_ENV_VAR` environment variable, then to the ``mujoco-menagerie`` package cache. download (bool, optional): whether the ``mujoco-menagerie`` package may download the robot into its cache. Only consulted when neither ``menagerie_path`` nor the environment variable is set: a checkout that lacks the robot raises instead of falling back to the package. Defaults to ``False``, in which case a robot missing from the cache raises ``FileNotFoundError`` describing every option. task (MenagerieTask, optional): the reset keyframe, the observed sites, the termination height and the reward weights. Defaults to ``MenagerieTask()``: the ``home`` keyframe, no sites, no termination on height and a zero reward. See :meth:`hold_pose_task`. backend (str, optional): ``"mujoco"`` (default) runs the official C bindings, one simulator per worker process with :class:`~torchrl.envs.ParallelEnv` when ``num_envs > 1`` (or in one process with :class:`~torchrl.envs.SerialEnv` when ``parallel=False``). ``"mujoco-torch"`` and ``"mjx"`` vectorize the ``num_envs`` simulators inside the engine and need one of the MJX-ready entry points. max_episode_steps (int, optional): truncation horizon. Defaults to ``1000``. \*\*kwargs: forwarded to :class:`~torchrl.envs.MujocoEnv`: ``num_envs``, ``device``, ``seed``, ``frame_skip``, ``reset_noise_scale``, ``dtype``, ``compile_step``, ``from_pixels``, ``render_width``, ``render_height``, ``camera_id`` and so on. ``xml_path`` and ``patch_xml`` are not accepted. Scenes are loaded unpatched, so ``camera_id`` indexes the cameras the scene defines; pass ``camera_id=-1`` for MuJoCo's free camera when it defines none. Examples: Load a quadruped through the package, fetching it on first use, and look at what the env exposes: >>> from torchrl.envs import MenagerieEnv, MenagerieTask >>> env = MenagerieEnv("unitree_go2", download=True, seed=0) # doctest: +SKIP >>> td = env.reset() # doctest: +SKIP >>> td["qpos"].shape, env.action_spec.shape # doctest: +SKIP (torch.Size([1, 19]), torch.Size([1, 12])) >>> rollout = env.rollout(20) # doctest: +SKIP The same robot from a local checkout, batched over worker processes: >>> env = MenagerieEnv( # doctest: +SKIP ... "unitree_go2", ... menagerie_path="~/mujoco_menagerie", ... num_envs=8, ... parallel=True, ... ) Its MJX scene on the vectorized torch engine, with the sensors it defines in the observation: >>> env = MenagerieEnv( # doctest: +SKIP ... "unitree_go2", ... entry="scene_mjx", ... backend="mujoco-torch", ... num_envs=1024, ... device="cuda", ... compile_step=True, ... ) >>> env.reset()["sensordata"].shape # doctest: +SKIP torch.Size([1024, 43]) A hold-pose task that ends the episode when the base falls: >>> env = MenagerieEnv( # doctest: +SKIP ... "unitree_go2", ... download=True, ... task=MenagerieEnv.hold_pose_task( ... control_cost_weight=0.05, terminate_below_height=0.15, alive_bonus=0.5 ... ), ... ) A task of your own: expose a site and write the reward from a transform, here the distance from a UR5e's flange to a target: >>> import torch >>> from torchrl.envs import Transform, TransformedEnv >>> class ReachReward(Transform): ... def __init__(self, target): ... super().__init__() ... self.target = target ... def _step(self, tensordict, next_tensordict): ... flange = next_tensordict["site_positions"][..., 0, :] ... distance = (flange - self.target).norm(dim=-1, keepdim=True) ... next_tensordict["reward"] = -distance ... return next_tensordict >>> env = TransformedEnv( # doctest: +SKIP ... MenagerieEnv( ... "universal_robots_ur5e", ... download=True, ... task=MenagerieTask(site_names=("attachment_site",)), ... ), ... ReachReward(torch.tensor([0.4, 0.2, 0.5])), ... ) >>> env.rollout(10)["next", "reward"].shape # doctest: +SKIP torch.Size([1, 10, 1]) Pixels from the free camera of a scene that defines none: >>> env = MenagerieEnv( # doctest: +SKIP ... "franka_emika_panda", download=True, from_pixels=True, camera_id=-1 ... ) """ DEFAULT_BACKEND: ClassVar[BackendName] = "mujoco" def __init__( self, robot: str, *, entry: str | None = None, menagerie_path: str | Path | None = None, download: bool = False, task: MenagerieTask | None = None, backend: BackendName = "mujoco", max_episode_steps: int = 1000, **kwargs: Any, ): for forbidden in ("xml_path", "patch_xml"): if forbidden in kwargs: raise ValueError( "MenagerieEnv loads the Menagerie model itself; pass " f"robot=... and menagerie_path=... instead of {forbidden}=..." ) self.robot = str(robot) self.entry = entry self.task = MenagerieTask() if task is None else task self.model_path = self.resolve_model( robot, entry=entry, menagerie_path=menagerie_path, download=download ) super().__init__( xml_path=self.model_path, patch_xml=False, backend=backend, max_episode_steps=max_episode_steps, **kwargs, ) # ------------------------------------------------------------------ # Model resolution and task presets # ------------------------------------------------------------------
[docs] @classmethod def resolve_model( cls, robot: str, *, entry: str | None = None, menagerie_path: str | Path | None = None, download: bool = False, ) -> Path: """Locate the XML of one Menagerie robot. Args: robot (str): the Menagerie model directory, for example ``"unitree_go2"``. Keyword Args: entry (str, optional): the top-level XML to load, by file stem. ``None`` (default) means ``scene`` from a checkout and the registry's default scene from the package. menagerie_path (str or Path, optional): a ``mujoco_menagerie`` checkout, the robot's directory inside one, or the XML itself. Defaults to the :data:`MENAGERIE_ENV_VAR` environment variable, then to the ``mujoco-menagerie`` package cache. download (bool, optional): whether the ``mujoco-menagerie`` package may download the robot into its cache. Defaults to ``False``. Returns: The absolute path to the XML. Raises: FileNotFoundError: if the robot cannot be located without downloading, or the checkout lacks the robot or the entry. The package raises its own errors for a name or an entry that is not in its registry. """ candidate = menagerie_path if candidate is None: candidate = os.environ.get(MENAGERIE_ENV_VAR) or None if candidate is not None: return cls._resolve_in_checkout(robot, entry, Path(candidate).expanduser()) if not _has_mujoco_menagerie: raise FileNotFoundError( f"MenagerieEnv could not locate {robot!r}. Pass " "menagerie_path=<mujoco_menagerie checkout>, set the " f"{MENAGERIE_ENV_VAR} environment variable, or `pip install " "mujoco-menagerie` and pass download=True." ) import mujoco_menagerie spec = mujoco_menagerie.get(robot) cache = mujoco_menagerie.Cache() if cache.root is None and not download and not cache.is_cached(spec): raise FileNotFoundError( f"{robot!r} is not in the mujoco-menagerie cache at {cache.dir}. " "Pass download=True to fetch it, or point menagerie_path or " f"{MENAGERIE_ENV_VAR} at a mujoco_menagerie checkout." ) entry_point = spec.entry(entry) try: robot_dir = spec.path(cache) except mujoco_menagerie.MenagerieError as err: raise FileNotFoundError( f"{robot!r} could not be fetched from the mujoco-menagerie " f"package: {err}. Point menagerie_path or {MENAGERIE_ENV_VAR} at " "a mujoco_menagerie checkout instead." ) from err return (Path(robot_dir) / entry_point.file).resolve()
@staticmethod def _resolve_in_checkout(robot: str, entry: str | None, path: Path) -> Path: if path.is_file(): if entry is not None and path.stem != entry: raise ValueError( f"menagerie_path points at {path.name} but entry={entry!r} " "was requested; pass the robot directory or drop entry." ) return path.resolve() if (path / robot).is_dir(): robot_dir = path / robot elif path.is_dir() and path.name == robot: robot_dir = path else: raise FileNotFoundError( f"MenagerieEnv: no {robot!r} model directory under {path}." ) xml = robot_dir / f"{'scene' if entry is None else entry}.xml" if not xml.is_file(): entries = sorted(candidate.stem for candidate in robot_dir.glob("*.xml")) raise FileNotFoundError( f"MenagerieEnv: {robot!r} has no entry {xml.stem!r} under " f"{robot_dir}; available entries: {entries}." ) return xml.resolve()
[docs] @classmethod def hold_pose_task( cls, *, pose_weight: float = 1.0, pose_std: float = 0.5, control_cost_weight: float = 0.01, **overrides: Any, ) -> MenagerieTask: r"""Hold the reset keyframe pose under a control cost. The usual first task for a new robot: the reward is the pose term times ``pose_weight`` plus the control cost times ``control_cost_weight``. Keyword Args: pose_weight (float, optional): weight of the pose term. Defaults to ``1.0``. pose_std (float, optional): scale of the pose term. Defaults to ``0.5``. control_cost_weight (float, optional): weight of the control cost. Defaults to ``0.01``. \*\*overrides: the other :class:`MenagerieTask` fields, typically ``terminate_below_height`` and ``alive_bonus`` for a floating-base robot. """ return MenagerieTask( pose_weight=pose_weight, pose_std=pose_std, control_cost_weight=control_cost_weight, **overrides, )
# ------------------------------------------------------------------ # Specs and observations # ------------------------------------------------------------------ def _make_specs(self) -> None: self._configure_from_model() super()._make_specs() def _configure_from_model(self) -> None: import mujoco model = self._backend.mj_model task = self.task qpos0 = self._backend.qpos0 qvel0 = self._backend.qvel0 key_id = self._keyframe_id(model, task.keyframe) if key_id is None: self._reset_qpos = qpos0.clone() self._reset_qvel = torch.zeros_like(qvel0) else: self._reset_qpos = torch.as_tensor( model.key_qpos[key_id], dtype=qpos0.dtype, device=qpos0.device ) self._reset_qvel = torch.as_tensor( model.key_qvel[key_id], dtype=qvel0.dtype, device=qvel0.device ) joint_qpos_index = [] free_joint_qpos_adr = None for joint_id in range(model.njnt): joint_type = model.jnt_type[joint_id] qpos_adr = int(model.jnt_qposadr[joint_id]) if joint_type in (mujoco.mjtJoint.mjJNT_HINGE, mujoco.mjtJoint.mjJNT_SLIDE): joint_qpos_index.append(qpos_adr) elif ( joint_type == mujoco.mjtJoint.mjJNT_FREE and free_joint_qpos_adr is None ): free_joint_qpos_adr = qpos_adr self._joint_qpos_index = torch.tensor( joint_qpos_index, dtype=torch.long, device=self.device ) self._free_joint_qpos_adr = free_joint_qpos_adr if task.terminate_below_height is not None and free_joint_qpos_adr is None: raise ValueError( "terminate_below_height needs a floating base, but the " f"{self.robot!r} model has no free joint." ) self._site_ids = self._mujoco_ids("site", task.site_names) self._nsensordata = int(model.nsensordata) def _keyframe_id(self, model: Any, keyframe: str | None) -> int | None: import mujoco name = "home" if keyframe is None else keyframe key_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_KEY, name) if key_id >= 0: return int(key_id) if keyframe is None: return None keyframes = [model.key(index).name for index in range(model.nkey)] raise KeyError( f"The {self.robot!r} model has no keyframe {keyframe!r}; it defines " f"{keyframes}. Pass keyframe=None to reset to qpos0." ) @property def reset_state(self) -> TensorDict: """The ``qpos`` and ``qvel`` the reset draws around, without a batch dimension. The task keyframe when the model defines it, otherwise ``qpos0`` at rest. Both entries are cast to the env's ``dtype``. """ return TensorDict( qpos=self._reset_qpos.to(self.dtype), qvel=self._reset_qvel.to(self.dtype), device=self.device, ) def _sample_initial_state( self, n: int, tensordict: TensorDictBase | None = None, ) -> tuple[torch.Tensor, torch.Tensor]: qpos, qvel = super()._sample_initial_state(n, tensordict) backend = self._backend return ( qpos + (self._reset_qpos - backend.qpos0).to(qpos), qvel + (self._reset_qvel - backend.qvel0).to(qvel), ) def _make_obs_spec(self) -> Composite: backend = self._backend spec = Composite( qpos=Unbounded( shape=(self.num_envs, backend.nq), dtype=self.dtype, device=self.device ), qvel=Unbounded( shape=(self.num_envs, backend.nv), dtype=self.dtype, device=self.device ), shape=(self.num_envs,), device=self.device, ) if self._nsensordata: spec["sensordata"] = Unbounded( shape=(self.num_envs, self._nsensordata), dtype=self.dtype, device=self.device, ) if self._site_ids: spec["site_positions"] = Unbounded( shape=(self.num_envs, len(self._site_ids), 3), dtype=self.dtype, device=self.device, ) return spec def _build_obs_dict(self, state: TensorDictBase) -> dict[str, torch.Tensor]: out = {} if self.pixels_only else self._make_obs_split(state) if self.from_pixels: out["pixels"] = self._render_pixels() return out def _make_obs_split(self, state: TensorDictBase) -> dict[str, torch.Tensor]: out = { "qpos": state["qpos"].to(self.dtype).clone(), "qvel": state["qvel"].to(self.dtype).clone(), } if self._nsensordata: out["sensordata"] = self._backend.sensordata.to(self.dtype).clone() if self._site_ids: out["site_positions"] = ( self._backend.site_positions(self._site_ids).to(self.dtype).clone() ) return out # ------------------------------------------------------------------ # Reward and termination # ------------------------------------------------------------------ def _fallen(self, qpos: torch.Tensor) -> torch.Tensor: height_limit = self.task.terminate_below_height if height_limit is None: return torch.zeros(self.num_envs, 1, dtype=torch.bool, device=qpos.device) adr = self._free_joint_qpos_adr return qpos[:, adr + 2 : adr + 3] < height_limit def _compute_reward( self, state: TensorDictBase, action: torch.Tensor, next_state: TensorDictBase, ) -> torch.Tensor: del state task = self.task qpos = next_state["qpos"].to(self.dtype) reward = torch.zeros(self.num_envs, 1, dtype=self.dtype, device=qpos.device) if task.pose_weight and self._joint_qpos_index.numel(): target = self._reset_qpos.to(qpos)[self._joint_qpos_index] error = ( (qpos[:, self._joint_qpos_index] - target) .square() .mean(dim=-1, keepdim=True) ) reward = reward + task.pose_weight * torch.exp(-error / task.pose_std**2) if task.control_cost_weight: low = self.action_spec.low high = self.action_spec.high normalized = (action - (high + low) / 2) / ((high - low) / 2) reward = reward - task.control_cost_weight * normalized.square().mean( dim=-1, keepdim=True ) if task.alive_bonus: reward = reward + task.alive_bonus * (~self._fallen(qpos)).to(self.dtype) return reward def _compute_done( self, state: TensorDictBase, next_state: TensorDictBase, ) -> torch.Tensor: del state qpos = next_state["qpos"] qvel = next_state["qvel"] finite = torch.isfinite(qpos).all(dim=-1, keepdim=True) & torch.isfinite( qvel ).all(dim=-1, keepdim=True) return ~finite | self._fallen(qpos.to(self.dtype))