Source code for wbc_mjlab.env.mdp.commands

from __future__ import annotations

import copy
import math
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Literal

import mujoco
import numpy as np
import torch
from mjlab.managers import CommandTerm
from mjlab.tasks.tracking.mdp import MotionCommandCfg as MjlabMotionCommandCfg
from mjlab.utils.lab_api.math import (
  matrix_from_quat,
  quat_apply,
  quat_apply_inverse,
  quat_error_magnitude,
  quat_from_euler_xyz,
  quat_inv,
  quat_mul,
  sample_uniform,
  yaw_quat,
)
from mjlab.viewer.debug_visualizer import DebugVisualizer

from wbc_mjlab.env.mdp.sampling import (
  RsiCfg,
  TrackingSimilarityState,
  bin_index_for_frame,
  build_bin_valid_mask,
  compile_similarity_terms,
  compute_assist_gain_matrix,
  compute_sampling_prob_matrix,
  resolve_tracking_reward_indices,
  rsi_failure_signal_label,
  sample_adaptive_bins,
  trajectory_conditional_prob_row,
  step_tracking_reward_similarity,
  step_tracking_similarity,
  update_failure_ema,
)
from wbc_mjlab.viewer.motion_vis import (
  clip_name_for_trajectory,
  error_to_rgba,
  format_motion_context_html,
  format_rsi_panel_html,
)

if TYPE_CHECKING:
  from collections.abc import Callable
  from typing import Any

  import viser
  from mjlab.entity import Entity
  from mjlab.envs import ManagerBasedRlEnv

_DESIRED_FRAME_COLORS = ((1.0, 0.5, 0.5), (0.5, 1.0, 0.5), (0.5, 0.5, 1.0))


[docs] class MotionLoader: """Load a motion library from a bundled NPZ or a dataset directory (``npz/*.npz``).""" def __init__( self, motion_file: str, body_indexes: torch.Tensor, anchor_body_index: int, step_dt: float, device: str = "cpu", ) -> None: from wbc_mjlab.motion.stack_bundle import ( list_clip_npz_files, stack_clip_arrays_from_paths, ) body_idx = body_indexes.detach().cpu().numpy() source = Path(motion_file).expanduser().resolve() self.motion_source = str(source) if source.is_dir(): clip_paths = list_clip_npz_files(source) if not clip_paths: raise FileNotFoundError( f"No clip NPZs in {source / 'npz'}; convert clips or pass a .npz bundle." ) stacked = stack_clip_arrays_from_paths(clip_paths) seg_start = stacked.segment_start_idx seg_len = stacked.segment_length self._load_arrays( joint_pos=stacked.joint_pos, joint_vel=stacked.joint_vel, body_pos_w=stacked.body_pos_w, body_quat_w=stacked.body_quat_w, body_lin_vel_w=stacked.body_lin_vel_w, body_ang_vel_w=stacked.body_ang_vel_w, seg_start=seg_start, seg_len=seg_len, body_idx=body_idx, anchor_body_index=anchor_body_index, step_dt=step_dt, device=device, segment_names=stacked.clip_names, ) return if not source.is_file(): raise FileNotFoundError(f"Motion source not found: {source}") with np.load(source, allow_pickle=True) as data: if "segment_start_idx" in data and "segment_length" in data: seg_start = np.asarray(data["segment_start_idx"], dtype=np.int64) seg_len = np.asarray(data["segment_length"], dtype=np.int64) else: total = int(data["joint_pos"].shape[0]) seg_start = np.asarray([0], dtype=np.int64) seg_len = np.asarray([total], dtype=np.int64) if "segment_names" in data: segment_names = tuple(str(x) for x in data["segment_names"].tolist()) elif "segment_source" in data: from wbc_mjlab.motion.manifest import clip_name_from_path segment_names = tuple( clip_name_from_path(str(x)) for x in data["segment_source"].tolist() ) else: segment_names = (source.stem,) self._load_arrays( joint_pos=np.asarray(data["joint_pos"]), joint_vel=np.asarray(data["joint_vel"]), body_pos_w=np.asarray(data["body_pos_w"]), body_quat_w=np.asarray(data["body_quat_w"]), body_lin_vel_w=np.asarray(data["body_lin_vel_w"]), body_ang_vel_w=np.asarray(data["body_ang_vel_w"]), seg_start=seg_start, seg_len=seg_len, body_idx=body_idx, anchor_body_index=anchor_body_index, step_dt=step_dt, device=device, segment_names=segment_names, ) def _load_arrays( self, *, joint_pos: np.ndarray, joint_vel: np.ndarray, body_pos_w: np.ndarray, body_quat_w: np.ndarray, body_lin_vel_w: np.ndarray, body_ang_vel_w: np.ndarray, seg_start: np.ndarray, seg_len: np.ndarray, body_idx: np.ndarray, anchor_body_index: int, step_dt: float, device: str, segment_names: tuple[str, ...] = (), ) -> None: self.joint_pos = torch.as_tensor(joint_pos, dtype=torch.float32, device=device) self.joint_vel = torch.as_tensor(joint_vel, dtype=torch.float32, device=device) self.body_pos_w = torch.as_tensor( np.asarray(body_pos_w[:, body_idx], dtype=np.float32), device=device ) self.body_quat_w = torch.as_tensor( np.asarray(body_quat_w[:, body_idx], dtype=np.float32), device=device ) self.body_lin_vel_w = torch.as_tensor( np.asarray(body_lin_vel_w[:, body_idx], dtype=np.float32), device=device ) self.body_ang_vel_w = torch.as_tensor( np.asarray(body_ang_vel_w[:, body_idx], dtype=np.float32), device=device ) self.time_step_total = int(self.joint_pos.shape[0]) self.segment_start_idx = torch.tensor(seg_start, dtype=torch.long, device=device) self.segment_length = torch.tensor(seg_len, dtype=torch.long, device=device) self.num_trajectories = int(self.segment_start_idx.shape[0]) self.segment_end_idx = self.segment_start_idx + self.segment_length if len(segment_names) < self.num_trajectories: segment_names = segment_names + tuple( f"traj_{i}" for i in range(len(segment_names), self.num_trajectories) ) self.segment_names = segment_names anchor_lin_vel = self.body_lin_vel_w[:, anchor_body_index] anchor_ang_vel = self.body_ang_vel_w[:, anchor_body_index] self.anchor_lin_acc_w = torch.gradient(anchor_lin_vel, spacing=step_dt, dim=0)[0] self.anchor_ang_acc_w = torch.gradient(anchor_ang_vel, spacing=step_dt, dim=0)[0]
[docs] class MotionCommand(CommandTerm): """Multi-clip motion playback with RSI resampling and assistive-wrench state. Loads an NPZ motion library, streams reference kinematics each step, and resamples start frames at episode boundaries according to :class:`RsiCfg`. Observation and reward terms read reference state from this command via ``command_name="motion"``. """ cfg: MotionCommandCfg _env: ManagerBasedRlEnv def __init__(self, cfg: MotionCommandCfg, env: ManagerBasedRlEnv): super().__init__(cfg, env) self.robot: Entity = env.scene[cfg.entity_name] if cfg.actuated_joint_names: tracked_joint_ids, _ = self.robot.find_joints( cfg.actuated_joint_names, preserve_order=True ) self._tracked_joint_ids = torch.tensor( tracked_joint_ids, dtype=torch.long, device=self.device ) else: self._tracked_joint_ids = None self.robot_anchor_body_index = self.robot.body_names.index( self.cfg.anchor_body_name ) self.motion_anchor_body_index = self.cfg.body_names.index(self.cfg.anchor_body_name) self.body_indexes = torch.tensor( self.robot.find_bodies(self.cfg.body_names, preserve_order=True)[0], dtype=torch.long, device=self.device, ) self.motion = MotionLoader( self.cfg.motion_file, self.body_indexes, anchor_body_index=self.motion_anchor_body_index, step_dt=env.step_dt, device=self.device, ) self.time_steps = torch.zeros(self.num_envs, dtype=torch.long, device=self.device) self.trajectory_ids = torch.zeros(self.num_envs, dtype=torch.long, device=self.device) self.body_pos_relative_w = torch.zeros( self.num_envs, len(cfg.body_names), 3, device=self.device ) self.body_quat_relative_w = torch.zeros( self.num_envs, len(cfg.body_names), 4, device=self.device ) self.body_quat_relative_w[:, :, 0] = 1.0 self.bin_width_frames = max( 1, int(math.ceil(cfg.rsi.bin_width_s / max(env.step_dt, 1.0e-6))) ) segment_bins = torch.ceil( self.motion.segment_length.float() / float(self.bin_width_frames) ).long() self.bins_per_trajectory = max(1, int(segment_bins.max().item())) self.bin_valid_mask = build_bin_valid_mask( self.motion.segment_length, bins_per_trajectory=self.bins_per_trajectory, bin_width_frames=self.bin_width_frames, min_bin_span_ratio=cfg.rsi.min_bin_span_ratio, device=self.device, ) self._valid_bin_indices = self.bin_valid_mask.nonzero(as_tuple=False) if self._valid_bin_indices.numel() == 0: raise ValueError( "No valid RSI bins after applying min_bin_span_ratio; " "lower min_bin_span_ratio or use shorter bin_width_s." ) self.bin_failure_levels = torch.zeros( self.motion.num_trajectories, self.bins_per_trajectory, dtype=torch.float, device=self.device, ) self.bin_visit_counts = torch.zeros( self.motion.num_trajectories, self.bins_per_trajectory, dtype=torch.float, device=self.device, ) self._episode_similarity_sum = torch.zeros(self.num_envs, device=self.device) self._episode_similarity_denom = torch.ones(self.num_envs, device=self.device) self._episode_step_count = torch.zeros( self.num_envs, dtype=torch.long, device=self.device ) self._episode_start_step = torch.zeros( self.num_envs, dtype=torch.long, device=self.device ) self._episode_start_bin = torch.zeros( self.num_envs, dtype=torch.long, device=self.device ) self.episode_assist_gain = torch.zeros(self.num_envs, device=self.device) self.assist_force_w = torch.zeros(self.num_envs, 3, device=self.device) self.assist_torque_w = torch.zeros(self.num_envs, 3, device=self.device) self.metrics["error_anchor_pos"] = torch.zeros(self.num_envs, device=self.device) self.metrics["error_anchor_rot"] = torch.zeros(self.num_envs, device=self.device) self.metrics["error_anchor_lin_vel"] = torch.zeros( self.num_envs, device=self.device ) self.metrics["error_anchor_ang_vel"] = torch.zeros( self.num_envs, device=self.device ) self.metrics["error_body_pos"] = torch.zeros(self.num_envs, device=self.device) self.metrics["error_body_rot"] = torch.zeros(self.num_envs, device=self.device) self.metrics["error_joint_pos"] = torch.zeros(self.num_envs, device=self.device) self.metrics["error_joint_vel"] = torch.zeros(self.num_envs, device=self.device) self.metrics["sampling_entropy"] = torch.zeros(self.num_envs, device=self.device) self.metrics["sampling_top1_prob"] = torch.zeros(self.num_envs, device=self.device) self.metrics["sampling_top1_bin"] = torch.zeros(self.num_envs, device=self.device) self.metrics["assist_gain_mean"] = torch.zeros(self.num_envs, device=self.device) self._similarity_terms, similarity_weight_sum = compile_similarity_terms( cfg.rsi.similarity_terms ) self._similarity_term_weight_sum = max(similarity_weight_sum, 1.0e-6) if ( cfg.rsi.strategy == "similarity_ema" and not cfg.rsi.similarity_from_rewards and similarity_weight_sum <= 0.0 ): raise ValueError( "similarity_ema requires at least one rsi.similarity_terms entry with weight > 0 " "when similarity_from_rewards=False." ) self._tracking_reward_indices: list[int] | None = None self._tracking_reward_weight_sum = 0.0 self._ghost_model: mujoco.MjModel | None = None self._ghost_color = np.array(cfg.viz.ghost_color, dtype=np.float32) self._viewer_task_id: str | None = None self._viewer_align_xy_yaw = False self._viewer_color_bodies = False self._viewer_context_html = None self._viewer_rsi_html = None @property def bin_count(self) -> int: return self.motion.num_trajectories * self.bins_per_trajectory def _set_episode_similarity_denom( self, env_ids: torch.Tensor, traj_ids: torch.Tensor, time_steps: torch.Tensor ) -> None: if not self.cfg.rsi.similarity_norm_by_remaining_clip: return seg_start = self.motion.segment_start_idx[traj_ids] seg_len = self.motion.segment_length[traj_ids] local_step = torch.clamp(time_steps - seg_start, min=0) remaining = seg_len - local_step max_episode = float(self._env.max_episode_length) norm = torch.minimum( remaining.float(), torch.full_like(remaining.float(), max_episode), ) self._episode_similarity_denom[env_ids] = torch.clamp(norm, min=1.0) def _update_failure_levels(self, env_ids: torch.Tensor) -> None: if self.cfg.rsi.sampling_mode != "adaptive" or env_ids.numel() == 0: return active_mask = self._episode_step_count[env_ids] > 0 if not torch.any(active_mask): return env_ids = env_ids[active_mask] rsi = self.cfg.rsi strategy = rsi.strategy update_failure_ema( self.bin_failure_levels, strategy=strategy, bins_per_trajectory=self.bins_per_trajectory, alpha=rsi.alpha, traj_ids=self.trajectory_ids[env_ids], start_bins=self._episode_start_bin[env_ids], episode_terminated=( self._env.termination_manager.terminated[env_ids] if strategy == "binary_failure" else None ), episode_similarity_sum=( self._episode_similarity_sum[env_ids] if strategy == "similarity_ema" else None ), episode_step_count=( self._episode_step_count[env_ids] if strategy == "similarity_ema" else None ), similarity_denom=( self._episode_similarity_denom[env_ids] if rsi.similarity_norm_by_remaining_clip and strategy == "similarity_ema" else None ), norm_by_remaining_clip=( rsi.similarity_norm_by_remaining_clip and strategy == "similarity_ema" ), ) def _set_episode_assist_gain( self, env_ids: torch.Tensor, traj_ids: torch.Tensor, bins: torch.Tensor ) -> None: if not self.cfg.assistive_wrench_enabled: self.episode_assist_gain[env_ids] = 0.0 return failure = self.bin_failure_levels[traj_ids, bins] similarity = 1.0 - failure eta = max(self.cfg.assistive_eta, 1.0e-6) self.episode_assist_gain[env_ids] = torch.clamp( 1.0 - similarity / eta, 0.0, self.cfg.assistive_beta_max ) def _adaptive_sampling(self, env_ids: torch.Tensor): self._update_failure_levels(env_ids) rsi = self.cfg.rsi traj_ids, bins, time_steps, probs_valid = sample_adaptive_bins( self.bin_failure_levels, self._valid_bin_indices, segment_length=self.motion.segment_length, segment_start_idx=self.motion.segment_start_idx, bin_width_frames=self.bin_width_frames, temperature_base=rsi.temperature_base, uniform_ratio=rsi.uniform_ratio, num_samples=len(env_ids), device=self.device, ) self.trajectory_ids[env_ids] = traj_ids self.time_steps[env_ids] = time_steps self._episode_start_bin[env_ids] = bins self._set_episode_similarity_denom(env_ids, traj_ids, time_steps) self._set_episode_assist_gain(env_ids, traj_ids, bins) self._record_bin_visits(traj_ids, bins) num_valid = max(1, probs_valid.shape[0]) H = -(probs_valid * (probs_valid + 1e-12).log()).sum() self.metrics["sampling_entropy"][:] = ( float(H / math.log(num_valid)) if num_valid > 1 else 1.0 ) pmax, imax = probs_valid.max(dim=0) self.metrics["sampling_top1_prob"][:] = float(pmax) self.metrics["sampling_top1_bin"][:] = float(imax) / float(num_valid) def _uniform_sampling(self, env_ids: torch.Tensor): traj_ids = torch.randint( 0, self.motion.num_trajectories, (len(env_ids),), device=self.device ) seg_lengths = self.motion.segment_length[traj_ids] local_frames = ( sample_uniform(0.0, 1.0, (len(env_ids),), device=self.device) * (seg_lengths.float() - 1.0) ).long() self.trajectory_ids[env_ids] = traj_ids self.time_steps[env_ids] = self.motion.segment_start_idx[traj_ids] + local_frames start_bins = self._bin_index_for_frame(traj_ids, self.time_steps[env_ids]) self._episode_start_bin[env_ids] = start_bins self._set_episode_similarity_denom(env_ids, traj_ids, self.time_steps[env_ids]) self._set_episode_assist_gain(env_ids, traj_ids, start_bins) self._record_bin_visits(traj_ids, start_bins) num_valid = max(1, int(self.bin_valid_mask.sum().item())) self.metrics["sampling_entropy"][:] = 1.0 self.metrics["sampling_top1_prob"][:] = 1.0 / num_valid self.metrics["sampling_top1_bin"][:] = 0.5 def _tracking_similarity_state(self) -> TrackingSimilarityState: return TrackingSimilarityState( tracked_joint_pos_error=self._tracked_joint_pos_error(), anchor_pos_error=self.anchor_pos_w - self.robot_anchor_pos_w, anchor_ori_error=quat_error_magnitude( self.anchor_quat_w, self.robot_anchor_quat_w ), body_pos_error=torch.sum( torch.square(self.body_pos_relative_w - self.robot_body_pos_w), dim=-1 ), body_ori_error=( quat_error_magnitude(self.body_quat_relative_w, self.robot_body_quat_w) ** 2 ), body_lin_vel_error=torch.sum( torch.square(self.body_lin_vel_w - self.robot_body_lin_vel_w), dim=-1 ), body_ang_vel_error=torch.sum( torch.square(self.body_ang_vel_w - self.robot_body_ang_vel_w), dim=-1 ), ) def _ensure_tracking_reward_indices(self) -> None: if self._tracking_reward_indices is not None: return rsi = self.cfg.rsi self._tracking_reward_indices, self._tracking_reward_weight_sum = ( resolve_tracking_reward_indices( self._env.reward_manager, name_prefix=rsi.tracking_reward_prefix, ) ) if not self._tracking_reward_indices: raise ValueError( "similarity_from_rewards requires at least one reward term " f"matching prefix {rsi.tracking_reward_prefix!r} with weight > 0." ) def _step_tracking_similarity(self) -> torch.Tensor: if self.cfg.rsi.similarity_from_rewards: self._ensure_tracking_reward_indices() return step_tracking_reward_similarity( self._env.reward_manager._step_reward, self._tracking_reward_indices, self._tracking_reward_weight_sum, ) return step_tracking_similarity( self._similarity_terms, self._similarity_term_weight_sum, self._tracking_similarity_state(), num_envs=self.num_envs, device=self.device, ) def _bin_index_for_frame( self, trajectory_ids: torch.Tensor, time_steps: torch.Tensor ): return bin_index_for_frame( segment_start_idx=self.motion.segment_start_idx, time_steps=time_steps, trajectory_ids=trajectory_ids, bin_width_frames=self.bin_width_frames, bins_per_trajectory=self.bins_per_trajectory, ) # --- WBC reference features (Table S3); stacked in ``command`` for the actor. --- # SE layouts use ``ref_anchor_pos_w`` / ``ref_anchor_ori_6d`` via ``presets/se_actor.py`` instead. @property def ref_base_height(self) -> torch.Tensor: """Anchor height relative to env origin (z_I r̂_IB).""" return self.anchor_pos_w[:, 2:3] - self._env.scene.env_origins[:, 2:3] @property def ref_base_lin_vel_b(self) -> torch.Tensor: """Reference anchor linear velocity in anchor frame (B v̂_IB).""" return quat_apply_inverse(self.anchor_quat_w, self.anchor_lin_vel_w) @property def ref_base_ang_vel_b(self) -> torch.Tensor: """Reference anchor angular velocity in anchor frame (B ω̂_IB).""" return quat_apply_inverse(self.anchor_quat_w, self.anchor_ang_vel_w) @property def ref_gravity_b(self) -> torch.Tensor: """Reference gravity in anchor frame (B ĝ_I).""" return quat_apply_inverse(self.anchor_quat_w, self.robot.data.gravity_vec_w) @property def ref_base_lin_acc_b(self) -> torch.Tensor: return quat_apply_inverse(self.anchor_quat_w, self.anchor_lin_acc_w) @property def ref_base_ang_acc_b(self) -> torch.Tensor: return quat_apply_inverse(self.anchor_quat_w, self.anchor_ang_acc_w) @property def tracked_joint_pos(self) -> torch.Tensor: """Reference joint positions for actuated / tracked DoFs (absolute).""" if self._tracked_joint_ids is not None: return self.joint_pos[:, self._tracked_joint_ids] return self.joint_pos @property def tracked_joint_vel(self) -> torch.Tensor: """Reference joint velocities for actuated / tracked DoFs (absolute).""" if self._tracked_joint_ids is not None: return self.joint_vel[:, self._tracked_joint_ids] return self.joint_vel @property def command(self) -> torch.Tensor: """Legacy stacked WBC reference vector (same layout as default ref obs terms). Prefer configuring individual reference observation terms in ``wbc_env_cfg``. Kept for ONNX metadata, ``wbc_command_dim``, and legacy deploy bundles. """ return torch.cat( [ self.ref_base_height, self.ref_base_lin_vel_b, self.ref_base_ang_vel_b, self.ref_gravity_b, self.tracked_joint_pos, ], dim=-1, ) @property def wbc_command_dim(self) -> int: return int(self.command.shape[-1]) @property def joint_pos(self) -> torch.Tensor: return self.motion.joint_pos[self.time_steps] @property def joint_vel(self) -> torch.Tensor: return self.motion.joint_vel[self.time_steps] def _tracked_joint_pos_error(self) -> torch.Tensor: error = self.joint_pos - self.robot_joint_pos if self._tracked_joint_ids is not None: error = error[:, self._tracked_joint_ids] return error def _tracked_joint_vel_error(self) -> torch.Tensor: error = self.joint_vel - self.robot_joint_vel if self._tracked_joint_ids is not None: error = error[:, self._tracked_joint_ids] return error @property def body_pos_w(self) -> torch.Tensor: return ( self.motion.body_pos_w[self.time_steps] + self._env.scene.env_origins[:, None, :] ) @property def body_quat_w(self) -> torch.Tensor: return self.motion.body_quat_w[self.time_steps] @property def body_lin_vel_w(self) -> torch.Tensor: return self.motion.body_lin_vel_w[self.time_steps] @property def body_ang_vel_w(self) -> torch.Tensor: return self.motion.body_ang_vel_w[self.time_steps] @property def anchor_pos_w(self) -> torch.Tensor: return ( self.motion.body_pos_w[self.time_steps][:, self.motion_anchor_body_index] + self._env.scene.env_origins ) @property def anchor_quat_w(self) -> torch.Tensor: return self.motion.body_quat_w[self.time_steps][:, self.motion_anchor_body_index] @property def anchor_lin_vel_w(self) -> torch.Tensor: return self.motion.body_lin_vel_w[self.time_steps][:, self.motion_anchor_body_index] @property def anchor_ang_vel_w(self) -> torch.Tensor: return self.motion.body_ang_vel_w[self.time_steps][:, self.motion_anchor_body_index] @property def anchor_lin_acc_w(self) -> torch.Tensor: return self.motion.anchor_lin_acc_w[self.time_steps] @property def anchor_ang_acc_w(self) -> torch.Tensor: return self.motion.anchor_ang_acc_w[self.time_steps] @property def robot_joint_pos(self) -> torch.Tensor: return self.robot.data.joint_pos @property def robot_joint_vel(self) -> torch.Tensor: return self.robot.data.joint_vel @property def robot_body_pos_w(self) -> torch.Tensor: return self.robot.data.body_link_pos_w[:, self.body_indexes] @property def robot_body_quat_w(self) -> torch.Tensor: return self.robot.data.body_link_quat_w[:, self.body_indexes] @property def robot_body_lin_vel_w(self) -> torch.Tensor: return self.robot.data.body_link_lin_vel_w[:, self.body_indexes] @property def robot_body_ang_vel_w(self) -> torch.Tensor: return self.robot.data.body_link_ang_vel_w[:, self.body_indexes] @property def robot_anchor_pos_w(self) -> torch.Tensor: return self.robot.data.body_link_pos_w[:, self.robot_anchor_body_index] @property def robot_anchor_quat_w(self) -> torch.Tensor: return self.robot.data.body_link_quat_w[:, self.robot_anchor_body_index] @property def robot_anchor_lin_vel_w(self) -> torch.Tensor: return self.robot.data.body_link_lin_vel_w[:, self.robot_anchor_body_index] @property def robot_anchor_ang_vel_w(self) -> torch.Tensor: return self.robot.data.body_link_ang_vel_w[:, self.robot_anchor_body_index] def _update_metrics(self): self.metrics["error_anchor_pos"] = torch.norm( self.anchor_pos_w - self.robot_anchor_pos_w, dim=-1 ) self.metrics["error_anchor_rot"] = quat_error_magnitude( self.anchor_quat_w, self.robot_anchor_quat_w ) self.metrics["error_anchor_lin_vel"] = torch.norm( self.anchor_lin_vel_w - self.robot_anchor_lin_vel_w, dim=-1 ) self.metrics["error_anchor_ang_vel"] = torch.norm( self.anchor_ang_vel_w - self.robot_anchor_ang_vel_w, dim=-1 ) self.metrics["error_body_pos"] = torch.norm( self.body_pos_relative_w - self.robot_body_pos_w, dim=-1 ).mean(dim=-1) self.metrics["error_body_rot"] = quat_error_magnitude( self.body_quat_relative_w, self.robot_body_quat_w ).mean(dim=-1) self.metrics["error_body_lin_vel"] = torch.norm( self.body_lin_vel_w - self.robot_body_lin_vel_w, dim=-1 ).mean(dim=-1) self.metrics["error_body_ang_vel"] = torch.norm( self.body_ang_vel_w - self.robot_body_ang_vel_w, dim=-1 ).mean(dim=-1) self.metrics["error_joint_pos"] = torch.norm( self._tracked_joint_pos_error(), dim=-1 ) self.metrics["error_joint_vel"] = torch.norm( self._tracked_joint_vel_error(), dim=-1 ) self.metrics["assist_gain_mean"] = self.episode_assist_gain def _write_reference_state_to_sim( self, env_ids: torch.Tensor, root_pos: torch.Tensor, root_ori: torch.Tensor, root_lin_vel: torch.Tensor, root_ang_vel: torch.Tensor, joint_pos: torch.Tensor, joint_vel: torch.Tensor, ) -> None: soft_limits = self.robot.data.soft_joint_pos_limits[env_ids] joint_pos = torch.clip(joint_pos, soft_limits[:, :, 0], soft_limits[:, :, 1]) self.robot.write_joint_state_to_sim(joint_pos, joint_vel, env_ids=env_ids) root_state = torch.cat([root_pos, root_ori, root_lin_vel, root_ang_vel], dim=-1) self.robot.write_root_state_to_sim(root_state, env_ids=env_ids) self.robot.reset(env_ids=env_ids) def _resample_command(self, env_ids: torch.Tensor): rsi = self.cfg.rsi if rsi.sampling_mode == "start": self.trajectory_ids[env_ids] = 0 self.time_steps[env_ids] = self.motion.segment_start_idx[0] self._episode_start_bin[env_ids] = 0 start_traj = self.trajectory_ids[env_ids] start_bins = self._episode_start_bin[env_ids] self._set_episode_similarity_denom(env_ids, start_traj, self.time_steps[env_ids]) self._set_episode_assist_gain(env_ids, start_traj, start_bins) self._record_bin_visits(start_traj, start_bins) elif rsi.sampling_mode == "uniform": self._uniform_sampling(env_ids) else: assert rsi.sampling_mode == "adaptive" self._adaptive_sampling(env_ids) root_pos = self.body_pos_w[env_ids, 0].clone() root_ori = self.body_quat_w[env_ids, 0].clone() root_lin_vel = self.body_lin_vel_w[env_ids, 0].clone() root_ang_vel = self.body_ang_vel_w[env_ids, 0].clone() range_list = [ self.cfg.pose_range.get(key, (0.0, 0.0)) for key in ["x", "y", "z", "roll", "pitch", "yaw"] ] ranges = torch.tensor(range_list, device=self.device) rand_samples = sample_uniform( ranges[:, 0], ranges[:, 1], (len(env_ids), 6), device=self.device ) root_pos += rand_samples[:, 0:3] orientations_delta = quat_from_euler_xyz( rand_samples[:, 3], rand_samples[:, 4], rand_samples[:, 5] ) root_ori = quat_mul(orientations_delta, root_ori) range_list = [ self.cfg.velocity_range.get(key, (0.0, 0.0)) for key in ["x", "y", "z", "roll", "pitch", "yaw"] ] ranges = torch.tensor(range_list, device=self.device) rand_samples = sample_uniform( ranges[:, 0], ranges[:, 1], (len(env_ids), 6), device=self.device ) root_lin_vel += rand_samples[:, :3] root_ang_vel += rand_samples[:, 3:] joint_pos = self.joint_pos[env_ids].clone() joint_vel = self.joint_vel[env_ids] joint_pos += sample_uniform( lower=self.cfg.joint_position_range[0], upper=self.cfg.joint_position_range[1], size=joint_pos.shape, device=joint_pos.device, ) self._write_reference_state_to_sim( env_ids, root_pos, root_ori, root_lin_vel, root_ang_vel, joint_pos, joint_vel, ) self._episode_start_step[env_ids] = self.time_steps[env_ids] self._episode_similarity_sum[env_ids] = 0.0 self._episode_step_count[env_ids] = 0 self.update_relative_body_poses()
[docs] def update_relative_body_poses(self) -> None: anchor_pos_w_repeat = self.anchor_pos_w[:, None, :].repeat( 1, len(self.cfg.body_names), 1 ) anchor_quat_w_repeat = self.anchor_quat_w[:, None, :].repeat( 1, len(self.cfg.body_names), 1 ) robot_anchor_pos_w_repeat = self.robot_anchor_pos_w[:, None, :].repeat( 1, len(self.cfg.body_names), 1 ) robot_anchor_quat_w_repeat = self.robot_anchor_quat_w[:, None, :].repeat( 1, len(self.cfg.body_names), 1 ) delta_pos_w = robot_anchor_pos_w_repeat delta_pos_w[..., 2] = anchor_pos_w_repeat[..., 2] delta_ori_w = yaw_quat( quat_mul(robot_anchor_quat_w_repeat, quat_inv(anchor_quat_w_repeat)) ) self.body_quat_relative_w = quat_mul(delta_ori_w, self.body_quat_w) self.body_pos_relative_w = delta_pos_w + quat_apply( delta_ori_w, self.body_pos_w - anchor_pos_w_repeat )
[docs] def compute(self, dt: float) -> None: self._update_metrics() self.time_left -= dt resample_env_ids = (self.time_left <= 0.0).nonzero().flatten() if len(resample_env_ids) > 0: self._resample(resample_env_ids) self._update_command(advance_time=dt > 0.0)
def _update_command(self, *, advance_time: bool = True): if self.cfg.rsi.strategy == "similarity_ema" and advance_time: self._episode_similarity_sum += self._step_tracking_similarity() self._episode_step_count += 1 if advance_time: self.time_steps += 1 seg_end = self.motion.segment_end_idx[self.trajectory_ids] env_ids = torch.where(self.time_steps >= seg_end)[0] if env_ids.numel() > 0: self._resample_command(env_ids) self.update_relative_body_poses()
[docs] def set_viewer_task_id(self, task_id: str | None) -> None: self._viewer_task_id = task_id
def _record_bin_visits(self, traj_ids: torch.Tensor, bins: torch.Tensor) -> None: for traj_id, bin_id in zip(traj_ids.tolist(), bins.tolist(), strict=True): self.bin_visit_counts[int(traj_id), int(bin_id)] += 1.0 def _rsi_view_tensors( self, ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: rsi = self.cfg.rsi failure = self.bin_failure_levels prob = compute_sampling_prob_matrix( failure, self.bin_valid_mask, temperature_base=rsi.temperature_base, uniform_ratio=rsi.uniform_ratio, ) assist = compute_assist_gain_matrix( failure, eta=self.cfg.assistive_eta, beta_max=self.cfg.assistive_beta_max, enabled=self.cfg.assistive_wrench_enabled, ) return ( failure.detach().cpu(), prob.detach().cpu(), self.bin_visit_counts.detach().cpu(), assist.detach().cpu(), self.bin_valid_mask.detach().cpu(), ) def _clip_name(self, traj_id: int) -> str: return clip_name_for_trajectory(self.motion.segment_names, traj_id) def _segment_phase(self, env_idx: int) -> float: traj_id = int(self.trajectory_ids[env_idx].item()) seg_start = int(self.motion.segment_start_idx[traj_id].item()) seg_len = max(int(self.motion.segment_length[traj_id].item()) - 1, 1) local = int(self.time_steps[env_idx].item()) - seg_start return float(np.clip(local / seg_len, 0.0, 1.0)) def _body_tracking_errors(self, env_idx: int) -> tuple[np.ndarray, np.ndarray]: pos_err = torch.norm( self.body_pos_relative_w[env_idx] - self.robot_body_pos_w[env_idx], dim=-1 ) rot_err = quat_error_magnitude( self.body_quat_relative_w[env_idx], self.robot_body_quat_w[env_idx] ) return pos_err.cpu().numpy(), rot_err.cpu().numpy()
[docs] def update_viewer_gui(self, env_idx: int) -> None: if self._viewer_context_html is None: return traj_id = int(self.trajectory_ids[env_idx].item()) frame = int(self.time_steps[env_idx].item()) self._viewer_context_html.content = format_motion_context_html( env_idx=env_idx, traj_id=traj_id, clip_name=self._clip_name(traj_id), frame=frame, phase=self._segment_phase(env_idx), task_id=self._viewer_task_id, rsi_mode=self.cfg.rsi.sampling_mode, rsi_strategy=self.cfg.rsi.strategy, anchor_body=self.cfg.anchor_body_name, num_bodies=len(self.cfg.body_names), ) if self._viewer_rsi_html is None: return bin_idx = int( self._bin_index_for_frame( self.trajectory_ids[env_idx : env_idx + 1], self.time_steps[env_idx : env_idx + 1], ).item() ) failure, prob, visits, assist, valid = self._rsi_view_tensors() rsi = self.cfg.rsi traj_probs = trajectory_conditional_prob_row(prob, valid, traj_id) self._viewer_rsi_html.content = format_rsi_panel_html( bin_idx=bin_idx, num_bins=self.bins_per_trajectory, bin_width_s=rsi.bin_width_s, failure_levels=failure[traj_id].tolist(), sampling_probs=traj_probs, visit_counts=visits[traj_id].tolist(), assist_gains=assist[traj_id].tolist(), valid_mask=valid[traj_id].tolist(), failure_signal_label=rsi_failure_signal_label( rsi.strategy, similarity_from_rewards=rsi.similarity_from_rewards, ), show_assist=self.cfg.assistive_wrench_enabled, beta_max=self.cfg.assistive_beta_max, )
def _debug_vis_impl(self, visualizer: DebugVisualizer) -> None: env_indices = visualizer.get_env_indices(self.num_envs) if not env_indices: return if self.cfg.viz.mode == "ghost": if self._ghost_model is None: self._ghost_model = copy.deepcopy(self._env.sim.mj_model) self._ghost_model.geom_rgba[:] = self._ghost_color entity: Entity = self._env.scene[self.cfg.entity_name] indexing = entity.indexing free_joint_q_adr = indexing.free_joint_q_adr.cpu().numpy() joint_q_adr = indexing.joint_q_adr.cpu().numpy() for batch in env_indices: if self._viewer_align_xy_yaw: root_pos = self.body_pos_relative_w[batch, 0].cpu().numpy() root_quat = self.body_quat_relative_w[batch, 0].cpu().numpy() else: root_pos = self.body_pos_w[batch, 0].cpu().numpy() root_quat = self.body_quat_w[batch, 0].cpu().numpy() qpos = np.zeros(self._env.sim.mj_model.nq) qpos[free_joint_q_adr[0:3]] = root_pos qpos[free_joint_q_adr[3:7]] = root_quat qpos[joint_q_adr] = self.joint_pos[batch].cpu().numpy() visualizer.add_ghost_mesh(qpos, model=self._ghost_model, label=f"ghost_{batch}") if self._viewer_color_bodies: self._add_body_error_markers(visualizer, batch) elif self.cfg.viz.mode == "frames": for batch in env_indices: if self._viewer_align_xy_yaw: desired_body_pos = self.body_pos_relative_w[batch].cpu().numpy() desired_body_quat = self.body_quat_relative_w[batch] else: desired_body_pos = self.body_pos_w[batch].cpu().numpy() desired_body_quat = self.body_quat_w[batch] desired_body_rotm = matrix_from_quat(desired_body_quat).cpu().numpy() current_body_pos = self.robot_body_pos_w[batch].cpu().numpy() current_body_quat = self.robot_body_quat_w[batch] current_body_rotm = matrix_from_quat(current_body_quat).cpu().numpy() pos_err, rot_err = self._body_tracking_errors(batch) for i, body_name in enumerate(self.cfg.body_names): err = float(pos_err[i] + rot_err[i]) axis_colors = None if self._viewer_color_bodies: rgba = error_to_rgba(err) rgb = (rgba[0], rgba[1], rgba[2]) axis_colors = (rgb, rgb, rgb) visualizer.add_frame( position=desired_body_pos[i], rotation_matrix=desired_body_rotm[i], scale=0.08, label=f"desired_{body_name}_{batch}", axis_colors=axis_colors or _DESIRED_FRAME_COLORS, ) visualizer.add_frame( position=current_body_pos[i], rotation_matrix=current_body_rotm[i], scale=0.12, label=f"current_{body_name}_{batch}", axis_colors=axis_colors, ) if self._viewer_color_bodies: visualizer.add_sphere( center=current_body_pos[i], radius=0.025, color=error_to_rgba(err), label=f"err_{body_name}_{batch}", ) if self._viewer_align_xy_yaw: desired_anchor_pos = self.body_pos_relative_w[batch, self.motion_anchor_body_index] desired_anchor_quat = self.body_quat_relative_w[batch, self.motion_anchor_body_index] else: desired_anchor_pos = self.anchor_pos_w[batch] desired_anchor_quat = self.anchor_quat_w[batch] desired_rotation_matrix = matrix_from_quat(desired_anchor_quat).cpu().numpy() visualizer.add_frame( position=desired_anchor_pos.cpu().numpy(), rotation_matrix=desired_rotation_matrix, scale=0.1, label=f"desired_anchor_{batch}", axis_colors=_DESIRED_FRAME_COLORS, ) current_anchor_pos = self.robot_anchor_pos_w[batch].cpu().numpy() current_anchor_quat = self.robot_anchor_quat_w[batch] current_rotation_matrix = matrix_from_quat(current_anchor_quat).cpu().numpy() visualizer.add_frame( position=current_anchor_pos, rotation_matrix=current_rotation_matrix, scale=0.15, label=f"current_anchor_{batch}", ) def _add_body_error_markers( self, visualizer: DebugVisualizer, env_idx: int ) -> None: pos_err, rot_err = self._body_tracking_errors(env_idx) body_pos = self.robot_body_pos_w[env_idx].cpu().numpy() for i, body_name in enumerate(self.cfg.body_names): err = float(pos_err[i] + rot_err[i]) visualizer.add_sphere( center=body_pos[i], radius=0.03, color=error_to_rgba(err), label=f"err_{body_name}_{env_idx}", )
[docs] def create_gui( self, name: str, server: viser.ViserServer, get_env_idx: Callable[[], int], on_change: Callable[[], None] | None = None, request_action: Callable[[str, Any], None] | None = None, ) -> None: max_frame = int(self.motion.time_step_total) - 1 with server.gui.add_folder(name.capitalize()): with server.gui.add_folder("Selected env", expand_by_default=True): self._viewer_context_html = server.gui.add_html("") with server.gui.add_folder("Adaptive sampling (RSI)", expand_by_default=False): self._viewer_rsi_html = server.gui.add_html("") align_cb = server.gui.add_checkbox( "Align xy/yaw to reference", initial_value=self._viewer_align_xy_yaw, ) color_cb = server.gui.add_checkbox( "Color bodies by tracking error", initial_value=self._viewer_color_bodies, ) @align_cb.on_update def _on_align(_) -> None: self._viewer_align_xy_yaw = bool(align_cb.value) if on_change is not None: on_change() @color_cb.on_update def _on_color(_) -> None: self._viewer_color_bodies = bool(color_cb.value) if on_change is not None: on_change() scrubber = server.gui.add_slider( "Frame", min=0, max=max_frame, step=1, initial_value=0, ) @scrubber.on_update def _(_) -> None: idx = get_env_idx() self.time_steps[idx] = int(scrubber.value) if on_change is not None: on_change() all_envs_cb = server.gui.add_checkbox("All envs", initial_value=True) start_btn = server.gui.add_button("Start Here") @start_btn.on_click def _(_) -> None: if request_action is not None: request_action( "CUSTOM", {"type": "gui_reset", "all_envs": all_envs_cb.value}, ) self._scrubber_handles = (scrubber, all_envs_cb, start_btn) self._set_scrubber_disabled(True) self.update_viewer_gui(get_env_idx())
def _set_scrubber_disabled(self, disabled: bool) -> None: for handle in self._scrubber_handles: handle.disabled = disabled
[docs] def on_viewer_pause(self, paused: bool) -> None: if hasattr(self, "_scrubber_handles"): self._set_scrubber_disabled(not paused)
[docs] def apply_gui_reset(self, env_ids: torch.Tensor) -> bool: if not hasattr(self, "_scrubber_handles"): return False frame = int(self._scrubber_handles[0].value) self.reset_to_frame(env_ids, frame) self.update_relative_body_poses() return True
[docs] def reset_to_frame(self, env_ids: torch.Tensor, frame: int) -> None: self.time_steps[env_ids] = frame traj_ids = torch.searchsorted( self.motion.segment_end_idx, torch.full((len(env_ids),), frame, device=self.device), right=False, ) traj_ids = torch.clamp(traj_ids, max=self.motion.num_trajectories - 1) self.trajectory_ids[env_ids] = traj_ids self._write_reference_state_to_sim( env_ids, self.body_pos_w[env_ids, 0], self.body_quat_w[env_ids, 0], self.body_lin_vel_w[env_ids, 0], self.body_ang_vel_w[env_ids, 0], self.joint_pos[env_ids], self.joint_vel[env_ids], )
[docs] @dataclass(kw_only=True) class MotionCommandCfg(MjlabMotionCommandCfg): """Config for :class:`MotionCommand` (set per robot in ``<robot>_base_cfg``).""" motion_file: str """Path to converted NPZ library (from ``wbc-mjlab-data-to-npz``).""" anchor_body_name: str """Body used for anchor-frame errors and assistive wrench.""" body_names: tuple[str, ...] """Keybodies tracked in rewards, RSI, and critic observations.""" entity_name: str """Scene entity name for the robot (usually ``\"robot\"``).""" actuated_joint_names: tuple[str, ...] = () """If set, joint tracking metrics/RSI use only these DoFs (subset of the robot).""" pose_range: dict[str, tuple[float, float]] = field(default_factory=dict) """Domain randomization ranges for reference root pose at resample.""" velocity_range: dict[str, tuple[float, float]] = field(default_factory=dict) """Domain randomization ranges for reference root velocity at resample.""" joint_position_range: tuple[float, float] = (-0.52, 0.52) """Domain randomization range for joint position offsets at resample.""" rsi: RsiCfg = field(default_factory=RsiCfg) """Reference-state initialization / adaptive bin sampling.""" assistive_wrench_enabled: bool = True """Whether assistive wrench curriculum is active.""" assistive_beta_max: float = 0.6 """Max assistive gain β.""" assistive_eta: float = 0.8 """Assistive wrench curriculum exponent."""
[docs] @dataclass class VizCfg: mode: Literal["ghost", "frames"] = "ghost" ghost_color: tuple[float, float, float, float] = (0.45, 0.6, 0.9, 0.5)
viz: VizCfg = field(default_factory=VizCfg)
[docs] def build(self, env: ManagerBasedRlEnv) -> MotionCommand: return MotionCommand(self, env)