[software] 添加16DOF早期训练仿真与Sim2Real闭环

This commit is contained in:
2026-07-21 16:15:14 +08:00
parent 9bd22225f9
commit e9e2c946b3
681 changed files with 137221 additions and 8 deletions
@@ -0,0 +1,25 @@
from mjlab.tasks.registry import register_mjlab_task
from .config.env_cfgs import flat_env_cfg, rough_env_cfg, crawl_env_cfg
from .config.rl_cfg import flat_ppo_runner_cfg, rough_ppo_runner_cfg, crawl_ppo_runner_cfg
register_mjlab_task(
task_id="Robot-Flat-v0",
env_cfg=flat_env_cfg(),
play_env_cfg=flat_env_cfg(play=True),
rl_cfg=flat_ppo_runner_cfg(),
)
register_mjlab_task(
task_id="Robot-Rough-v0",
env_cfg=rough_env_cfg(),
play_env_cfg=rough_env_cfg(play=True),
rl_cfg=rough_ppo_runner_cfg(),
)
register_mjlab_task(
task_id="Robot-Crawl-v0",
env_cfg=crawl_env_cfg(),
play_env_cfg=crawl_env_cfg(play=True),
rl_cfg=crawl_ppo_runner_cfg(),
)
@@ -0,0 +1 @@
@@ -0,0 +1,685 @@
"""Robot Environment Configurations for RL Locomotion Training.
This module defines the Manager-Based RL Environment Configurations for unitree robots
equipped with actuated wheels and leg joints. It handles sensors, actuators, command
generators, observation/critic terms, event randomizations, rewards, and terminations
for flat ground, rough terrains, and crawling tasks.
"""
import math
from mjlab.envs import ManagerBasedRlEnvCfg
from mjlab.envs import mdp as envs_mdp
from mjlab.envs.mdp import dr as envs_dr
from mjlab.sim import SimulationCfg, MujocoCfg
from mjlab.managers.action_manager import ActionTermCfg
from mjlab.managers.command_manager import CommandTermCfg
from mjlab.managers.curriculum_manager import CurriculumTermCfg
from mjlab.managers.event_manager import EventTermCfg
from mjlab.managers.metrics_manager import MetricsTermCfg
from mjlab.managers.observation_manager import ObservationGroupCfg, ObservationTermCfg
from mjlab.managers.reward_manager import RewardTermCfg
from mjlab.managers.scene_entity_config import SceneEntityCfg
from mjlab.managers.termination_manager import TerminationTermCfg
from mjlab.scene import SceneCfg
from mjlab.sensor import (
ContactMatch,
ContactSensorCfg,
ObjRef,
RayCastSensorCfg,
GridPatternCfg,
)
from mjlab.tasks.velocity.mdp import UniformVelocityCommandCfg
from mjlab.tasks.velocity import mdp as velocity_mdp
from mjlab.terrains import (
TerrainEntityCfg,
TerrainGeneratorCfg,
BoxFlatTerrainCfg,
BoxPyramidStairsTerrainCfg,
BoxInvertedPyramidStairsTerrainCfg,
BoxRandomGridTerrainCfg,
HfRandomUniformTerrainCfg,
HfPerlinNoiseTerrainCfg,
HfPyramidSlopedTerrainCfg,
)
from ..terrains import RCWallTerrainCfg, RCLowBarTerrainCfg
from mjlab.utils.noise import UniformNoiseCfg as Unoise
from mjlab.viewer import ViewerConfig
from ..robot_cfg import get_robot_cfg, get_robot_crawl_cfg, LEG_POS_SCALE, WHEEL_VEL_SCALE
from ..mdp.lowpass_actions import JointPositionDelayedLowPassActionCfg, JointVelocityDelayedLowPassActionCfg
from ..mdp.disturbances import apply_continuous_disturbance
from ..mdp.only_positive_rewards import enable_only_positive_rewards
from ..mdp.rewards import (
track_linear_velocity,
track_linear_velocity_l1,
track_angular_velocity,
base_height_l2,
safe_base_lin_vel,
safe_foot_contact,
safe_height_scan,
wheel_roll_tracking,
adaptive_leg_motion_penalty,
leg_symmetry,
contact_fraction_reward,
stand_still,
hip_deviation,
joint_deviation_l2,
flat_orientation_l2,
lin_vel_z_l2,
crawl_height_reward,
terrain_level_bonus,
action_rate_curriculum_l2,
variable_posture,
joint_pos_penalty,
joint_mirror,
feet_contact_without_cmd,
upright_roll_only,
pitch_control_penalty,
)
from ..mdp.curriculums import terrain_levels_vel_strict
from ..mdp.commands import UniformThresholdVelocityCommandCfg
# Constant Definitions
WHEEL_NAMES = ("fl", "fr", "rl", "rr")
def _make_base_env_cfg() -> ManagerBasedRlEnvCfg:
"""Create the base environment configuration containing sensors, commands, and default policies."""
# ------------------
# Sensors Definition
# ------------------
feet_ground_cfg = ContactSensorCfg(
name="feet_ground_contact",
primary=ContactMatch(
mode="body",
pattern=tuple(f"{w}_wheel_Link" for w in WHEEL_NAMES),
entity="robot",
),
secondary=ContactMatch(mode="body", pattern="terrain"),
fields=("found", "force"),
reduce="none",
num_slots=1,
history_length=3,
track_air_time=True,
)
base_ground_cfg = ContactSensorCfg(
name="base_ground_contact",
primary=ContactMatch(mode="body", pattern="base_link", entity="robot"),
secondary=ContactMatch(mode="body", pattern="terrain"),
fields=("found",),
reduce="none",
num_slots=1,
history_length=4,
)
body_collision_cfg = ContactSensorCfg(
name="body_collision",
primary=ContactMatch(
mode="body",
pattern=(".*_hip_abduction_Link", ".*_hip_pitch_Link", ".*_knee_Link"),
entity="robot",
),
secondary=ContactMatch(mode="body", pattern="terrain"),
fields=("found", "force"),
reduce="none",
num_slots=1,
history_length=4,
)
terrain_scan = RayCastSensorCfg(
name="height_scanner",
frame=ObjRef(type="body", name="base_link", entity="robot"),
pattern=GridPatternCfg(resolution=0.08, size=(1.6, 1.0)),
ray_alignment="yaw",
max_distance=5.0,
exclude_parent_body=True,
include_geom_groups=(0,),
debug_vis=False,
)
# ------------------
# Observations Setup
# ------------------
actor_terms = {
"base_ang_vel": ObservationTermCfg(
func=envs_mdp.base_ang_vel, scale=0.25,
noise=Unoise(n_min=-0.2, n_max=0.2),
),
"projected_gravity": ObservationTermCfg(
func=velocity_mdp.projected_gravity,
noise=Unoise(n_min=-0.05, n_max=0.05),
),
"command": ObservationTermCfg(
func=velocity_mdp.generated_commands,
params={"command_name": "twist"},
),
"joint_pos": ObservationTermCfg(
func=envs_mdp.joint_pos_rel,
params={"asset_cfg": SceneEntityCfg("robot", joint_names=(
".*_hip_abduction_joint", ".*_hip_pitch_joint", ".*_knee_joint",
))},
noise=Unoise(n_min=-0.01, n_max=0.01),
),
"joint_vel": ObservationTermCfg(
func=envs_mdp.joint_vel_rel,
params={"asset_cfg": SceneEntityCfg("robot", joint_names=(
".*_hip_abduction_joint", ".*_hip_pitch_joint", ".*_knee_joint",
))},
scale=0.05, noise=Unoise(n_min=-1.5, n_max=1.5),
),
"wheel_vel": ObservationTermCfg(
func=envs_mdp.joint_vel_rel,
params={"asset_cfg": SceneEntityCfg("robot", joint_names=(".*_wheel_joint",))},
scale=0.05, noise=Unoise(n_min=-1.0, n_max=1.0),
),
"actions": ObservationTermCfg(func=velocity_mdp.last_action, history_length=1),
}
critic_terms = {
**actor_terms,
"base_lin_vel": ObservationTermCfg(func=safe_base_lin_vel, scale=2.0),
"foot_contact": ObservationTermCfg(
func=safe_foot_contact, params={"sensor_name": "feet_ground_contact"},
),
"height_scan": ObservationTermCfg(
func=safe_height_scan, params={"sensor_name": "height_scanner"},
clip=(-1.0, 1.0),
),
}
observations = {
"actor": ObservationGroupCfg(
terms=actor_terms, concatenate_terms=True,
enable_corruption=True, history_length=6,
),
"critic": ObservationGroupCfg(
terms=critic_terms, concatenate_terms=True, enable_corruption=False,
),
}
# ------------------
# Actions & Commands
# ------------------
actions: dict[str, ActionTermCfg] = {
"leg_joint_pos": JointPositionDelayedLowPassActionCfg(
entity_name="robot",
actuator_names=(".*_hip_abduction_joint", ".*_hip_pitch_joint", ".*_knee_joint"),
scale={".*_hip_abduction_joint": 0.125, "^(?!.*_hip_abduction_joint).*": 0.25}, use_default_offset=True,
control_frequency=50.0, cut_off_frequency=5.0,
min_delay=0, max_delay=2,
),
"wheel_joint_vel": JointVelocityDelayedLowPassActionCfg(
entity_name="robot", actuator_names=(".*_wheel_joint",),
scale=5.0, offset=0.0, use_default_offset=False,
control_frequency=50.0, cut_off_frequency=15.0,
min_delay=0, max_delay=2,
),
}
commands: dict[str, CommandTermCfg] = {
"twist": UniformThresholdVelocityCommandCfg(
entity_name="robot", resampling_time_range=(10.0, 10.0),
rel_standing_envs=0.15, rel_heading_envs=1.0, heading_command=True,
heading_control_stiffness=0.6,
rel_forward_envs=0.40,
ranges=UniformThresholdVelocityCommandCfg.Ranges(
lin_vel_x=(-1.0, 1.0), lin_vel_y=(-0.5, 0.5),
ang_vel_z=(-1.0, 1.0), heading=(-math.pi, math.pi),
),
)
}
# ------------------
# Domain Randomization (Events)
# ------------------
events = {
"reset_scene": EventTermCfg(func=envs_mdp.reset_scene_to_default, mode="reset"),
"reset_base": EventTermCfg(
func=envs_mdp.reset_root_state_uniform, mode="reset",
params={
"pose_range": {"z": (0.30, 0.50), "yaw": (-math.pi, math.pi)},
"velocity_range": {"x": (-0.5, 0.5), "y": (-0.15, 0.15), "yaw": (-0.35, 0.35)},
"asset_cfg": SceneEntityCfg("robot"),
},
),
"push_robot": EventTermCfg(
func=envs_mdp.push_by_setting_velocity, mode="interval",
interval_range_s=(10.0, 15.0),
params={"velocity_range": {"x": (-0.5, 0.5), "y": (-0.5, 0.5)}, "asset_cfg": SceneEntityCfg("robot")},
),
"base_com": EventTermCfg(
func=envs_dr.body_com_offset, mode="startup",
params={"asset_cfg": SceneEntityCfg("robot", body_names=("base_link",)),
"operation": "add", "ranges": {0: (-0.05, 0.05), 1: (-0.05, 0.05), 2: (-0.05, 0.05)}},
),
"encoder_bias": EventTermCfg(
func=envs_dr.encoder_bias, mode="startup",
params={"asset_cfg": SceneEntityCfg("robot", joint_names=(".*",)), "bias_range": (-0.015, 0.015)},
),
"body_friction": EventTermCfg(
func=envs_dr.geom_friction, mode="startup",
params={"asset_cfg": SceneEntityCfg("robot", geom_names=(".*",)), "operation": "abs", "ranges": (0.3, 1.2)},
),
"actuator_stiffness": EventTermCfg(
func=envs_dr.joint_stiffness, mode="startup",
params={"asset_cfg": SceneEntityCfg("robot"), "ranges": (0.9, 1.1), "operation": "scale", "distribution": "log_uniform"},
),
"actuator_damping": EventTermCfg(
func=envs_dr.joint_damping, mode="startup",
params={"asset_cfg": SceneEntityCfg("robot"), "ranges": (0.9, 1.1), "operation": "scale", "distribution": "log_uniform"},
),
"actuator_effort_limit": EventTermCfg(
func=envs_dr.effort_limits, mode="startup",
params={
"asset_cfg": SceneEntityCfg("robot"),
"effort_limit_range": (0.8, 1.0),
"operation": "scale",
"distribution": "uniform",
},
),
"payload_mass": EventTermCfg(
func=envs_dr.body_mass, mode="startup",
params={
"asset_cfg": SceneEntityCfg("robot", body_names=("base_link",)),
"operation": "add",
"ranges": (-1.0, 3.0),
},
),
"continuous_disturbance": EventTermCfg(
func=apply_continuous_disturbance, mode="step",
params={
"asset_cfg": SceneEntityCfg("robot", body_names=("base_link",)),
"force_range": (-15.0, 15.0),
"torque_range": (-10.0, 10.0),
"resample_time_range": (0.5, 2.0),
"time_constant": 0.5,
},
),
}
# ------------------
# Rewards Setup
# ------------------
rewards = {
"track_lin_vel": RewardTermCfg(func=track_linear_velocity, weight=2.5, params={"std": 0.5, "command_name": "twist"}),
"track_ang_vel": RewardTermCfg(func=track_angular_velocity, weight=2.5, params={"std": 0.5, "command_name": "twist"}),
"upright": RewardTermCfg(func=velocity_mdp.upright, weight=1.0, params={"std": 0.5, "asset_cfg": SceneEntityCfg("robot", body_names=("base_link",))}),
"base_height_l2": RewardTermCfg(func=base_height_l2, weight=-2.0, params={"target_height": 0.36}),
"body_ang_vel": RewardTermCfg(func=velocity_mdp.body_angular_velocity_penalty, weight=-0.1, params={"asset_cfg": SceneEntityCfg("robot", body_names=("base_link",))}),
"is_terminated": RewardTermCfg(func=envs_mdp.is_terminated, weight=-200.0),
"joint_torques": RewardTermCfg(func=envs_mdp.joint_torques_l2, weight=-2.0e-4),
"joint_acc": RewardTermCfg(func=envs_mdp.joint_acc_l2, weight=-2.5e-7),
"action_rate": RewardTermCfg(func=envs_mdp.action_rate_l2, weight=-0.01),
"joint_pos_limits": RewardTermCfg(func=envs_mdp.joint_pos_limits, weight=-10.0),
"wheel_roll_tracking": RewardTermCfg(func=wheel_roll_tracking, weight=2.0, params={"command_name": "twist", "wheel_radius": 0.10, "wheel_track": 0.32, "std": 3.0, "asset_cfg": SceneEntityCfg("robot", joint_names=(".*_wheel_joint",))}),
"wheel_contact_bonus": RewardTermCfg(func=contact_fraction_reward, weight=0.5, params={"sensor_name": "feet_ground_contact"}),
"feet_air_time": RewardTermCfg(func=velocity_mdp.feet_air_time, weight=0.5, params={"sensor_name": "feet_ground_contact", "threshold_min": 0.1, "threshold_max": 0.5, "command_name": "twist", "command_threshold": 0.1}),
"leg_motion_penalty": RewardTermCfg(func=adaptive_leg_motion_penalty, weight=-0.02, params={"command_name": "twist", "sensor_name": "feet_ground_contact", "command_threshold": 0.05, "tilt_relax_start": 0.08, "tilt_relax_end": 0.30, "contact_target": 0.85, "min_penalty_scale": 0.2, "asset_cfg": SceneEntityCfg("robot", joint_names=(".*_hip_abduction_joint", ".*_hip_pitch_joint", ".*_knee_joint"))}),
"stand_still": RewardTermCfg(func=stand_still, weight=-0.2, params={"command_name": "twist", "command_threshold": 0.1}),
"body_collision": RewardTermCfg(func=velocity_mdp.self_collision_cost, weight=-1.0, params={"sensor_name": "body_collision"}),
}
# ------------------
# Terminations
# ------------------
terminations = {
"time_out": TerminationTermCfg(func=envs_mdp.time_out, time_out=True),
"bad_orientation": TerminationTermCfg(func=envs_mdp.bad_orientation, params={"limit_angle": 1.0}),
"base_ground_contact": TerminationTermCfg(func=velocity_mdp.illegal_contact, params={"sensor_name": "base_ground_contact"}),
"nan_detection": TerminationTermCfg(func=envs_mdp.nan_detection),
}
curriculum: dict[str, CurriculumTermCfg] = {}
metrics = {"mean_leg_action_acc": MetricsTermCfg(func=velocity_mdp.mean_action_acc)}
return ManagerBasedRlEnvCfg(
scene=SceneCfg(
num_envs=2048, env_spacing=2.5,
terrain=TerrainEntityCfg(terrain_type="generator", terrain_generator=TerrainGeneratorCfg(
size=(8.0, 8.0), border_width=20.0, num_rows=10, num_cols=20,
sub_terrains={"flat": BoxFlatTerrainCfg(proportion=1.0)},
)),
sensors=(feet_ground_cfg, base_ground_cfg, body_collision_cfg, terrain_scan),
),
commands=commands, actions=actions, observations=observations,
rewards=rewards, terminations=terminations, events=events,
metrics=metrics, curriculum=curriculum, decimation=10, episode_length_s=20.0,
sim=SimulationCfg(mujoco=MujocoCfg(impratio=100, cone="elliptic")),
viewer=ViewerConfig(body_name="base_link", distance=3.0, elevation=-20.0, azimuth=45.0),
)
def flat_env_cfg(play: bool = False) -> ManagerBasedRlEnvCfg:
"""Flat ground training and evaluation configuration."""
cfg = _make_base_env_cfg()
cfg.scene.entities = {"robot": get_robot_cfg()}
if play:
cfg.episode_length_s = int(1e9)
cfg.observations["actor"].enable_corruption = False
cfg.events.pop("push_robot", None)
cfg.curriculum = {}
return cfg
def rough_env_cfg(play: bool = False) -> ManagerBasedRlEnvCfg:
"""Rough terrains configuration for general wheel-legged navigation."""
enable_only_positive_rewards()
cfg = _make_base_env_cfg()
cfg.scene.entities = {"robot": get_robot_cfg()}
# ------------------
# Terrain Generator & Curriculum
# ------------------
cfg.scene.terrain = TerrainEntityCfg(
terrain_type="generator",
terrain_generator=TerrainGeneratorCfg(
size=(8.0, 8.0), border_width=20.0, num_rows=10, num_cols=20, curriculum=True,
sub_terrains={
"flat": BoxFlatTerrainCfg(proportion=0.05, size=(8.0, 8.0)),
"pyramid_stairs": BoxPyramidStairsTerrainCfg(proportion=0.25, step_height_range=(0.0, 0.3), step_width=0.30, size=(8.0, 8.0)),
"pyramid_stairs_inv": BoxInvertedPyramidStairsTerrainCfg(proportion=0.10, step_height_range=(0.0, 0.3), step_width=0.30, size=(8.0, 8.0)),
"random_grid": BoxRandomGridTerrainCfg(proportion=0.1, grid_width=0.45, grid_height_range=(0.0, 0.3), size=(8.0, 8.0)),
"random_rough": HfRandomUniformTerrainCfg(proportion=0.05, noise_range=(0.0, 0.06), noise_step=0.01, horizontal_scale=0.20, downsampled_scale=0.20, border_width=0.25, base_thickness_ratio=100.0, size=(8.0, 8.0)),
"perlin_noise": HfPerlinNoiseTerrainCfg(proportion=0.05, height_range=(0.0, 0.06), octaves=2, persistence=0.4, lacunarity=2.0, horizontal_scale=0.20, resolution=0.20, border_width=0.50, base_thickness_ratio=100.0, size=(8.0, 8.0)),
"rc_wall": RCWallTerrainCfg(proportion=0.25, wall_height_range=(0.0, 0.45), size=(8.0, 8.0)),
"sloped_terrain": HfPyramidSlopedTerrainCfg(proportion=0.15, slope_range=(0.052, 0.325), platform_width=2.0, border_width=0.25, base_thickness_ratio=100.0, horizontal_scale=0.20, size=(8.0, 8.0)),
},
),
max_init_terrain_level=0,
)
# Disable default velocity stages command and bind strict velocity terrain curriculum
cfg.curriculum.pop("command_vel", None)
cfg.curriculum["terrain_levels"] = CurriculumTermCfg(func=terrain_levels_vel_strict, params={"command_name": "twist"})
cfg.commands["twist"].heading_command = True
cfg.commands["twist"].rel_heading_envs = 0.5
cfg.commands["twist"].heading_control_stiffness = 0.6
cfg.commands["twist"].ranges.heading = (-math.pi, math.pi)
cfg.commands["twist"].rel_standing_envs = 0.2
# ------------------
# Startup & Reset Randomizations
# ------------------
cfg.events["joint_friction"] = EventTermCfg(func=envs_dr.joint_friction, mode="startup", params={"asset_cfg": SceneEntityCfg("robot"), "ranges": (0.7, 1.3), "operation": "scale"})
cfg.events["reset_joints"] = EventTermCfg(func=envs_mdp.reset_joints_by_offset, mode="reset", params={"position_range": (0.0, 0.1), "velocity_range": (0.0, 0.0), "asset_cfg": SceneEntityCfg("robot", joint_names=(".*",))})
cfg.events["reset_base"] = EventTermCfg(
func=envs_mdp.reset_root_state_uniform, mode="reset",
params={
"pose_range": {"z": (0.40, 0.45), "yaw": (-math.pi, math.pi)},
"velocity_range": {"x": (-0.2, 0.2), "y": (-0.1, 0.1), "yaw": (-0.2, 0.2)},
"asset_cfg": SceneEntityCfg("robot"),
},
)
cfg.events["push_robot"] = EventTermCfg(
func=envs_mdp.push_by_setting_velocity, mode="interval",
interval_range_s=(5.0, 10.0),
params={"velocity_range": {"x": (-0.5, 0.5), "y": (-0.5, 0.5)}, "asset_cfg": SceneEntityCfg("robot")},
)
# ------------------
# Rewards Integration
# ------------------
cfg.rewards["track_lin_vel"] = RewardTermCfg(
func=track_linear_velocity_l1,
weight=4.5,
params={"std": 0.5, "command_name": "twist"}
)
cfg.rewards["track_ang_vel"].weight = 2.0
cfg.rewards["track_ang_vel"].params["std"] = 0.5
cfg.rewards["lin_vel_z"] = RewardTermCfg(func=lin_vel_z_l2, weight=-0.5) # 🌟 增强垂直速度惩罚,抑制越障后的惯性暴冲
cfg.rewards["ang_vel_xy"] = RewardTermCfg(func=velocity_mdp.body_angular_velocity_penalty, weight=-0.3, params={"asset_cfg": SceneEntityCfg("robot", body_names=("base_link",))}) # 🌟 增强角速度惩罚,防止突发性翻转/后仰
cfg.rewards.pop("upright", None)
cfg.rewards["roll_penalty"] = RewardTermCfg(
func=upright_roll_only,
weight=-1.0,
params={"asset_cfg": SceneEntityCfg("robot")}
)
# 🌟 限制俯仰角死区(Pitch Dead-zone):允许正常爬坡时有最大 29 度(0.50 rad)的仰角,但严厉惩罚超过该仰角的“前轮悬空暴冲/后翻”
cfg.rewards["pitch_penalty"] = RewardTermCfg(
func=pitch_control_penalty,
weight=-1.5,
params={"max_pitch_rad": 0.50, "asset_cfg": SceneEntityCfg("robot")}
)
# 动态课程奖励与动作惩罚衰减
cfg.rewards.pop("terrain_level_bonus", None)
cfg.rewards.pop("action_rate", None)
cfg.rewards["action_rate_curriculum"] = RewardTermCfg(func=action_rate_curriculum_l2, weight=-0.005)
cfg.rewards["joint_torques"].weight = -1e-4
cfg.rewards.pop("joint_acc", None)
cfg.rewards["leg_joint_acc_l2"] = RewardTermCfg(func=envs_mdp.joint_acc_l2, weight=-2.5e-7, params={"asset_cfg": SceneEntityCfg("robot", joint_names=(".*_hip_abduction_joint", ".*_hip_pitch_joint", ".*_knee_joint"))})
cfg.rewards["wheel_joint_acc_l2"] = RewardTermCfg(func=envs_mdp.joint_acc_l2, weight=-2.5e-9, params={"asset_cfg": SceneEntityCfg("robot", joint_names=(".*_wheel_joint",))})
cfg.rewards["joint_pos_limits"].weight = -0.2
cfg.rewards.pop("leg_motion_penalty", None)
cfg.rewards["is_terminated"].weight = 0.0
cfg.rewards.pop("leg_symmetry", None)
cfg.rewards["joint_mirror"] = RewardTermCfg(
func=joint_mirror,
weight=-0.05,
params={
"mirror_joints": [
["fl_(hip_abduction|hip_pitch|knee)_joint", "rr_(hip_abduction|hip_pitch|knee)_joint"],
["fr_(hip_abduction|hip_pitch|knee)_joint", "rl_(hip_abduction|hip_pitch|knee)_joint"]
],
"asset_cfg": SceneEntityCfg("robot")
}
)
# 移除 variable_posture 及其产生的静止奖励陷阱,换用极轻微的偏离惩罚
cfg.rewards.pop("stand_still", None)
cfg.rewards["stand_still"] = RewardTermCfg(func=stand_still, weight=-2.0, params={"command_name": "twist", "command_threshold": 0.1})
cfg.rewards.pop("hip_deviation", None)
cfg.rewards.pop("variable_posture", None)
cfg.rewards.pop("joint_deviation_l2", None)
cfg.rewards["joint_pos_penalty"] = RewardTermCfg(
func=joint_pos_penalty,
weight=-0.8,
params={
"stand_still_scale": 5.0,
"velocity_threshold": 0.5,
"command_threshold": 0.1,
"asset_cfg": SceneEntityCfg("robot", joint_names=(".*_hip_abduction_joint", ".*_hip_pitch_joint", ".*_knee_joint")),
"command_name": "twist"
}
)
cfg.rewards["feet_contact_without_cmd"] = RewardTermCfg(
func=feet_contact_without_cmd,
weight=0.1,
params={"command_name": "twist", "sensor_name": "feet_ground_contact"}
)
cfg.rewards["base_height_l2"].weight = -0.5
cfg.rewards["base_height_l2"].params["sensor_cfg"] = SceneEntityCfg("height_scanner")
# 恢复机身碰撞惩罚为-1.0,逼迫机器人高抬腿跨越障碍,防止拖地
cfg.rewards.pop("body_collision", None)
cfg.rewards["body_collision"] = RewardTermCfg(func=velocity_mdp.self_collision_cost, weight=-1.0, params={"sensor_name": "body_collision"})
# 🌟 严厉惩罚机身/胸部碰撞(防止硬撞高墙),逼迫机器人学会用前轮触墙并主动抬腿攀爬的“触觉反射”
cfg.rewards["base_collision"] = RewardTermCfg(
func=velocity_mdp.self_collision_cost,
weight=-5.0,
params={"sensor_name": "base_ground_contact"}
)
# 彻底移除机身俯仰约束,允许机器人抬头爬高? cfg.rewards.pop("flat_orientation", None)
cfg.rewards.pop("feet_air_time", None)
# Remove non-applicable rewards
for key in ("wheel_roll_tracking", "wheel_contact_bonus", "body_ang_vel"):
cfg.rewards.pop(key, None)
cfg.episode_length_s = 30.0
cfg.sim = SimulationCfg(contact_sensor_maxmatch=128, mujoco=MujocoCfg(impratio=100, cone="elliptic", ccd_iterations=80))
# 移除 orientation 终止,允许机器人翻倒以学习回复
cfg.terminations.pop("bad_orientation", None)
# 移除 base_ground_contact 终止,越障时机身会碰到障碍物
cfg.terminations.pop("base_ground_contact", None)
cfg.seed = 42
if cfg.scene.terrain is not None:
cfg.scene.terrain.num_envs = 2048
cfg.scene.terrain.env_spacing = 2.5
if play:
cfg.episode_length_s = int(1e9)
cfg.observations["actor"].enable_corruption = False
cfg.events.pop("push_robot", None)
cfg.curriculum = {}
if cfg.scene.terrain is not None and cfg.scene.terrain.terrain_generator is not None:
cfg.scene.terrain.terrain_generator.curriculum = False
cfg.scene.terrain.terrain_generator.num_cols = 5
cfg.scene.terrain.terrain_generator.num_rows = 5
cfg.scene.terrain.terrain_generator.border_width = 10.0
return cfg
def crawl_env_cfg(play: bool = False) -> ManagerBasedRlEnvCfg:
"""Crawling and ducking configuration for climbing obstacles under low clearing heights."""
enable_only_positive_rewards()
cfg = _make_base_env_cfg()
cfg.scene.entities = {"robot": get_robot_crawl_cfg()}
# ------------------
# Terrain Definition
# ------------------
cfg.scene.terrain = TerrainEntityCfg(
terrain_type="generator",
terrain_generator=TerrainGeneratorCfg(
size=(8.0, 8.0), border_width=20.0, num_rows=10, num_cols=20, curriculum=True,
sub_terrains={
"flat": BoxFlatTerrainCfg(proportion=0.25),
"rc_low_bar": RCLowBarTerrainCfg(proportion=0.35, clearance_range=(0.24, 0.32)),
"random_grid": BoxRandomGridTerrainCfg(proportion=0.20, grid_width=0.45, grid_height_range=(0.0, 0.05)),
"perlin_noise": HfPerlinNoiseTerrainCfg(proportion=0.20, height_range=(0.0, 0.05), octaves=2, persistence=0.4, lacunarity=2.0, horizontal_scale=0.20, resolution=0.20, border_width=0.50, base_thickness_ratio=100.0),
},
),
max_init_terrain_level=0,
)
# Disable command vel curriculum and setup base command ranges
cfg.curriculum.pop("command_vel", None)
cfg.curriculum["terrain_levels"] = CurriculumTermCfg(func=velocity_mdp.terrain_levels_vel, params={"command_name": "twist"})
cfg.commands["twist"].heading_command = False
cfg.commands["twist"].rel_heading_envs = 0.0
cfg.commands["twist"].ranges.heading = None
cfg.commands["twist"].rel_standing_envs = 0.05 # 减少静止比例(匍匐需持续运动? cfg.commands["twist"].rel_forward_envs = 0.40 # 40% 纯前向(低杆地形全是直穿?
# ------------------
# Events & Reset
# ------------------
cfg.events["joint_friction"] = EventTermCfg(func=envs_dr.joint_friction, mode="startup", params={"asset_cfg": SceneEntityCfg("robot"), "ranges": (0.7, 1.3), "operation": "scale"})
cfg.events["reset_joints"] = EventTermCfg(func=envs_mdp.reset_joints_by_offset, mode="reset", params={"position_range": (0.0, 0.1), "velocity_range": (0.0, 0.0), "asset_cfg": SceneEntityCfg("robot", joint_names=(".*",))})
cfg.events["reset_base"] = EventTermCfg(
func=envs_mdp.reset_root_state_uniform, mode="reset",
params={
"pose_range": {"z": (0.00, 0.04), "yaw": (-math.pi, math.pi)},
"velocity_range": {"x": (-0.1, 0.1), "y": (-0.05, 0.05), "yaw": (-0.1, 0.1)},
"asset_cfg": SceneEntityCfg("robot"),
},
)
cfg.events["push_robot"] = EventTermCfg(
func=envs_mdp.push_by_setting_velocity, mode="interval",
interval_range_s=(5.0, 10.0),
params={"velocity_range": {"x": (-0.3, 0.3), "y": (-0.3, 0.3)}, "asset_cfg": SceneEntityCfg("robot")},
)
# ------------------
# Rewards Integration
# ------------------
cfg.rewards["track_lin_vel"].weight = 3.0
cfg.rewards["track_lin_vel"].params["std"] = 0.5
cfg.rewards["track_ang_vel"].weight = 1.5
cfg.rewards["track_ang_vel"].params["std"] = 0.5
cfg.rewards["lin_vel_z"] = RewardTermCfg(func=lin_vel_z_l2, weight=-1.0)
cfg.rewards["ang_vel_xy"] = RewardTermCfg(func=velocity_mdp.body_angular_velocity_penalty, weight=-0.05, params={"asset_cfg": SceneEntityCfg("robot", body_names=("base_link",))})
cfg.rewards["upright"].weight = 1.0
cfg.rewards["upright"].params["std"] = 0.5
cfg.rewards["action_rate"].weight = -0.001
cfg.rewards["joint_torques"].weight = -1e-4
cfg.rewards["joint_acc"].weight = 0.0
cfg.rewards["joint_pos_limits"].weight = -1.0
cfg.rewards["leg_motion_penalty"].weight = -5.0
cfg.rewards["is_terminated"].weight = -50.0
# Under-crawling height reward: maximum bonus when body stays under 0.22m
cfg.rewards.pop("base_height_l2", None)
cfg.rewards["crawl_height_reward"] = RewardTermCfg(
func=crawl_height_reward,
weight=1.5,
params={"target_height": 0.22, "std": 0.05}
)
cfg.rewards.pop("stand_still", None)
cfg.rewards.pop("hip_deviation", None)
cfg.rewards["leg_joint_deviation"] = RewardTermCfg(
func=joint_deviation_l2,
weight=-15.0,
params={"asset_cfg": SceneEntityCfg("robot", joint_names=(".*_hip_abduction_joint", ".*_hip_pitch_joint", ".*_knee_joint"))},
)
# 🌟 强力引入轮子滚动跟踪奖励,引导机器人完全依靠轮子的正向滚动进行平地/低矮处的推进
cfg.rewards["wheel_roll_tracking"] = RewardTermCfg(
func=wheel_roll_tracking,
weight=4.0,
params={
"command_name": "twist",
"wheel_radius": 0.10,
"wheel_track": 0.32,
"std": 3.0,
"asset_cfg": SceneEntityCfg("robot", joint_names=(".*_wheel_joint",))
}
)
if "body_collision" in cfg.rewards:
cfg.rewards["body_collision"].weight = -0.1
cfg.rewards["flat_orientation"] = RewardTermCfg(func=flat_orientation_l2, weight=-1.0)
for key in ("wheel_roll_tracking", "feet_air_time", "wheel_contact_bonus", "body_ang_vel"):
cfg.rewards.pop(key, None)
cfg.episode_length_s = 30.0
cfg.sim = SimulationCfg(contact_sensor_maxmatch=128, mujoco=MujocoCfg(impratio=100, cone="elliptic", ccd_iterations=80))
# Loosen orientation bad threshold to 80 degrees for steep crawling tilts
cfg.terminations["bad_orientation"].params["limit_angle"] = math.radians(80.0)
# Remove base ground contact termination to facilitate crawl under bars
cfg.terminations.pop("base_ground_contact", None)
if play:
cfg.episode_length_s = int(1e9)
cfg.observations["actor"].enable_corruption = False
cfg.events.pop("push_robot", None)
cfg.curriculum = {}
if cfg.scene.terrain is not None and cfg.scene.terrain.terrain_generator is not None:
cfg.scene.terrain.terrain_generator.curriculum = False
cfg.scene.terrain.terrain_generator.num_cols = 5
cfg.scene.terrain.terrain_generator.num_rows = 5
cfg.scene.terrain.terrain_generator.border_width = 10.0
return cfg
@@ -0,0 +1,59 @@
from mjlab.rl import (
RslRlModelCfg,
RslRlOnPolicyRunnerCfg,
RslRlPpoAlgorithmCfg,
)
def flat_ppo_runner_cfg() -> RslRlOnPolicyRunnerCfg:
cfg = rough_ppo_runner_cfg()
cfg.experiment_name = "robot_flat"
cfg.max_iterations = 10_000
return cfg
def rough_ppo_runner_cfg() -> RslRlOnPolicyRunnerCfg:
return RslRlOnPolicyRunnerCfg(
actor=RslRlModelCfg(
hidden_dims=(512, 256, 128),
activation="elu",
obs_normalization=False,
distribution_cfg={
"class_name": "GaussianDistribution",
"init_std": 1.0,
"std_type": "log",
},
),
critic=RslRlModelCfg(
hidden_dims=(512, 256, 128),
activation="elu",
obs_normalization=False,
),
algorithm=RslRlPpoAlgorithmCfg(
value_loss_coef=1.0,
use_clipped_value_loss=True,
clip_param=0.2,
entropy_coef=0.002,
num_learning_epochs=5,
num_mini_batches=4,
learning_rate=2.0e-4,
schedule="adaptive",
gamma=0.99,
lam=0.95,
desired_kl=0.01,
max_grad_norm=1.0,
),
experiment_name="robot_rough",
save_interval=50,
num_steps_per_env=24,
max_iterations=15_000,
)
def crawl_ppo_runner_cfg() -> RslRlOnPolicyRunnerCfg:
cfg = rough_ppo_runner_cfg()
cfg.experiment_name = "robot_crawl"
return cfg
@@ -0,0 +1 @@
@@ -0,0 +1,92 @@
from __future__ import annotations
import torch
from dataclasses import dataclass
from typing import TYPE_CHECKING
from mjlab.tasks.velocity.mdp.velocity_command import UniformVelocityCommand, UniformVelocityCommandCfg
if TYPE_CHECKING:
from mjlab.envs.manager_based_rl_env import ManagerBasedRlEnv
class UniformThresholdVelocityCommand(UniformVelocityCommand):
"""带死区过滤、高速侧向解耦以及地形自适应速度约束的速度命令生成器。
在楼梯和垂直短墙等障碍地形上,自动将 y 轴(横移)和 z 轴(偏航)控制约束置 0.0,
强制机器人心无旁骛笔直冲锋越障,有效杜绝打滑和偏航翻滚跌落;而在平地、斜坡等普通地形上
放开多向混合采样,训练机动转向能力。
"""
cfg: UniformThresholdVelocityCommandCfg
def __init__(self, cfg: UniformThresholdVelocityCommandCfg, env: ManagerBasedRlEnv):
super().__init__(cfg, env)
# 缓存地形类型的索引(台阶、反向台阶、垂直短墙),实现高容错动态查找
self._climbing_indices = []
terrain = getattr(self._env.scene, "terrain", None)
if terrain is not None and getattr(terrain.cfg, "terrain_generator", None) is not None:
sub_terrain_names = list(terrain.cfg.terrain_generator.sub_terrains.keys())
for name in ["pyramid_stairs", "pyramid_stairs_inv", "rc_wall"]:
if name in sub_terrain_names:
self._climbing_indices.append(sub_terrain_names.index(name))
def _resample_command(self, env_ids: torch.Tensor) -> None:
# 1. 调用基类的标准采样
super()._resample_command(env_ids)
# 2. 基础死区过滤:低于 0.2 m/s 时强制归零,区分静止和运动
cmd_xy_norm = torch.norm(self.vel_command_b[env_ids, :2], dim=1)
small_cmd_mask = cmd_xy_norm < 0.2
small_cmd_ids = env_ids[small_cmd_mask]
if len(small_cmd_ids) > 0:
self.vel_command_b[small_cmd_ids, :] = 0.0
self.vel_command_w[small_cmd_ids, :] = 0.0
# 3. 地形自适应重采样限制:若在爬行地形,强制纯前进方向且速度 >= 0.3 m/s
terrain = getattr(self._env.scene, "terrain", None)
terrain_types = getattr(terrain, "terrain_types", None)
if terrain_types is not None and len(self._climbing_indices) > 0:
is_climbing = torch.zeros(self.num_envs, dtype=torch.bool, device=self.device)
for idx in self._climbing_indices:
is_climbing |= (terrain_types == idx)
climbing_env_ids = env_ids[is_climbing[env_ids]]
if len(climbing_env_ids) > 0:
# 强制 x 方向为正(向前越障),y 和 z 轴指令清零
self.vel_command_b[climbing_env_ids, 0] = self.vel_command_b[climbing_env_ids, 0].abs().clamp(min=0.3)
self.vel_command_b[climbing_env_ids, 1] = 0.0
self.vel_command_b[climbing_env_ids, 2] = 0.0
self.is_heading_env[climbing_env_ids] = True
def _update_command(self) -> None:
# 调用基类的每步更新
super()._update_command()
# 4. 每步更新时强力约束:若处于台阶/短墙爬高地形,强制 y 轴横移持续为 0.0,允许温和的偏航纠偏对齐台阶
terrain = getattr(self._env.scene, "terrain", None)
terrain_types = getattr(terrain, "terrain_types", None)
if terrain_types is not None and len(self._climbing_indices) > 0:
is_climbing = torch.zeros(self.num_envs, dtype=torch.bool, device=self.device)
for idx in self._climbing_indices:
is_climbing |= (terrain_types == idx)
climbing_env_ids = is_climbing.nonzero(as_tuple=False).flatten()
if len(climbing_env_ids) > 0:
self.vel_command_b[climbing_env_ids, 1] = 0.0
# 🌟 允许微弱的偏航纠偏,将偏航指令限制在温和的 [-0.3, 0.3] 区间,防止过度甩尾,但保证能修正航向
self.vel_command_b[climbing_env_ids, 2] = torch.clip(
self.cfg.heading_control_stiffness * self.heading_error[climbing_env_ids],
min=-0.3,
max=0.3
)
# 5. 高速侧向解耦(适用于平地/斜坡等混合路面):当前进速度 >= 0.8 m/s 时,清空侧向指令,防止高速甩尾甩飞
high_speed_mask = self.vel_command_b[:, 0].abs() >= 0.8
high_speed_ids = high_speed_mask.nonzero(as_tuple=False).flatten()
if len(high_speed_ids) > 0:
self.vel_command_b[high_speed_ids, 1] = 0.0
@dataclass(kw_only=True)
class UniformThresholdVelocityCommandCfg(UniformVelocityCommandCfg):
class_type: type = UniformThresholdVelocityCommand
@@ -0,0 +1,165 @@
"""Adaptive command curriculum based on tracking reward performance."""
from __future__ import annotations
from typing import TYPE_CHECKING, cast
import torch
from mjlab.managers.curriculum_manager import CurriculumTermCfg
from mjlab.entity import Entity
from mjlab.managers.scene_entity_config import SceneEntityCfg
if TYPE_CHECKING:
from mjlab.envs import ManagerBasedRlEnv
class adaptive_command_vel:
"""Expand velocity command ranges dynamically when tracking performance exceeds threshold.
Uses the reward manager's per-step reward buffer directly to compute the mean raw
tracking reward (in [0, 1] for exponential-based rewards).
"""
def __init__(self, cfg: CurriculumTermCfg, env: ManagerBasedRlEnv):
from mjlab.tasks.velocity.mdp import UniformVelocityCommandCfg
p = cfg.params
self._command_name: str = p["command_name"]
self._reward_name: str = p["reward_name"]
self._upper_threshold: float = p.get("upper_threshold", 0.8)
self._lower_threshold: float = p.get("lower_threshold", 0.4)
self._delta: float = p.get("delta", 0.2)
self._max_lin_vel_x: tuple = tuple(p.get("max_lin_vel_x", (-1.0, 1.0)))
self._max_lin_vel_y: tuple = tuple(p.get("max_lin_vel_y", (-0.5, 0.5)))
self._max_ang_vel_z: tuple = tuple(p.get("max_ang_vel_z", (-1.0, 1.0)))
self._min_range: float = 0.3
command_term = env.command_manager.get_term(self._command_name)
self._cfg = cast(UniformVelocityCommandCfg, command_term.cfg)
# Locate the index of the target reward term
self._reward_idx = list(env.reward_manager._term_names).index(self._reward_name)
self._reward_weight = env.reward_manager.get_term_cfg(self._reward_name).weight
# Exponential moving average (EMA) smoothing to prevent oscillations
self._ema = 0.5
self._running_mean = 0.0
def __call__(
self,
env: ManagerBasedRlEnv,
env_ids: torch.Tensor,
**kwargs,
) -> dict[str, torch.Tensor]:
# Retrieve raw step rewards from step_reward buffer
# step_reward[:, idx] = raw * weight (already scaled by dt)
step_rewards = env.reward_manager._step_reward[:, self._reward_idx]
# raw = step_reward / weight -> [0, 1] for exponential rewards
mean_raw = (torch.mean(step_rewards) / self._reward_weight).item()
# Apply EMA smoothing to prevent jitter
self._running_mean = self._ema * mean_raw + (1 - self._ema) * self._running_mean
if self._running_mean > self._upper_threshold:
self._expand()
elif self._running_mean < self._lower_threshold:
self._shrink()
return self._log()
def _expand(self):
lo, hi = self._cfg.ranges.lin_vel_x
self._cfg.ranges.lin_vel_x = (
max(lo - self._delta, self._max_lin_vel_x[0]),
min(hi + self._delta, self._max_lin_vel_x[1]),
)
lo, hi = self._cfg.ranges.lin_vel_y
self._cfg.ranges.lin_vel_y = (
max(lo - self._delta * 0.5, self._max_lin_vel_y[0]),
min(hi + self._delta * 0.5, self._max_lin_vel_y[1]),
)
lo, hi = self._cfg.ranges.ang_vel_z
self._cfg.ranges.ang_vel_z = (
max(lo - self._delta, self._max_ang_vel_z[0]),
min(hi + self._delta, self._max_ang_vel_z[1]),
)
def _shrink(self):
lo, hi = self._cfg.ranges.lin_vel_x
new_lo = min(lo + self._delta, -self._min_range)
new_hi = max(hi - self._delta, self._min_range)
if new_hi - new_lo >= 2 * self._min_range:
self._cfg.ranges.lin_vel_x = (new_lo, new_hi)
def _log(self) -> dict[str, torch.Tensor]:
return {
"lin_vel_x_min": torch.tensor(self._cfg.ranges.lin_vel_x[0]),
"lin_vel_x_max": torch.tensor(self._cfg.ranges.lin_vel_x[1]),
"ang_vel_z_max": torch.tensor(self._cfg.ranges.ang_vel_z[1]),
}
def terrain_levels_vel_strict(
env: ManagerBasedRlEnv,
env_ids: torch.Tensor,
command_name: str,
asset_cfg: SceneEntityCfg = SceneEntityCfg("robot"),
) -> dict[str, torch.Tensor]:
"""Velocity-based terrain curriculum aligned with standard legged_gym logic.
Upgrade: robot travels beyond half the terrain tile width (> 4m).
Downgrade: actual distance < 50% of commanded target distance.
This matches DreamWaQ / HIMLoco / LocoLeggedWheel curriculum behaviour:
- Promotion is easy (any traversal past 4m qualifies).
- Demotion requires consistently failing to cover half the expected distance.
"""
asset: Entity = env.scene[asset_cfg.name]
terrain = env.scene.terrain
assert terrain is not None
terrain_generator = terrain.cfg.terrain_generator
assert terrain_generator is not None
command = env.command_manager.get_command(command_name)
assert command is not None
# Horizontal displacement from episode start
distance = torch.norm(
asset.data.root_link_pos_w[env_ids, :2] - env.scene.env_origins[env_ids, :2],
dim=1,
)
cmd_speed = torch.norm(command[env_ids, :2], dim=1)
# Upgrade: crossed half the tile width
move_up = distance > terrain_generator.size[0] / 2
# Downgrade: traveled less than 33% of commanded target distance.
# Absolute threshold ≈ cmd_speed × 10m, identical to DreamWaQ / HIMLoco / LocoLeggedWheel
# which use 50% × 20s episode = 10m. Adjusted for the longer 30s episode here.
move_down = (distance < cmd_speed * env.max_episode_length_s * 0.33) & ~move_up
terrain.update_env_origins(env_ids, move_up, move_down)
levels = terrain.terrain_levels.float()
result: dict[str, torch.Tensor] = {
"mean": torch.mean(levels),
"max": torch.max(levels),
}
sub_terrain_names = list(terrain_generator.sub_terrains.keys())
terrain_origins = terrain.terrain_origins
assert terrain_origins is not None
num_cols = terrain_origins.shape[1]
if num_cols == len(sub_terrain_names):
types = terrain.terrain_types
for i, name in enumerate(sub_terrain_names):
mask = types == i
if mask.any():
result[name] = torch.mean(levels[mask])
return result
@@ -0,0 +1,117 @@
"""Continuous low-frequency external wrench disturbance for mjlab."""
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import TYPE_CHECKING
import torch
from mjlab.entity import Entity
from mjlab.managers.scene_entity_config import SceneEntityCfg
from mjlab.utils.lab_api.math import sample_uniform
if TYPE_CHECKING:
from mjlab.envs import ManagerBasedRlEnv
class apply_continuous_disturbance:
"""Apply smooth, continuous low-frequency force and torque disturbances to bodies.
Achieved by sampling random target forces and torques periodically, and interpolating
towards them via a 1st-order low-pass filter (exponential smoothing).
"""
def __init__(self, cfg, env: ManagerBasedRlEnv):
self._asset: Entity = env.scene[cfg.params["asset_cfg"].name]
self._body_ids = cfg.params["asset_cfg"].body_ids
self._num_envs = env.num_envs
self._device = env.device
self._step_dt = env.step_dt
self._num_bodies = (
len(self._body_ids)
if isinstance(self._body_ids, list)
else self._asset.num_bodies
)
# Disturbance states: shape (num_envs, num_bodies, 3)
self._current_force = torch.zeros(self._num_envs, self._num_bodies, 3, device=self._device)
self._current_torque = torch.zeros(self._num_envs, self._num_bodies, 3, device=self._device)
self._target_force = torch.zeros(self._num_envs, self._num_bodies, 3, device=self._device)
self._target_torque = torch.zeros(self._num_envs, self._num_bodies, 3, device=self._device)
# Periodic resampling timers: shape (num_envs,)
self._timer = torch.zeros(self._num_envs, device=self._device)
def __call__(
self,
env: ManagerBasedRlEnv,
env_ids: torch.Tensor | None,
force_range: tuple[float, float],
torque_range: tuple[float, float],
resample_time_range: tuple[float, float],
time_constant: float,
asset_cfg: SceneEntityCfg,
) -> None:
"""Tick disturbance state: interpolate towards targets, periodically resample targets.
Use with mode="step".
"""
del env, env_ids, asset_cfg # Unused, step events act on all envs.
dt = self._step_dt
# Decrement timers
self._timer -= dt
# Identify envs that need resampling
resample = self._timer <= 0
if resample.any():
resample_ids = resample.nonzero(as_tuple=False).squeeze(-1)
n = len(resample_ids)
# Sample new targets
size = (n, self._num_bodies, 3)
self._target_force[resample_ids] = sample_uniform(*force_range, size, self._device)
self._target_torque[resample_ids] = sample_uniform(*torque_range, size, self._device)
# Sample new timer durations
t_low, t_high = resample_time_range
self._timer[resample_ids] = (
torch.rand(n, device=self._device) * (t_high - t_low) + t_low
)
# Exponential smoothing step: x_new = (1 - alpha) * x + alpha * x_target
alpha = 1.0 - math.exp(-dt / time_constant)
self._current_force = (1.0 - alpha) * self._current_force + alpha * self._target_force
self._current_torque = (1.0 - alpha) * self._current_torque + alpha * self._target_torque
# Apply smooth wrenches to simulation
all_env_ids = torch.arange(self._num_envs, device=self._device)
self._asset.write_external_wrench_to_sim(
self._current_force, self._current_torque, env_ids=all_env_ids, body_ids=self._body_ids
)
def reset(self, env_ids: torch.Tensor | slice | None = None) -> None:
if env_ids is None:
env_ids = slice(None)
# Reset states
self._current_force[env_ids] = 0.0
self._current_torque[env_ids] = 0.0
self._target_force[env_ids] = 0.0
self._target_torque[env_ids] = 0.0
self._timer[env_ids] = 0.0
if isinstance(env_ids, slice):
reset_ids = torch.arange(self._num_envs, device=self._device)[env_ids]
else:
reset_ids = env_ids
if len(reset_ids) > 0:
zeros = torch.zeros((len(reset_ids), self._num_bodies, 3), device=self._device)
self._asset.write_external_wrench_to_sim(
zeros, zeros, env_ids=reset_ids, body_ids=self._body_ids
)
@@ -0,0 +1,245 @@
"""Low-pass filtered action terms for mjlab (native implementation).
Provides FIR low-pass filtering on the raw policy output before applying
scale/offset. This smooths control signals and reduces sim-to-real gap.
- Leg joints: 5 Hz cutoff (slow, smooth)
- Wheel joints: 15 Hz cutoff (faster response needed)
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import TYPE_CHECKING
import torch
from mjlab.envs.mdp.actions.actions import (
JointPositionAction,
JointPositionActionCfg,
JointVelocityAction,
JointVelocityActionCfg,
)
if TYPE_CHECKING:
from mjlab.envs import ManagerBasedRlEnv
def _lowpass_weights(control_freq: float, cutoff_freq: float) -> list[float]:
"""Compute 1st-order IIR low-pass filter weights.
alpha = 1 - exp(-2π * f_c / f_s)
filtered = alpha * current + (1 - alpha) * previous
"""
alpha = 1.0 - math.exp(-2.0 * math.pi * cutoff_freq / control_freq)
return [alpha, 1.0 - alpha]
# -- Position low-pass --
@dataclass(kw_only=True)
class JointPositionLowPassActionCfg(JointPositionActionCfg):
"""Joint position action with 1st-order low-pass filter on policy output."""
control_frequency: float = 50.0
"""Control frequency in Hz (= 1 / (dt * decimation))."""
cut_off_frequency: float = 5.0
"""Cut-off frequency in Hz for the low-pass filter."""
def build(self, env: ManagerBasedRlEnv) -> JointPositionLowPassAction:
return JointPositionLowPassAction(self, env)
class JointPositionLowPassAction(JointPositionAction):
"""Applies a 1st-order low-pass filter to raw actions before processing."""
def __init__(self, cfg: JointPositionLowPassActionCfg, env: ManagerBasedRlEnv):
super().__init__(cfg, env)
self._weights = _lowpass_weights(cfg.control_frequency, cfg.cut_off_frequency)
self._prev_raw = torch.zeros_like(self._raw_actions)
def process_actions(self, actions: torch.Tensor):
filtered = self._weights[0] * actions + self._weights[1] * self._prev_raw
self._prev_raw[:] = actions
super().process_actions(filtered)
def reset(self, env_ids: torch.Tensor | slice | None = None) -> None:
self._prev_raw[env_ids] = 0.0
super().reset(env_ids)
# -- Velocity low-pass --
@dataclass(kw_only=True)
class JointVelocityLowPassActionCfg(JointVelocityActionCfg):
"""Joint velocity action with 1st-order low-pass filter on policy output."""
control_frequency: float = 50.0
"""Control frequency in Hz (= 1 / (dt * decimation))."""
cut_off_frequency: float = 15.0
"""Cut-off frequency in Hz for the low-pass filter."""
def build(self, env: ManagerBasedRlEnv) -> JointVelocityLowPassAction:
return JointVelocityLowPassAction(self, env)
class JointVelocityLowPassAction(JointVelocityAction):
"""Applies a 1st-order low-pass filter to raw actions before processing."""
def __init__(self, cfg: JointVelocityLowPassActionCfg, env: ManagerBasedRlEnv):
super().__init__(cfg, env)
self._weights = _lowpass_weights(cfg.control_frequency, cfg.cut_off_frequency)
self._prev_raw = torch.zeros_like(self._raw_actions)
def process_actions(self, actions: torch.Tensor):
filtered = self._weights[0] * actions + self._weights[1] * self._prev_raw
self._prev_raw[:] = actions
super().process_actions(filtered)
def reset(self, env_ids: torch.Tensor | slice | None = None) -> None:
self._prev_raw[env_ids] = 0.0
super().reset(env_ids)
@dataclass(kw_only=True)
class JointPositionDelayedLowPassActionCfg(JointPositionLowPassActionCfg):
"""Joint position action with random delay and 1st-order low-pass filter."""
min_delay: int = 0
max_delay: int = 2
def build(self, env: ManagerBasedRlEnv) -> JointPositionDelayedLowPassAction:
return JointPositionDelayedLowPassAction(self, env)
class JointPositionDelayedLowPassAction(JointPositionLowPassAction):
"""Applies a random delay and a 1st-order low-pass filter to raw actions."""
def __init__(self, cfg: JointPositionDelayedLowPassActionCfg, env: ManagerBasedRlEnv):
super().__init__(cfg, env)
self.min_delay = cfg.min_delay
self.max_delay = cfg.max_delay
# Buffer shape: (max_delay + 1, num_envs, action_dim)
self._action_buffer = torch.zeros(
self.max_delay + 1, self.num_envs, self.action_dim, device=self.device
)
# Active delays for each env, shape: (num_envs,)
self._active_delays = torch.zeros(self.num_envs, dtype=torch.long, device=self.device)
self.reset_delays(torch.arange(self.num_envs, device=self.device))
def reset_delays(self, env_ids: torch.Tensor):
if self.min_delay == self.max_delay:
self._active_delays[env_ids] = self.min_delay
else:
sampled = torch.randint(
self.min_delay,
self.max_delay + 1,
(len(env_ids),),
dtype=torch.long,
device=self.device,
)
self._active_delays[env_ids] = sampled
def process_actions(self, actions: torch.Tensor):
if self.max_delay > 0:
self._action_buffer = torch.cat(
[self._action_buffer[1:], actions.unsqueeze(0)], dim=0
)
else:
self._action_buffer[0] = actions
indices = self.max_delay - self._active_delays
actions_delayed = self._action_buffer[
indices, torch.arange(self.num_envs, device=self.device)
]
filtered = self._weights[0] * actions_delayed + self._weights[1] * self._prev_raw
self._prev_raw[:] = actions_delayed
super(JointPositionLowPassAction, self).process_actions(filtered)
def reset(self, env_ids: torch.Tensor | slice | None = None) -> None:
if env_ids is None:
env_ids = torch.arange(self.num_envs, device=self.device)
elif isinstance(env_ids, slice):
env_ids = torch.arange(self.num_envs, device=self.device)[env_ids]
self.reset_delays(env_ids)
self._action_buffer[:, env_ids] = 0.0
self._prev_raw[env_ids] = 0.0
super().reset(env_ids)
@dataclass(kw_only=True)
class JointVelocityDelayedLowPassActionCfg(JointVelocityLowPassActionCfg):
"""Joint velocity action with random delay and 1st-order low-pass filter."""
min_delay: int = 0
max_delay: int = 2
def build(self, env: ManagerBasedRlEnv) -> JointVelocityDelayedLowPassAction:
return JointVelocityDelayedLowPassAction(self, env)
class JointVelocityDelayedLowPassAction(JointVelocityLowPassAction):
"""Applies a random delay and a 1st-order low-pass filter to raw actions."""
def __init__(self, cfg: JointVelocityDelayedLowPassActionCfg, env: ManagerBasedRlEnv):
super().__init__(cfg, env)
self.min_delay = cfg.min_delay
self.max_delay = cfg.max_delay
# Buffer shape: (max_delay + 1, num_envs, action_dim)
self._action_buffer = torch.zeros(
self.max_delay + 1, self.num_envs, self.action_dim, device=self.device
)
# Active delays for each env, shape: (num_envs,)
self._active_delays = torch.zeros(self.num_envs, dtype=torch.long, device=self.device)
self.reset_delays(torch.arange(self.num_envs, device=self.device))
def reset_delays(self, env_ids: torch.Tensor):
if self.min_delay == self.max_delay:
self._active_delays[env_ids] = self.min_delay
else:
sampled = torch.randint(
self.min_delay,
self.max_delay + 1,
(len(env_ids),),
dtype=torch.long,
device=self.device,
)
self._active_delays[env_ids] = sampled
def process_actions(self, actions: torch.Tensor):
if self.max_delay > 0:
self._action_buffer = torch.cat(
[self._action_buffer[1:], actions.unsqueeze(0)], dim=0
)
else:
self._action_buffer[0] = actions
indices = self.max_delay - self._active_delays
actions_delayed = self._action_buffer[
indices, torch.arange(self.num_envs, device=self.device)
]
filtered = self._weights[0] * actions_delayed + self._weights[1] * self._prev_raw
self._prev_raw[:] = actions_delayed
super(JointVelocityLowPassAction, self).process_actions(filtered)
def reset(self, env_ids: torch.Tensor | slice | None = None) -> None:
if env_ids is None:
env_ids = torch.arange(self.num_envs, device=self.device)
elif isinstance(env_ids, slice):
env_ids = torch.arange(self.num_envs, device=self.device)[env_ids]
self.reset_delays(env_ids)
self._action_buffer[:, env_ids] = 0.0
self._prev_raw[env_ids] = 0.0
super().reset(env_ids)
@@ -0,0 +1,174 @@
"""Discrete mode command term for multi-conditioned policy training.
Architecture is modeled after UniformVelocityCommand / UniformVelocityCommandCfg in
mjlab/tasks/velocity/mdp/velocity_command.py.
During training: mode_id in {0, 1, 2} is sampled randomly per episode based on mode_probs.
During deployment: ModeCommandCfg.fixed_mode is set to 0, 1, or 2, which can be modified externally.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
import torch
from mjlab.managers.command_manager import CommandTerm, CommandTermCfg
if TYPE_CHECKING:
from mjlab.envs.manager_based_rl_env import ManagerBasedRlEnv
# Locomotion Mode ID Constants
MODE_WALK = 0
MODE_CLIMB = 1
MODE_CROUCH = 2
NUM_MODES = 3
class ModeCommand(CommandTerm):
"""Discrete locomotion mode command term.
The command tensor shape is [num_envs, 1], holding normalized mode values in [-1.0, 1.0].
Mapping details: mode_id -> observation value
0 (walk) -> -1.0
1 (climb) -> 0.0
2 (crouch) -> +1.0
Using normalized scalar values keeps observation dimensions minimal (1D).
"""
cfg: ModeCommandCfg
def __init__(self, cfg: ModeCommandCfg, env: ManagerBasedRlEnv):
super().__init__(cfg, env)
# Stores current integer mode_id for each environment
self._mode_id = torch.zeros(
self.num_envs, dtype=torch.long, device=self.device
)
# Normalized command observations: shape [num_envs, 1]
self._command = torch.zeros(self.num_envs, 1, device=self.device)
# Pre-calculate cumulative sum of mode probabilities (normalized for multinomial sampling)
probs = torch.tensor(cfg.mode_probs, dtype=torch.float32, device=self.device)
self._mode_probs = probs / probs.sum()
@property
def command(self) -> torch.Tensor:
"""Normalized mode command observation tensor of shape [num_envs, 1]."""
return self._command
def _update_metrics(self) -> None:
pass
def _resample_command(self, env_ids: torch.Tensor) -> None:
if self.cfg.fixed_mode is not None:
# Deployment mode: fixed mode
self._mode_id[env_ids] = self.cfg.fixed_mode
else:
# Training mode: map modes based on active terrain type if configured
terrain = getattr(self._env.scene, "terrain", None)
terrain_types = getattr(terrain, "terrain_types", None)
if self.cfg.terrain_mode_mapping is not None and terrain_types is not None:
mapping = torch.tensor(
self.cfg.terrain_mode_mapping, dtype=torch.long, device=self.device
)
types = terrain_types[env_ids]
# Defensive clamp to prevent out of bounds indexing
types_safe = torch.clamp(types, 0, len(mapping) - 1)
self._mode_id[env_ids] = mapping[types_safe]
else:
# Fallback to random multinomial sampling
sampled = torch.multinomial(
self._mode_probs.expand(len(env_ids), -1),
num_samples=1,
replacement=True,
).squeeze(-1)
self._mode_id[env_ids] = sampled
# Update normalized command observations
self._update_obs_from_mode_id(env_ids)
def _update_command(self) -> None:
# Modes are persistent across episodes, no step-level command updates required
pass
def _update_obs_from_mode_id(self, env_ids: torch.Tensor) -> None:
"""Map integer mode_id to normalized observation: walk=0 -> -1.0, climb=1 -> 0.0, crouch=2 -> +1.0."""
mode = self._mode_id[env_ids].float()
# Linear projection: [0, NUM_MODES - 1] -> [-1.0, +1.0]
normalized = 2.0 * mode / (NUM_MODES - 1) - 1.0
self._command[env_ids, 0] = normalized
def create_gui(
self,
name: str,
server, # ViserServer instance (late-bound to avoid top-level dependency)
get_env_idx,
on_change=None,
request_action=None,
) -> None:
"""Construct interactive mode selection controls in the Viser visualizer GUI.
When enabled is active, slider values override target environment mode IDs in real-time.
Discrete modes: 0=walk, 1=climb, 2=crouch (step size = 1).
"""
with server.gui.add_folder(f"{name.capitalize()} (0=walk 1=climb 2=crouch)"):
enabled = server.gui.add_checkbox("Enable", initial_value=False)
mode_slider = server.gui.add_slider(
"mode_id",
min=0,
max=NUM_MODES - 1,
step=1,
initial_value=0,
)
# Store GUI handles for compute() execution
self._gui_enabled = enabled
self._gui_slider = mode_slider
self._gui_get_env_idx = get_env_idx
def compute(self, dt: float) -> None:
"""Perform step updates: override mode_id with interactive sliders if visualizer GUI is active."""
super().compute(dt)
if (
getattr(self, "_gui_enabled", None) is not None
and self._gui_enabled.value
and self._gui_get_env_idx is not None
):
idx = self._gui_get_env_idx()
new_mode = int(round(self._gui_slider.value))
env_ids = torch.tensor([idx], dtype=torch.long, device=self.device)
self._mode_id[env_ids] = new_mode
self._update_obs_from_mode_id(env_ids)
def get_mode_id(self, env_ids: torch.Tensor | None = None) -> torch.Tensor:
"""Retrieve active environment mode IDs for reward/termination conditioning."""
if env_ids is None:
return self._mode_id
return self._mode_id[env_ids]
@dataclass(kw_only=True)
class ModeCommandCfg(CommandTermCfg):
"""Discrete ModeCommand configuration class."""
resampling_time_range: tuple[float, float] = (20.0, 20.0)
"""Discrete mode resampling duration (resampled at episode boundary, equal to episode length)."""
mode_probs: tuple[float, ...] = (0.5, 0.3, 0.2)
"""Probabilities for discrete modes (walk, climb, crouch). Normalized automatically."""
fixed_mode: int | None = None
"""None for active random sampling during training; 0/1/2 for deployment."""
terrain_mode_mapping: tuple[int, ...] | None = None
"""Force maps discrete modes based on environment sub-terrain grid index.
E.g., (0, 0, 0, 0, 1, 1, 2) corresponds to walk mode for first four sub-terrains,
climb mode for the next two sub-terrains, and crouch mode for the last sub-terrain.
Overrides mode_probs if active.
"""
def build(self, env: ManagerBasedRlEnv) -> ModeCommand:
return ModeCommand(self, env)
@@ -0,0 +1,42 @@
"""HIMLoco-style only_positive_rewards: clip total step reward to >= 0.
Mechanics:
- PPO Advantage = reward + gamma * V(s') - V(s)
- During early policy learning, when penalties greatly exceed positive rewards,
the total step reward can become highly negative, producing negative advantages.
- This often causes policies to learn to intentionally "fall down immediately" to terminate
the episode and minimize long-term negative accumulation.
- Clamping the total step reward to >= 0 prevents the penalty from generating negative
step feedback, ensuring penalty terms can only offset positive gains without directing the
advantage signal incorrectly.
- Individual term episode sums are still recorded correctly for tracking and diagnostics.
"""
from __future__ import annotations
import torch
from mjlab.managers.reward_manager import RewardManager
_original_compute = RewardManager.compute
def _compute_with_clamp(self: RewardManager, dt: float) -> torch.Tensor:
"""Compute step rewards and clamp the final total reward to >= 0."""
reward = _original_compute(self, dt)
# In-place clamp on the same buffer reference for consistency with original API
return torch.clamp(reward, min=0.0)
def enable_only_positive_rewards() -> None:
"""Enable HIMLoco-style positive-only step reward clamping globally.
Idempotent: calling this multiple times is completely safe.
"""
if RewardManager.compute is not _compute_with_clamp:
RewardManager.compute = _compute_with_clamp
def disable_only_positive_rewards() -> None:
"""Disable positive-only step reward clamping, restoring default behavior."""
if RewardManager.compute is not _original_compute:
RewardManager.compute = _original_compute
@@ -0,0 +1,738 @@
"""MDP Reward functions for unitree locomotion training."""
from __future__ import annotations
from typing import TYPE_CHECKING
import torch
from mjlab.managers.scene_entity_config import SceneEntityCfg
from mjlab.managers.reward_manager import RewardTermCfg
from mjlab.entity import Entity
from mjlab.utils.lab_api.string import resolve_matching_names_values
if TYPE_CHECKING:
from mjlab.envs import ManagerBasedRlEnv
def track_linear_velocity(
env: ManagerBasedRlEnv,
std: float,
command_name: str,
asset_cfg: SceneEntityCfg | None = None,
) -> torch.Tensor:
"""Reward tracking commanded horizontal xy velocity."""
if asset_cfg is None:
asset_cfg = SceneEntityCfg("robot")
asset: Entity = env.scene[asset_cfg.name]
command = env.command_manager.get_command(command_name)
actual = asset.data.root_link_lin_vel_b
xy_error = torch.sum(torch.square(command[:, :2] - actual[:, :2]), dim=1)
reward = torch.exp(-xy_error / std**2)
return reward
def track_angular_velocity(
env: ManagerBasedRlEnv,
std: float,
command_name: str,
asset_cfg: SceneEntityCfg | None = None,
) -> torch.Tensor:
"""Reward tracking commanded yaw rate."""
if asset_cfg is None:
asset_cfg = SceneEntityCfg("robot")
asset: Entity = env.scene[asset_cfg.name]
command = env.command_manager.get_command(command_name)
actual = asset.data.root_link_ang_vel_b
z_error = torch.square(command[:, 2] - actual[:, 2])
reward = torch.exp(-z_error / std**2)
return reward
def base_height_l2(
env: ManagerBasedRlEnv,
target_height: float = 0.36,
sensor_cfg: SceneEntityCfg | None = None,
asset_cfg: SceneEntityCfg | None = None,
) -> torch.Tensor:
"""Penalize base height deviation from target height, relative to terrain or origin."""
if asset_cfg is None:
asset_cfg = SceneEntityCfg("robot")
asset: Entity = env.scene[asset_cfg.name]
has_sensor = False
if sensor_cfg is not None:
try:
_ = env.scene[sensor_cfg.name]
has_sensor = True
except KeyError:
has_sensor = False
if has_sensor:
from mjlab.envs.mdp.observations import height_scan
# Get height scanner readings (each ray's height relative to base frame Z)
heights = height_scan(env, sensor_cfg.name, offset=0.0)
# Clean any invalid values
heights = torch.nan_to_num(heights, nan=target_height, posinf=target_height, neginf=target_height)
# Filter out rays that represent extreme misses/cliffs (e.g. height < 0 or height > 1.5)
# The robot target height is ~0.36m, so the scanner should measure something between 0.1m and 0.8m usually.
valid_mask = (heights > 0.0) & (heights < 1.5)
# Calculate mean height above terrain ignoring misses
sum_heights = torch.sum(torch.where(valid_mask, heights, torch.zeros_like(heights)), dim=1)
count_valid = torch.sum(valid_mask.float(), dim=1)
measured_height = torch.where(count_valid > 0, sum_heights / torch.clamp(count_valid, min=1.0), torch.full_like(sum_heights, target_height))
error = measured_height - target_height
else:
if hasattr(env.scene, "env_origins") and env.scene.env_origins is not None:
env_origins_z = env.scene.env_origins[:, 2]
else:
env_origins_z = torch.zeros(env.num_envs, device=env.device)
root_z = asset.data.root_link_pos_w[:, 2] - env_origins_z
error = root_z - target_height
reward = torch.square(error)
return reward
def safe_height_scan(env: ManagerBasedRlEnv, sensor_name: str) -> torch.Tensor:
"""Safely fetch height scanner outputs, replacing NaNs/infs with finite values."""
from mjlab.envs.mdp.observations import height_scan
result = height_scan(env, sensor_name)
return torch.nan_to_num(result, nan=0.0, posinf=5.0, neginf=-5.0)
def safe_base_lin_vel(env: ManagerBasedRlEnv) -> torch.Tensor:
"""Safely fetch base linear velocity, replacing NaNs/infs with finite values."""
from mjlab.envs.mdp.observations import base_lin_vel
result = base_lin_vel(env)
return torch.nan_to_num(result, nan=0.0, posinf=100.0, neginf=-100.0)
def safe_foot_air_time(env: ManagerBasedRlEnv, sensor_name: str) -> torch.Tensor:
"""Safely fetch foot air time, replacing NaNs with zeros."""
from mjlab.tasks.velocity.mdp.observations import foot_air_time
result = foot_air_time(env, sensor_name)
return torch.nan_to_num(result, nan=0.0)
def safe_foot_contact(env: ManagerBasedRlEnv, sensor_name: str) -> torch.Tensor:
"""Safely fetch foot contact flags, replacing NaNs with zeros."""
from mjlab.tasks.velocity.mdp.observations import foot_contact
result = foot_contact(env, sensor_name)
return torch.nan_to_num(result, nan=0.0)
def safe_foot_contact_forces(env: ManagerBasedRlEnv, sensor_name: str) -> torch.Tensor:
"""Safely fetch foot contact forces, replacing NaNs with zeros."""
from mjlab.tasks.velocity.mdp.observations import foot_contact_forces
result = foot_contact_forces(env, sensor_name)
return torch.nan_to_num(result, nan=0.0)
def feet_clearance(
env: ManagerBasedRlEnv,
target_height: float,
command_name: str = "twist",
command_threshold: float = 0.1,
asset_cfg: SceneEntityCfg | None = None,
) -> torch.Tensor:
"""Penalize deviation from the target foot clearance height."""
if asset_cfg is None:
asset_cfg = SceneEntityCfg("robot", body_names=(".*_wheel_Link",))
asset: Entity = env.scene[asset_cfg.name]
foot_z = asset.data.body_com_pos_w[:, asset_cfg.body_ids, 2]
foot_vel_xy = asset.data.body_link_vel_w[:, asset_cfg.body_ids, :2]
vel_norm = torch.norm(foot_vel_xy, dim=-1)
delta = torch.abs(foot_z - target_height)
cost = torch.sum(delta * vel_norm, dim=1)
cmd = env.command_manager.get_command(command_name)
linear_norm = torch.norm(cmd[:, :2], dim=1)
angular_norm = torch.abs(cmd[:, 2])
total_command = linear_norm + angular_norm
active = (total_command > command_threshold).float()
reward = cost * active
return reward
def soft_landing(
env: ManagerBasedRlEnv,
sensor_name: str,
command_name: str = "twist",
command_threshold: float = 0.05,
) -> torch.Tensor:
"""Penalize high impact forces on the first landing contact."""
from mjlab.sensor import ContactSensor
contact_sensor: ContactSensor = env.scene[sensor_name]
forces = contact_sensor.data.force
force_magnitude = torch.norm(forces, dim=-1)
first_contact = contact_sensor.compute_first_contact(dt=env.step_dt)
landing_impact = force_magnitude * first_contact.float()
cost = torch.sum(landing_impact, dim=1)
cmd = env.command_manager.get_command(command_name)
linear_norm = torch.norm(cmd[:, :2], dim=1)
angular_norm = torch.abs(cmd[:, 2])
total_command = linear_norm + angular_norm
active = (total_command > command_threshold).float()
reward = cost * active
return reward
def _total_command_magnitude(env: ManagerBasedRlEnv, command_name: str) -> torch.Tensor:
"""Retrieve absolute horizontal command speed."""
command = env.command_manager.get_command(command_name)
linear_norm = torch.norm(command[:, :2], dim=1)
angular_norm = torch.abs(command[:, 2])
return linear_norm + angular_norm
def wheel_roll_tracking(
env: ManagerBasedRlEnv,
command_name: str,
wheel_radius: float,
wheel_track: float,
std: float,
asset_cfg: SceneEntityCfg | None = None,
) -> torch.Tensor:
"""Reward wheel angular velocity matching the commanded planar motion."""
if asset_cfg is None:
asset_cfg = SceneEntityCfg("robot", joint_names=(".*_wheel_joint",))
asset: Entity = env.scene[asset_cfg.name]
command = env.command_manager.get_command(command_name)
wheel_vel = asset.data.joint_vel[:, asset_cfg.joint_ids]
# Assumes order: fl, fr, rl, rr. Side signs: fl=1, fr=-1, rl=1, rr=-1
side_signs = torch.tensor(
[1.0, -1.0, 1.0, -1.0],
device=env.device,
dtype=wheel_vel.dtype,
).unsqueeze(0)
lin_vel_x = command[:, 0:1]
ang_vel_z = command[:, 2:3]
target_wheel_vel = (
lin_vel_x + 0.5 * wheel_track * side_signs * ang_vel_z
) / wheel_radius
err = torch.mean(torch.square(wheel_vel - target_wheel_vel), dim=1)
reward = torch.exp(-err / (std**2))
return reward
def adaptive_leg_motion_penalty(
env: ManagerBasedRlEnv,
command_name: str,
sensor_name: str,
command_threshold: float = 0.05,
tilt_relax_start: float = 0.08,
tilt_relax_end: float = 0.30,
contact_target: float = 0.85,
min_penalty_scale: float = 0.2,
asset_cfg: SceneEntityCfg | None = None,
) -> torch.Tensor:
"""Penalize leg motion less when tilt or wheel contact suggests recovery."""
if asset_cfg is None:
asset_cfg = SceneEntityCfg("robot", joint_names=(
".*_hip_abduction_joint",
".*_hip_pitch_joint",
".*_knee_joint",
))
asset: Entity = env.scene[asset_cfg.name]
leg_vel = asset.data.joint_vel[:, asset_cfg.joint_ids]
base_cost = torch.mean(torch.square(leg_vel), dim=1)
active = (_total_command_magnitude(env, command_name) > command_threshold).float()
tilt_xy = torch.norm(asset.data.projected_gravity_b[:, :2], dim=1)
tilt_relax = torch.clamp(
(tilt_xy - tilt_relax_start) / max(tilt_relax_end - tilt_relax_start, 1.0e-6),
min=0.0,
max=1.0,
)
from mjlab.sensor import ContactSensor
sensor: ContactSensor = env.scene[sensor_name]
in_contact = (sensor.data.found > 0).float()
contact_fraction = torch.mean(in_contact, dim=1)
if contact_fraction.ndim > 1:
contact_fraction = torch.mean(contact_fraction, dim=1)
contact_relax = torch.clamp(
(contact_target - contact_fraction) / max(contact_target, 1.0e-6),
min=0.0,
max=1.0,
)
relax = torch.maximum(tilt_relax, contact_relax)
penalty_scale = 1.0 - (1.0 - min_penalty_scale) * relax
reward = base_cost * active * penalty_scale
return reward
def contact_fraction_reward(
env: ManagerBasedRlEnv,
sensor_name: str,
) -> torch.Tensor:
"""Reward persistent wheel-ground contact."""
from mjlab.sensor import ContactSensor
sensor: ContactSensor = env.scene[sensor_name]
in_contact = (sensor.data.found > 0).float()
reward = torch.mean(in_contact, dim=1)
return reward
def stand_still(
env,
command_name: str,
command_threshold: float = 0.1,
asset_cfg: SceneEntityCfg | None = None,
) -> torch.Tensor:
if asset_cfg is None:
asset_cfg = SceneEntityCfg("robot", joint_names=(
".*_hip_abduction_joint", ".*_hip_pitch_joint", ".*_knee_joint",
))
asset = env.scene[asset_cfg.name]
diff = asset.data.joint_pos[:, asset_cfg.joint_ids] - asset.data.default_joint_pos[:, asset_cfg.joint_ids]
cost = torch.mean(torch.abs(diff), dim=1)
command = env.command_manager.get_command(command_name)
linear_norm = torch.norm(command[:, :2], dim=1)
angular_norm = torch.abs(command[:, 2])
inactive = (linear_norm + angular_norm < command_threshold).float()
reward = cost * inactive
return reward
def hip_deviation(
env: ManagerBasedRlEnv,
asset_cfg: SceneEntityCfg | None = None,
) -> torch.Tensor:
"""Penalize hip abduction joints deviating from default."""
if asset_cfg is None:
asset_cfg = SceneEntityCfg("robot", joint_names=(".*_hip_abduction_joint",))
asset: Entity = env.scene[asset_cfg.name]
q = asset.data.joint_pos[:, asset_cfg.joint_ids]
q0 = asset.data.default_joint_pos[:, asset_cfg.joint_ids]
reward = torch.sum(torch.square(q - q0), dim=1)
return reward
def joint_deviation_l2(
env: ManagerBasedRlEnv,
asset_cfg: SceneEntityCfg | None = None,
) -> torch.Tensor:
"""Penalize all specified joints deviating from default."""
if asset_cfg is None:
asset_cfg = SceneEntityCfg("robot", joint_names=(".*_hip_abduction_joint", ".*_hip_pitch_joint", ".*_knee_joint"))
asset: Entity = env.scene[asset_cfg.name]
q = asset.data.joint_pos[:, asset_cfg.joint_ids]
q0 = asset.data.default_joint_pos[:, asset_cfg.joint_ids]
reward = torch.sum(torch.square(q - q0), dim=1)
return reward
def flat_orientation_l2(
env: ManagerBasedRlEnv,
asset_cfg: SceneEntityCfg | None = None,
) -> torch.Tensor:
"""Penalize non-flat base orientation (roll/pitch via projected gravity)."""
if asset_cfg is None:
asset_cfg = SceneEntityCfg("robot")
asset: Entity = env.scene[asset_cfg.name]
reward = torch.sum(torch.square(asset.data.projected_gravity_b[:, :2]), dim=1)
return reward
def lin_vel_z_l2(
env: ManagerBasedRlEnv,
asset_cfg: SceneEntityCfg | None = None,
) -> torch.Tensor:
"""Penalize vertical (z) base linear velocity."""
if asset_cfg is None:
asset_cfg = SceneEntityCfg("robot")
asset: Entity = env.scene[asset_cfg.name]
reward = torch.square(asset.data.root_link_lin_vel_b[:, 2])
return reward
class variable_posture:
"""Reward posture tracking with speed-dependent tolerance."""
def __init__(self, cfg: RewardTermCfg, env: ManagerBasedRlEnv):
asset: Entity = env.scene[cfg.params["asset_cfg"].name]
default_joint_pos = asset.data.default_joint_pos
self.default_joint_pos = default_joint_pos
_, joint_names = asset.find_joints(cfg.params["asset_cfg"].joint_names)
_, _, std_standing = resolve_matching_names_values(
data=cfg.params["std_standing"], list_of_strings=joint_names,
)
self.std_standing = torch.tensor(std_standing, device=env.device, dtype=torch.float32)
_, _, std_walking = resolve_matching_names_values(
data=cfg.params["std_walking"], list_of_strings=joint_names,
)
self.std_walking = torch.tensor(std_walking, device=env.device, dtype=torch.float32)
_, _, std_running = resolve_matching_names_values(
data=cfg.params["std_running"], list_of_strings=joint_names,
)
self.std_running = torch.tensor(std_running, device=env.device, dtype=torch.float32)
def __call__(
self,
env: ManagerBasedRlEnv,
std_standing: dict,
std_walking: dict,
std_running: dict,
asset_cfg: SceneEntityCfg,
command_name: str,
walking_threshold: float = 0.5,
running_threshold: float = 1.5,
) -> torch.Tensor:
del std_standing, std_walking, std_running
asset: Entity = env.scene[asset_cfg.name]
total_speed = _total_command_magnitude(env, command_name)
# 提取身体倾斜度 (重力在 XY 平面的投影)
tilt_xy = torch.norm(asset.data.projected_gravity_b[:, :2], dim=1)
# 如果身体倾斜 > 0.15 (约 8.6 度),说明正在爬墙、越障或已摔倒
is_tilting = (tilt_xy > 0.15).float()
is_running_cmd = (total_speed >= running_threshold).float()
# 【核心越障修改】只要速度极快,或者正在倾斜攀爬,直接赋予最大幅度的动作容忍权 (running_mask)
running_mask = torch.clamp(is_running_cmd + is_tilting, 0.0, 1.0)
# 剩下的平稳且不倾斜的状态,再去根据低速指令判断是 walking 还是 standing
not_running_mask = 1.0 - running_mask
is_walking_cmd = (total_speed >= walking_threshold).float()
walking_mask = not_running_mask * is_walking_cmd
standing_mask = not_running_mask * (1.0 - is_walking_cmd)
std = (
self.std_standing * standing_mask.unsqueeze(1)
+ self.std_walking * walking_mask.unsqueeze(1)
+ self.std_running * running_mask.unsqueeze(1)
)
current_joint_pos = asset.data.joint_pos[:, asset_cfg.joint_ids]
desired_joint_pos = self.default_joint_pos[:, asset_cfg.joint_ids]
error_squared = torch.square(current_joint_pos - desired_joint_pos)
reward = torch.exp(-torch.mean(error_squared / (std**2), dim=1))
return reward
def get_mode_id(env: ManagerBasedRlEnv) -> torch.Tensor:
"""Retrieve current integer mode_id tensor from command_manager.
Safely falls back to zeros (walk mode) if command manager or mode is not initialized.
"""
if not hasattr(env, "command_manager") or env.command_manager is None:
return torch.zeros(env.num_envs, dtype=torch.long, device=env.device)
term = env.command_manager.get_term("mode")
if term is None or not hasattr(term, "get_mode_id"):
return torch.zeros(env.num_envs, dtype=torch.long, device=env.device)
return term.get_mode_id()
def crouch_height_reward(
env: ManagerBasedRlEnv,
target_height: float = 0.22,
std: float = 0.05,
mode_command_name: str = "mode",
asset_cfg: SceneEntityCfg | None = None,
) -> torch.Tensor:
"""Reward the robot for keeping body low under target height when in crouch mode (mode_id == 2).
Uses a single-sided penalty so that going lower than target_height receives full reward.
"""
if asset_cfg is None:
asset_cfg = SceneEntityCfg("robot")
asset: Entity = env.scene[asset_cfg.name]
if hasattr(env.scene, "env_origins") and env.scene.env_origins is not None:
env_origins_z = env.scene.env_origins[:, 2]
else:
env_origins_z = torch.zeros(env.num_envs, device=env.device)
root_z = asset.data.root_link_pos_w[:, 2] - env_origins_z
error = torch.square(torch.clamp(root_z - target_height, min=0.0))
reward = torch.exp(-error / std ** 2)
mode_id = get_mode_id(env)
active = (mode_id == 2).float()
reward = reward * active
return reward
class mode_conditioned_posture:
"""Conditioned posture reward selecting targets/tolerances by active mode_id."""
def __init__(self, cfg: RewardTermCfg, env: ManagerBasedRlEnv) -> None:
asset = env.scene[cfg.params["asset_cfg"].name]
default_joint_pos = asset.data.default_joint_pos
assert default_joint_pos is not None
self.default_joint_pos = default_joint_pos
_, joint_names = asset.find_joints(cfg.params["asset_cfg"].joint_names)
self.joint_ids = asset.find_joints(cfg.params["asset_cfg"].joint_names)[0]
_, _, std_walk = resolve_matching_names_values(
data=cfg.params["std_walk"], list_of_strings=joint_names,
)
self.std_walk = torch.tensor(std_walk, device=env.device, dtype=torch.float32)
if "crouch_pos" in cfg.params:
_, _, crouch_pos_vals = resolve_matching_names_values(
data=cfg.params["crouch_pos"], list_of_strings=joint_names,
)
self.crouch_pos_target = torch.tensor(
crouch_pos_vals, device=env.device, dtype=torch.float32
).unsqueeze(0)
else:
self.crouch_pos_target = None
climb_scale = cfg.params.get("climb_std_scale", 3.0)
crouch_scale = cfg.params.get("crouch_std_scale", 4.0)
self.std_climb = self.std_walk * climb_scale
self.std_crouch = self.std_walk * crouch_scale
def __call__(
self,
env: ManagerBasedRlEnv,
std_walk: dict,
asset_cfg: SceneEntityCfg,
climb_std_scale: float = 3.0,
crouch_std_scale: float = 4.0,
crouch_pos: dict | None = None,
) -> torch.Tensor:
del std_walk, crouch_pos
asset = env.scene[asset_cfg.name]
mode_id = get_mode_id(env).float()
walk_mask = (mode_id == 0).float().unsqueeze(1)
climb_mask = (mode_id == 1).float().unsqueeze(1)
crouch_mask = (mode_id == 2).float().unsqueeze(1)
std = (
self.std_walk * walk_mask
+ self.std_climb * climb_mask
+ self.std_crouch * crouch_mask
)
current_pos = asset.data.joint_pos[:, self.joint_ids]
base_desired_pos = self.default_joint_pos[:, self.joint_ids]
if self.crouch_pos_target is not None:
desired_pos = (
base_desired_pos * (walk_mask + climb_mask)
+ self.crouch_pos_target * crouch_mask
)
else:
desired_pos = base_desired_pos
error_sq = torch.square(current_pos - desired_pos)
reward = torch.exp(-torch.mean(error_sq / (std ** 2 + 1e-8), dim=1))
return reward
def crawl_height_reward(
env: ManagerBasedRlEnv,
target_height: float = 0.22,
std: float = 0.05,
asset_cfg: SceneEntityCfg | None = None,
) -> torch.Tensor:
"""Reward the robot for staying below target crawling height (0.22m).
Uses a single-sided penalty: error is 0 if root_z <= target_height.
"""
if asset_cfg is None:
asset_cfg = SceneEntityCfg("robot")
asset: Entity = env.scene[asset_cfg.name]
if hasattr(env.scene, "env_origins") and env.scene.env_origins is not None:
env_origins_z = env.scene.env_origins[:, 2]
else:
env_origins_z = torch.zeros(env.num_envs, device=env.device)
root_z = asset.data.root_link_pos_w[:, 2] - env_origins_z
error = torch.square(torch.clamp(root_z - target_height, min=0.0))
reward = torch.exp(-error / std ** 2)
return reward
def _get_terrain_levels(env: ManagerBasedRlEnv) -> torch.Tensor | None:
"""Safely fetch current active terrain levels from environment."""
terrain = getattr(env.scene, "terrain", None)
if terrain is not None and hasattr(terrain, "terrain_levels"):
return terrain.terrain_levels
if hasattr(env, "terrain_levels"):
return env.terrain_levels
return None
def terrain_level_bonus(env: ManagerBasedRlEnv) -> torch.Tensor:
"""Positive step bonus normalized to [0, 1] by max terrain level (num_rows-1 = 9).
Normalizing prevents the raw level integer (0~9) from dominating the reward
signal at high difficulty, which would otherwise incentivize level-rushing
over quality locomotion.
"""
levels = _get_terrain_levels(env)
if levels is None:
return torch.zeros(env.num_envs, device=env.device)
return levels.float() / 9.0
def action_rate_curriculum_l2(env: ManagerBasedRlEnv) -> torch.Tensor:
"""Apply action change rate penalty with curriculum-driven scaling.
Penalizes rate changes fully at level 0 (flat) for optimal smoothness,
decaying to 10% penalty at levels 8+ to facilitate explosive dynamic clearing maneuvers.
"""
action_rate = torch.sum(torch.square(env.action_manager.action - env.action_manager.prev_action), dim=1)
levels = _get_terrain_levels(env)
if levels is None:
reward = action_rate
else:
decay = 1.0 - 0.9 * torch.clamp(levels.float() / 8.0, 0.0, 1.0)
reward = action_rate * decay
return reward
def leg_symmetry(
env: ManagerBasedRlEnv,
asset_cfg: SceneEntityCfg | None = None,
) -> torch.Tensor:
"""Penalize asymmetry between left and right legs (pitch and knee joints) robustly."""
asset: Entity = env.scene["robot"]
joint_names = asset.data.joint_names
def find_idx(side, jtype):
for i, name in enumerate(joint_names):
name_lower = name.lower()
if side in name_lower and jtype in name_lower:
return i
# Fallback if not found to avoid crash, though it should find it
return 0
fl_p = find_idx("fl_", "pitch")
fr_p = find_idx("fr_", "pitch")
rl_p = find_idx("rl_", "pitch")
rr_p = find_idx("rr_", "pitch")
fl_k = find_idx("fl_", "knee")
fr_k = find_idx("fr_", "knee")
rl_k = find_idx("rl_", "knee")
rr_k = find_idx("rr_", "knee")
q = asset.data.joint_pos
cost = torch.square(q[:, fl_p] - q[:, fr_p]) + \
torch.square(q[:, rl_p] - q[:, rr_p]) + \
torch.square(q[:, fl_k] - q[:, fr_k]) + \
torch.square(q[:, rl_k] - q[:, rr_k])
return cost
def feet_contact_without_cmd(env, command_name: str, sensor_name: str) -> torch.Tensor:
from mjlab.sensor import ContactSensor
contact_sensor = env.scene[sensor_name]
contact = contact_sensor.data.found > 0
reward = torch.sum(contact, dim=-1).float()
cmd = env.command_manager.get_command(command_name)
linear_norm = torch.norm(cmd[:, :2], dim=1)
angular_norm = torch.abs(cmd[:, 2])
reward *= (linear_norm + angular_norm) < 0.1
return reward
def joint_pos_penalty(
env,
command_name: str,
stand_still_scale: float,
velocity_threshold: float,
command_threshold: float,
asset_cfg: SceneEntityCfg | None = None,
) -> torch.Tensor:
if asset_cfg is None:
asset_cfg = SceneEntityCfg("robot")
asset = env.scene[asset_cfg.name]
cmd_val = env.command_manager.get_command(command_name)
cmd = torch.linalg.norm(cmd_val[:, :2], dim=1) + torch.abs(cmd_val[:, 2])
body_vel = torch.linalg.norm(asset.data.root_link_lin_vel_b[:, :2], dim=1)
running_reward = torch.linalg.norm(
(asset.data.joint_pos[:, asset_cfg.joint_ids] - asset.data.default_joint_pos[:, asset_cfg.joint_ids]), dim=1
)
reward = torch.where(
torch.logical_or(cmd > command_threshold, body_vel > velocity_threshold),
running_reward,
stand_still_scale * running_reward,
)
return reward
def joint_mirror(env, mirror_joints: list[list[str]], asset_cfg: SceneEntityCfg | None = None) -> torch.Tensor:
if asset_cfg is None:
asset_cfg = SceneEntityCfg("robot")
asset = env.scene[asset_cfg.name]
if not hasattr(env, "joint_mirror_joints_cache") or env.joint_mirror_joints_cache is None:
env.joint_mirror_joints_cache = []
for joint_pair in mirror_joints:
p0 = asset.find_joints(joint_pair[0])[0]
p1 = asset.find_joints(joint_pair[1])[0]
env.joint_mirror_joints_cache.append([p0, p1])
reward = torch.zeros(env.num_envs, device=env.device)
for joint_pair in env.joint_mirror_joints_cache:
diff = torch.sum(
torch.square(asset.data.joint_pos[:, joint_pair[0]] - asset.data.joint_pos[:, joint_pair[1]]),
dim=-1,
)
reward += diff
reward *= 1 / len(mirror_joints) if len(mirror_joints) > 0 else 0
return reward
def upward(env, asset_cfg=None):
if asset_cfg is None:
asset_cfg = SceneEntityCfg('robot')
asset = env.scene[asset_cfg.name]
reward = torch.square(1 - asset.data.projected_gravity_b[:, 2])
return reward
def upright_roll_only(env, asset_cfg=None):
if asset_cfg is None:
from mjlab.envs.manager_based_rl_env import SceneEntityCfg
asset_cfg = SceneEntityCfg('robot')
asset = env.scene[asset_cfg.name]
reward = torch.square(asset.data.projected_gravity_b[:, 1])
return reward
def track_linear_velocity_l1(env, std, command_name):
asset = env.scene['robot']
cmd = env.command_manager.get_command(command_name)
lin_vel_error = torch.linalg.norm(asset.data.root_link_lin_vel_b[:, :2] - cmd[:, :2], dim=1)
reward = torch.exp(-lin_vel_error / std)
return reward
def pitch_control_penalty(env, max_pitch_rad: float = 0.50, asset_cfg=None) -> torch.Tensor:
"""Penalize excessive pitch orientation beyond a safe threshold.
Allows pitch angles up to max_pitch_rad (e.g. 29 degrees) for climbing obstacles,
but quadratically penalizes any pitch angle exceeding this threshold (e.g. during flinging).
"""
import math
if asset_cfg is None:
from mjlab.envs.manager_based_rl_env import SceneEntityCfg
asset_cfg = SceneEntityCfg('robot')
asset = env.scene[asset_cfg.name]
g_x = asset.data.projected_gravity_b[:, 0]
g_x_threshold = math.sin(max_pitch_rad)
excessive_pitch = torch.clamp(torch.abs(g_x) - g_x_threshold, min=0.0)
reward = torch.square(excessive_pitch)
return reward
@@ -0,0 +1,41 @@
# SPDX-FileCopyrightText: Copyright (c) 2021 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: BSD-3-Clause
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this
# list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its
# contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
# Copyright (c) 2021 ETH Zurich, Nikita Rudin
"""
HimLoco RSL-RL implementation with history-informed models.
"""
# Export HIM implementations
from .algorithms.him_ppo import *
from .modules.him_actor_critic import *
from .modules.him_estimator import *
from .storage.him_rollout_storage import *
from .runners.him_on_policy_runner import *
from .wrappers import *
@@ -0,0 +1,31 @@
# SPDX-FileCopyrightText: Copyright (c) 2021 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: BSD-3-Clause
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this
# list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its
# contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
# Copyright (c) 2021 ETH Zurich, Nikita Rudin
from .him_ppo import HIMPPO
@@ -0,0 +1,192 @@
# SPDX-FileCopyrightText: Copyright (c) 2021 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: BSD-3-Clause
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this
# list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its
# contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
# Copyright (c) 2021 ETH Zurich, Nikita Rudin
import torch
import torch.nn as nn
import torch.optim as optim
from ..modules import HIMActorCritic
from ..storage import HIMRolloutStorage
class HIMPPO:
actor_critic: HIMActorCritic
def __init__(self,
actor_critic,
num_learning_epochs=1,
num_mini_batches=1,
clip_param=0.2,
gamma=0.998,
lam=0.95,
value_loss_coef=1.0,
entropy_coef=0.0,
learning_rate=1e-3,
max_grad_norm=1.0,
use_clipped_value_loss=True,
schedule="fixed",
desired_kl=0.01,
device='cpu',
):
self.device = device
self.desired_kl = desired_kl
self.schedule = schedule
self.learning_rate = learning_rate
# PPO components
self.actor_critic = actor_critic
self.actor_critic.to(self.device)
self.storage = None # initialized later
self.optimizer = optim.Adam(self.actor_critic.parameters(), lr=learning_rate)
self.transition = HIMRolloutStorage.Transition()
# PPO parameters
self.clip_param = clip_param
self.num_learning_epochs = num_learning_epochs
self.num_mini_batches = num_mini_batches
self.value_loss_coef = value_loss_coef
self.entropy_coef = entropy_coef
self.gamma = gamma
self.lam = lam
self.max_grad_norm = max_grad_norm
self.use_clipped_value_loss = use_clipped_value_loss
def init_storage(self, num_envs, num_transitions_per_env, actor_obs_shape, critic_obs_shape, action_shape):
self.storage = HIMRolloutStorage(num_envs, num_transitions_per_env, actor_obs_shape, critic_obs_shape, action_shape, self.device)
def test_mode(self):
self.actor_critic.test()
def train_mode(self):
self.actor_critic.train()
def act(self, obs, critic_obs):
# Compute the actions and values
self.transition.actions = self.actor_critic.act(obs).detach()
self.transition.values = self.actor_critic.evaluate(critic_obs).detach()
self.transition.actions_log_prob = self.actor_critic.get_actions_log_prob(self.transition.actions).detach()
self.transition.action_mean = self.actor_critic.action_mean.detach()
self.transition.action_sigma = self.actor_critic.action_std.detach()
# need to record obs and critic_obs before env.step()
self.transition.observations = obs
self.transition.critic_observations = critic_obs
return self.transition.actions
def process_env_step(self, rewards, dones, infos, next_critic_obs):
self.transition.next_critic_observations = next_critic_obs.clone()
self.transition.rewards = rewards.clone()
self.transition.dones = dones
# Bootstrapping on time outs
if 'time_outs' in infos:
self.transition.rewards += self.gamma * torch.squeeze(self.transition.values * infos['time_outs'].unsqueeze(1).to(self.device), 1)
# Record the transition
self.storage.add_transitions(self.transition)
self.transition.clear()
self.actor_critic.reset(dones)
def compute_returns(self, last_critic_obs):
last_values= self.actor_critic.evaluate(last_critic_obs).detach()
self.storage.compute_returns(last_values, self.gamma, self.lam)
def update(self):
mean_value_loss = 0
mean_surrogate_loss = 0
mean_estimation_loss = 0
mean_swap_loss = 0
generator = self.storage.mini_batch_generator(self.num_mini_batches, self.num_learning_epochs)
for obs_batch, critic_obs_batch, actions_batch, next_critic_obs_batch, target_values_batch, advantages_batch, returns_batch, old_actions_log_prob_batch, \
old_mu_batch, old_sigma_batch in generator:
self.actor_critic.act(obs_batch)
actions_log_prob_batch = self.actor_critic.get_actions_log_prob(actions_batch)
value_batch = self.actor_critic.evaluate(critic_obs_batch)
mu_batch = self.actor_critic.action_mean
sigma_batch = self.actor_critic.action_std
entropy_batch = self.actor_critic.entropy
# KL
if self.desired_kl != None and self.schedule == 'adaptive':
with torch.inference_mode():
kl = torch.sum(
torch.log(sigma_batch / old_sigma_batch + 1.e-5) + (torch.square(old_sigma_batch) + torch.square(old_mu_batch - mu_batch)) / (2.0 * torch.square(sigma_batch)) - 0.5, axis=-1)
kl_mean = torch.mean(kl)
if kl_mean > self.desired_kl * 2.0:
self.learning_rate = max(1e-5, self.learning_rate / 1.5)
elif kl_mean < self.desired_kl / 2.0 and kl_mean > 0.0:
self.learning_rate = min(1e-2, self.learning_rate * 1.5)
for param_group in self.optimizer.param_groups:
param_group['lr'] = self.learning_rate
#Estimator Update
estimation_loss, swap_loss = self.actor_critic.estimator.update(obs_batch, next_critic_obs_batch, lr=self.learning_rate)
# Surrogate loss
ratio = torch.exp(actions_log_prob_batch - torch.squeeze(old_actions_log_prob_batch))
surrogate = -torch.squeeze(advantages_batch) * ratio
surrogate_clipped = -torch.squeeze(advantages_batch) * torch.clamp(ratio, 1.0 - self.clip_param,
1.0 + self.clip_param)
surrogate_loss = torch.max(surrogate, surrogate_clipped).mean()
# Value function loss
if self.use_clipped_value_loss:
value_clipped = target_values_batch + (value_batch - target_values_batch).clamp(-self.clip_param,
self.clip_param)
value_losses = (value_batch - returns_batch).pow(2)
value_losses_clipped = (value_clipped - returns_batch).pow(2)
value_loss = torch.max(value_losses, value_losses_clipped).mean()
else:
value_loss = (returns_batch - value_batch).pow(2).mean()
loss = surrogate_loss + self.value_loss_coef * value_loss - self.entropy_coef * entropy_batch.mean()
# Gradient step
self.optimizer.zero_grad()
loss.backward()
nn.utils.clip_grad_norm_(self.actor_critic.parameters(), self.max_grad_norm)
self.optimizer.step()
mean_value_loss += value_loss.item()
mean_surrogate_loss += surrogate_loss.item()
mean_estimation_loss += estimation_loss
mean_swap_loss += swap_loss
num_updates = self.num_learning_epochs * self.num_mini_batches
mean_value_loss /= num_updates
mean_surrogate_loss /= num_updates
mean_estimation_loss /= num_updates
mean_swap_loss /= num_updates
self.storage.clear()
return mean_value_loss, mean_surrogate_loss, estimation_loss, swap_loss
@@ -0,0 +1,31 @@
# SPDX-FileCopyrightText: Copyright (c) 2021 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: BSD-3-Clause
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this
# list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its
# contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
# Copyright (c) 2021 ETH Zurich, Nikita Rudin
from .rl_cfg import *
@@ -0,0 +1,180 @@
from dataclasses import MISSING
from isaaclab.utils import configclass
from typing import Literal
@configclass
class HIMBaseRunnerCfg:
"""Base configuration of the runner."""
seed: int = 1
"""The seed for the experiment. Default is 1."""
device: str = "cuda:0"
"""The device for the rl-agent. Default is cuda:0."""
num_steps_per_env: int = MISSING
"""The number of steps per environment per update."""
max_iterations: int = MISSING
"""The maximum number of iterations."""
empirical_normalization: bool | None = None
"""This parameter is deprecated and will be removed in the future.
Use `actor_obs_normalization` and `critic_obs_normalization` instead.
"""
# obs_groups: dict[str, list[str]] = MISSING
# """A mapping from observation groups to observation sets.
# The keys of the dictionary are predefined observation sets used by the underlying algorithm
# and values are lists of observation groups provided by the environment.
# For instance, if the environment provides a dictionary of observations with groups "policy", "images",
# and "privileged", these can be mapped to algorithmic observation sets as follows:
# .. code-block:: python
# obs_groups = {
# "policy": ["policy", "images"],
# "critic": ["policy", "privileged"],
# }
# This way, the policy will receive the "policy" and "images" observations, and the critic will
# receive the "policy" and "privileged" observations.
# For more details, please check ``vec_env.py`` in the rsl_rl library.
# """
# clip_actions: float | None = None
# """The clipping value for actions. If None, then no clipping is done. Defaults to None.
# .. note::
# This clipping is performed inside the :class:`RslRlVecEnvWrapper` wrapper.
# """
save_interval: int = MISSING
"""The number of iterations between saves."""
experiment_name: str = MISSING
"""The experiment name."""
run_name: str = ""
"""The run name. Default is empty string.
The name of the run directory is typically the time-stamp at execution. If the run name is not empty,
then it is appended to the run directory's name, i.e. the logging directory's name will become
``{time-stamp}_{run_name}``.
"""
logger: Literal["tensorboard", "neptune", "wandb"] = "tensorboard"
"""The logger to use. Default is tensorboard."""
neptune_project: str = "isaaclab"
"""The neptune project name. Default is "isaaclab"."""
wandb_project: str = "isaaclab"
"""The wandb project name. Default is "isaaclab"."""
resume: bool = False
"""Whether to resume a previous training. Default is False.
This flag will be ignored for distillation.
"""
load_run: str = ".*"
"""The run directory to load. Default is ".*" (all).
If regex expression, the latest (alphabetical order) matching run will be loaded.
"""
load_checkpoint: str = "model_.*.pt"
"""The checkpoint file to load. Default is ``"model_.*.pt"`` (all).
If regex expression, the latest (alphabetical order) matching file will be loaded.
"""
@configclass
class HIMPPOActorCriticCfg:
"""Configuration of the HIM PPO actor-critic."""
actor_hidden_dims: list[int] = [512, 256, 128]
"""The hidden dimensions of the actor network."""
critic_hidden_dims: list[int] = [512, 256, 128]
"""The hidden dimensions of the critic network."""
activation: str = "elu"
"""The activation function to use. Default is 'elu'."""
init_noise_std: float = 1.0
"""The initial noise standard deviation for the actor. Default is 1.0."""
normalize_obs: bool = False
"""Whether to normalize observations. Default is False."""
@configclass
class HIMPPPOAlgorithmCfg:
"""Configuration of the HIM PPO algorithm."""
num_learning_epochs: int = 1
"""The number of learning epochs per update. Default is 1."""
num_mini_batches: int = 1
"""The number of mini-batches per update. Default is 1."""
clip_param: float = 0.2
"""The clipping parameter for PPO. Default is 0.2."""
gamma: float = 0.998
"""The discount factor. Default is 0.998."""
lam: float = 0.95
"""The GAE lambda parameter. Default is 0.95."""
value_loss_coef: float = 1.0
"""The coefficient for the value loss. Default is 1.0."""
entropy_coef: float = 0.0
"""The coefficient for the entropy bonus. Default is 0.0."""
learning_rate: float = 1.0e-3
"""The learning rate. Default is 1.0e-3."""
max_grad_norm: float = 1.0
"""The maximum gradient norm for clipping. Default is 1.0."""
use_clipped_value_loss: bool = True
"""Whether to use clipped value loss. Default is True."""
schedule: str = "fixed"
"""The learning rate schedule. Default is 'fixed'."""
desired_kl: float = 0.01
"""The desired KL divergence for adaptive learning rate. Default is 0.01."""
@configclass
class HIMOnPolicyRunnerCfg(HIMBaseRunnerCfg):
"""Configuration of the runner for on-policy algorithms."""
class_name: str = "HIMOnPolicyRunner"
"""The runner class name. Default is OnPolicyRunner."""
policy_class_name: str = "HIMActorCritic"
"""The policy class name. Default is HIMActorCritic."""
algorithm_class_name: str = "HIMPPO"
"""The algorithm class name. Default is HIMPPO."""
policy: HIMPPOActorCriticCfg = MISSING
"""The policy configuration."""
algorithm: HIMPPPOAlgorithmCfg = MISSING
"""The algorithm configuration."""
history_length: int = 0
"""Number of historical time steps to stack with current observation (0 means current only). Default is 0."""
privileged_history_length: int = 0
"""Number of historical time steps to stack with current privileged observation. Default is 0."""
@@ -0,0 +1,31 @@
# SPDX-FileCopyrightText: Copyright (c) 2021 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: BSD-3-Clause
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this
# list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its
# contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
# Copyright (c) 2021 ETH Zurich, Nikita Rudin
from .vec_env import VecEnv
@@ -0,0 +1,60 @@
# SPDX-FileCopyrightText: Copyright (c) 2021 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: BSD-3-Clause
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this
# list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its
# contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
# Copyright (c) 2021 ETH Zurich, Nikita Rudin
from abc import ABC, abstractmethod
import torch
from typing import Tuple, Union
# minimal interface of the environment
class VecEnv(ABC):
num_envs: int
num_obs: int
num_privileged_obs: int
num_actions: int
max_episode_length: int
privileged_obs_buf: torch.Tensor
obs_buf: torch.Tensor
rew_buf: torch.Tensor
reset_buf: torch.Tensor
episode_length_buf: torch.Tensor # current episode duration
extras: dict
device: torch.device
@abstractmethod
def step(self, actions: torch.Tensor) -> Tuple[torch.Tensor, Union[torch.Tensor, None], torch.Tensor, torch.Tensor, dict]:
pass
@abstractmethod
def reset(self, env_ids: Union[list, torch.Tensor]):
pass
@abstractmethod
def get_observations(self) -> torch.Tensor:
pass
@abstractmethod
def get_privileged_observations(self) -> Union[torch.Tensor, None]:
pass
@@ -0,0 +1,32 @@
# SPDX-FileCopyrightText: Copyright (c) 2021 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: BSD-3-Clause
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this
# list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its
# contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
# Copyright (c) 2021 ETH Zurich, Nikita Rudin
from .him_actor_critic import HIMActorCritic
from .him_estimator import HIMEstimator
@@ -0,0 +1,236 @@
# SPDX-FileCopyrightText: Copyright (c) 2021 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: BSD-3-Clause
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this
# list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its
# contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
# Copyright (c) 2021 ETH Zurich, Nikita Rudin
import numpy as np
import torch
import torch.nn as nn
from torch.distributions import Normal
from ..modules.him_estimator import HIMEstimator
class RunningMeanStd:
# Dynamically calculate mean and std
def __init__(self, shape, device): # shape:the dimension of input data
self.n = 1e-4
self.uninitialized = True
self.mean = torch.zeros(shape, device=device)
self.var = torch.ones(shape, device=device)
def update(self, x):
count = self.n
batch_count = x.size(0)
tot_count = count + batch_count
old_mean = self.mean.clone()
delta = torch.mean(x, dim=0) - old_mean
self.mean = old_mean + delta * batch_count / tot_count
m_a = self.var * count
m_b = x.var(dim=0) * batch_count
M2 = m_a + m_b + torch.square(delta) * count * batch_count / tot_count
self.var = M2 / tot_count
self.n = tot_count
class Normalization:
def __init__(self, shape, device='cuda:0'):
self.running_ms = RunningMeanStd(shape=shape, device=device)
def __call__(self, x, update=False):
# Whether to update the mean and std,during the evaluating,update=Flase
if update:
self.running_ms.update(x)
x = (x - self.running_ms.mean) / (torch.sqrt(self.running_ms.var) + 1e-4)
return x
class HIMActorCritic(nn.Module):
is_recurrent = False
def __init__(self, num_actor_obs,
num_critic_obs,
num_one_step_obs,
num_actions,
actor_hidden_dims=[512, 256, 128],
critic_hidden_dims=[512, 256, 128],
activation='elu',
init_noise_std=1.0,
**kwargs):
if kwargs:
print("ActorCritic.__init__ got unexpected arguments, which will be ignored: " + str([key for key in kwargs.keys()]))
super(HIMActorCritic, self).__init__()
activation = get_activation(activation)
self.history_size = int(num_actor_obs/num_one_step_obs)
self.num_actor_obs = num_actor_obs
self.num_actions = num_actions
self.num_one_step_obs = num_one_step_obs
mlp_input_dim_a = num_one_step_obs + 3 + 16
mlp_input_dim_c = num_critic_obs
# Estimator
self.estimator = HIMEstimator(temporal_steps=self.history_size, num_one_step_obs=num_one_step_obs)
# Policy
actor_layers = []
actor_layers.append(nn.Linear(mlp_input_dim_a, actor_hidden_dims[0]))
actor_layers.append(activation)
for l in range(len(actor_hidden_dims)):
if l == len(actor_hidden_dims) - 1:
actor_layers.append(nn.Linear(actor_hidden_dims[l], num_actions))
# actor_layers.append(nn.Tanh())
else:
actor_layers.append(nn.Linear(actor_hidden_dims[l], actor_hidden_dims[l + 1]))
actor_layers.append(activation)
self.actor = nn.Sequential(*actor_layers)
# Value function
critic_layers = []
critic_layers.append(nn.Linear(mlp_input_dim_c, critic_hidden_dims[0]))
critic_layers.append(activation)
for l in range(len(critic_hidden_dims)):
if l == len(critic_hidden_dims) - 1:
critic_layers.append(nn.Linear(critic_hidden_dims[l], 1))
else:
critic_layers.append(nn.Linear(critic_hidden_dims[l], critic_hidden_dims[l + 1]))
critic_layers.append(activation)
self.critic = nn.Sequential(*critic_layers)
print(f"Actor MLP: {self.actor}")
print(f"Critic MLP: {self.critic}")
print(f'Estimator: {self.estimator.encoder}')
# Action noise
self.std = nn.Parameter(init_noise_std * torch.ones(num_actions))
self.distribution = None
# disable args validation for speedup
Normal.set_default_validate_args = False
# seems that we get better performance without init
# self.init_memory_weights(self.memory_a, 0.001, 0.)
# self.init_memory_weights(self.memory_c, 0.001, 0.)
@staticmethod
# not used at the moment
def init_weights(sequential, scales):
[torch.nn.init.orthogonal_(module.weight, gain=scales[idx]) for idx, module in
enumerate(mod for mod in sequential if isinstance(mod, nn.Linear))]
def reset(self, dones=None):
pass
def forward(self):
raise NotImplementedError
@property
def action_mean(self):
return self.distribution.mean
@property
def action_std(self):
return self.distribution.stddev
@property
def entropy(self):
return self.distribution.entropy().sum(dim=-1)
def update_distribution(self, obs_history):
with torch.no_grad():
vel, latent = self.estimator(obs_history)
actor_input = torch.cat((obs_history[:,:self.num_one_step_obs], vel, latent), dim=-1)
# Check for NaN/Inf in inputs
if torch.isnan(actor_input).any() or torch.isinf(actor_input).any():
print(f"[ERROR] NaN/Inf detected in actor_input before normalization!")
print(f" - obs_history stats: min={obs_history.min():.4f}, max={obs_history.max():.4f}, mean={obs_history.mean():.4f}, has_nan={torch.isnan(obs_history).any()}")
print(f" - vel stats: min={vel.min():.4f}, max={vel.max():.4f}, mean={vel.mean():.4f}, has_nan={torch.isnan(vel).any()}")
print(f" - latent stats: min={latent.min():.4f}, max={latent.max():.4f}, mean={latent.mean():.4f}, has_nan={torch.isnan(latent).any()}")
raise ValueError("NaN/Inf in actor_input before normalization")
mean = self.actor(actor_input)
# Check for NaN/Inf in actor output
if torch.isnan(mean).any() or torch.isinf(mean).any():
print(f"[ERROR] NaN/Inf in actor output (mean)!")
print(f" - actor_input stats: min={actor_input.min():.4f}, max={actor_input.max():.4f}, mean={actor_input.mean():.4f}")
print(f" - Batch size: {actor_input.shape[0]}")
# Check actor network weights AFTER forward (they should still be OK)
for name, param in self.actor.named_parameters():
if torch.isnan(param).any():
print(f" - NaN in actor weight: {name}")
if torch.isinf(param).any():
print(f" - Inf in actor weight: {name}")
raise ValueError("NaN or Inf detected in actor network output!")
self.distribution = Normal(mean, mean*0. + self.std)
def act(self, obs_history=None, **kwargs):
self.update_distribution(obs_history)
return self.distribution.sample()
def get_actions_log_prob(self, actions):
return self.distribution.log_prob(actions).sum(dim=-1)
def act_inference(self, obs_history, observations=None):
vel, latent = self.estimator(obs_history)
actions_mean = self.actor(torch.cat((obs_history[:,:self.num_one_step_obs], vel, latent), dim=-1))
return actions_mean
def test_inference(self, obs_history, observations=None):
vel, latent = self.estimator(obs_history)
actions_mean = self.actor(torch.cat((obs_history[:,:self.num_one_step_obs], vel, latent), dim=-1))
estimator_output = torch.cat((vel, latent), dim=-1)
return actions_mean, estimator_output
def evaluate(self, critic_observations, **kwargs):
value = self.critic(critic_observations)
return value
def get_activation(act_name):
if act_name == "elu":
return nn.ELU()
elif act_name == "selu":
return nn.SELU()
elif act_name == "relu":
return nn.ReLU()
elif act_name == "crelu":
return nn.ReLU()
elif act_name == "lrelu":
return nn.LeakyReLU()
elif act_name == "tanh":
return nn.Tanh()
elif act_name == "sigmoid":
return nn.Sigmoid()
else:
print("invalid activation function!")
return None
@@ -0,0 +1,155 @@
import copy
import math
import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F
import torch.distributions as torchd
from torch.distributions import Normal, Categorical
class HIMEstimator(nn.Module):
def __init__(self,
temporal_steps,
num_one_step_obs,
enc_hidden_dims=[128, 64, 16],
tar_hidden_dims=[128, 64],
activation='elu',
learning_rate=1e-3,
max_grad_norm=10.0,
num_prototype=32,
temperature=3.0,
**kwargs):
if kwargs:
print("Estimator_CL.__init__ got unexpected arguments, which will be ignored: " + str(
[key for key in kwargs.keys()]))
super(HIMEstimator, self).__init__()
activation = get_activation(activation)
self.temporal_steps = temporal_steps
self.num_one_step_obs = num_one_step_obs
self.num_latent = enc_hidden_dims[-1]
self.max_grad_norm = max_grad_norm
self.temperature = temperature
# Encoder
enc_input_dim = self.temporal_steps * self.num_one_step_obs
enc_layers = []
for l in range(len(enc_hidden_dims) - 1):
enc_layers += [nn.Linear(enc_input_dim, enc_hidden_dims[l]), activation]
enc_input_dim = enc_hidden_dims[l]
enc_layers += [nn.Linear(enc_input_dim, enc_hidden_dims[-1] + 3)]
self.encoder = nn.Sequential(*enc_layers)
# Target
tar_input_dim = self.num_one_step_obs
tar_layers = []
for l in range(len(tar_hidden_dims)):
tar_layers += [nn.Linear(tar_input_dim, tar_hidden_dims[l]), activation]
tar_input_dim = tar_hidden_dims[l]
tar_layers += [nn.Linear(tar_input_dim, enc_hidden_dims[-1])]
self.target = nn.Sequential(*tar_layers)
# Prototype
self.proto = nn.Embedding(num_prototype, enc_hidden_dims[-1])
# Optimizer
self.learning_rate = learning_rate
self.optimizer = optim.Adam(self.parameters(), lr=self.learning_rate)
def get_latent(self, obs_history):
vel, z = self.encode(obs_history)
return vel.detach(), z.detach()
def forward(self, obs_history):
parts = self.encoder(obs_history.detach())
vel, z = parts[..., :3], parts[..., 3:]
z = F.normalize(z, dim=-1, p=2)
return vel.detach(), z.detach()
def encode(self, obs_history):
parts = self.encoder(obs_history.detach())
vel, z = parts[..., :3], parts[..., 3:]
z = F.normalize(z, dim=-1, p=2)
return vel, z
def update(self, obs_history, next_critic_obs, lr=None):
if lr is not None:
self.learning_rate = lr
for param_group in self.optimizer.param_groups:
param_group['lr'] = self.learning_rate
vel = next_critic_obs[:, self.num_one_step_obs:self.num_one_step_obs+3].detach()
next_obs = next_critic_obs.detach()[:, 3:self.num_one_step_obs+3]
z_s = self.encoder(obs_history)
z_t = self.target(next_obs)
pred_vel, z_s = z_s[..., :3], z_s[..., 3:]
z_s = F.normalize(z_s, dim=-1, p=2)
z_t = F.normalize(z_t, dim=-1, p=2)
with torch.no_grad():
w = self.proto.weight.data.clone()
w = F.normalize(w, dim=-1, p=2)
self.proto.weight.copy_(w)
score_s = z_s @ self.proto.weight.T
score_t = z_t @ self.proto.weight.T
with torch.no_grad():
q_s = sinkhorn(score_s)
q_t = sinkhorn(score_t)
log_p_s = F.log_softmax(score_s / self.temperature, dim=-1)
log_p_t = F.log_softmax(score_t / self.temperature, dim=-1)
swap_loss = -0.5 * (q_s * log_p_t + q_t * log_p_s).mean()
estimation_loss = F.mse_loss(pred_vel, vel)
losses = estimation_loss + swap_loss
self.optimizer.zero_grad()
losses.backward()
nn.utils.clip_grad_norm_(self.parameters(), self.max_grad_norm)
self.optimizer.step()
return estimation_loss.item(), swap_loss.item()
@torch.no_grad()
def sinkhorn(out, eps=0.05, iters=3):
Q = torch.exp(out / eps).T
K, B = Q.shape[0], Q.shape[1]
Q /= Q.sum()
for it in range(iters):
# normalize each row: total weight per prototype must be 1/K
Q /= torch.sum(Q, dim=1, keepdim=True)
Q /= K
# normalize each column: total weight per sample must be 1/B
Q /= torch.sum(Q, dim=0, keepdim=True)
Q /= B
return (Q * B).T
def get_activation(act_name):
if act_name == "elu":
return nn.ELU()
elif act_name == "selu":
return nn.SELU()
elif act_name == "relu":
return nn.ReLU()
elif act_name == "crelu":
return nn.ReLU()
elif act_name == "silu":
return nn.SiLU()
elif act_name == "lrelu":
return nn.LeakyReLU()
elif act_name == "tanh":
return nn.Tanh()
elif act_name == "sigmoid":
return nn.Sigmoid()
else:
print("invalid activation function!")
return None
@@ -0,0 +1,31 @@
# SPDX-FileCopyrightText: Copyright (c) 2021 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: BSD-3-Clause
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this
# list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its
# contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
# Copyright (c) 2021 ETH Zurich, Nikita Rudin
from .him_on_policy_runner import HIMOnPolicyRunner
@@ -0,0 +1,362 @@
# SPDX-FileCopyrightText: Copyright (c) 2021 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: BSD-3-Clause
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this
# list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its
# contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
# Copyright (c) 2021 ETH Zurich, Nikita Rudin
import time
import os
from collections import deque
import statistics
import torch
from ..algorithms import HIMPPO
from ..modules import HIMActorCritic
from ..env import VecEnv
class HIMOnPolicyRunner:
def __init__(self,
env: VecEnv,
train_cfg,
log_dir=None,
device='cpu'):
"""Initialize HimLoco on-policy runner.
Args:
env: Vectorized environment.
train_cfg: Training configuration dictionary.
log_dir: Directory for logging.
device: Device to run on.
"""
# Store configuration
self.cfg = train_cfg # Full training config (for logging compatibility)
self.alg_cfg = train_cfg["algorithm"]
self.policy_cfg = train_cfg["policy"]
self.device = device
self.env = env
# Determine observation dimensions
if self.env.num_privileged_obs is not None:
num_critic_obs = self.env.num_privileged_obs
else:
num_critic_obs = self.env.num_obs
self.num_actor_obs = self.env.num_obs
self.num_critic_obs = num_critic_obs
# Initialize policy network
actor_critic_class = eval(train_cfg["policy_class_name"]) # HIMActorCritic
actor_critic: HIMActorCritic = actor_critic_class(
self.env.num_obs, # historical obs
num_critic_obs, # historical privileged obs
self.env.num_one_step_obs,
self.env.num_actions,
**self.policy_cfg
).to(self.device)
# Initialize algorithm
alg_class = eval(train_cfg["algorithm_class_name"]) # HIMPPO
self.alg: HIMPPO = alg_class(actor_critic, device=self.device, **self.alg_cfg)
# Training configuration
self.num_steps_per_env = train_cfg["num_steps_per_env"]
self.save_interval = train_cfg["save_interval"]
# Initialize storage and model
self.alg.init_storage(
self.env.num_envs,
self.num_steps_per_env,
[self.env.num_obs],
[self.env.num_privileged_obs],
[self.env.num_actions]
)
# Logging
self.log_dir = log_dir
self.writer = None
self.tot_timesteps = 0
self.tot_time = 0
self.current_learning_iteration = 0
self.logger_type = self.cfg["logger"].lower()
# _, _ = self.env.reset() we call this in wrapper
def learn(self, num_learning_iterations, init_at_random_ep_len=False):
"""Train the policy using HimLoco PPO algorithm.
Args:
num_learning_iterations: Number of policy updates.
init_at_random_ep_len: Whether to randomize initial episode lengths.
"""
# Initialize writer (support multiple logger types like RSL-RL)
if self.log_dir is not None and self.writer is None:
# Launch either Tensorboard, WandB, or Neptune summary writer(s)
if self.logger_type == "neptune":
from rsl_rl.utils.neptune_utils import NeptuneSummaryWriter
self.writer = NeptuneSummaryWriter(log_dir=self.log_dir, flush_secs=10, cfg=self.alg_cfg)
# TODO: Add config logging support
# self.writer.log_config(self.env.cfg, train_cfg, self.alg_cfg, self.policy_cfg)
elif self.logger_type == "wandb":
from rsl_rl.utils.wandb_utils import WandbSummaryWriter
self.writer = WandbSummaryWriter(log_dir=self.log_dir, flush_secs=10, cfg=self.alg_cfg)
# TODO: Add config logging support
elif self.logger_type == "tensorboard":
from torch.utils.tensorboard import SummaryWriter
self.writer = SummaryWriter(log_dir=self.log_dir, flush_secs=10)
else:
raise ValueError("Logger type not found. Please choose 'neptune', 'wandb' or 'tensorboard'.")
if init_at_random_ep_len:
self.env.episode_length_buf = torch.randint_like(self.env.episode_length_buf, high=int(self.env.max_episode_length))
obs = self.env.get_observations()
privileged_obs = self.env.get_privileged_observations()
critic_obs = privileged_obs if privileged_obs is not None else obs
obs, critic_obs = obs.to(self.device), critic_obs.to(self.device)
self.alg.actor_critic.train() # switch to train mode (for dropout for example)
ep_infos = []
rewbuffer = deque(maxlen=100)
lenbuffer = deque(maxlen=100)
cur_reward_sum = torch.zeros(self.env.num_envs, dtype=torch.float, device=self.device)
cur_episode_length = torch.zeros(self.env.num_envs, dtype=torch.float, device=self.device)
start_iter = self.current_learning_iteration
tot_iter = start_iter + num_learning_iterations
for it in range(start_iter, tot_iter):
start = time.time()
# Rollout
with torch.inference_mode():
for i in range(self.num_steps_per_env):
actions = self.alg.act(obs, critic_obs)
obs, privileged_obs, rewards, dones, infos, termination_ids, termination_privileged_obs = self.env.step(actions)
critic_obs = privileged_obs if privileged_obs is not None else obs
obs, critic_obs, rewards, dones = obs.to(self.device), critic_obs.to(self.device), rewards.to(self.device), dones.to(self.device)
termination_ids = termination_ids.to(self.device)
termination_privileged_obs = termination_privileged_obs.to(self.device)
next_critic_obs = critic_obs.clone().detach()
next_critic_obs[termination_ids] = termination_privileged_obs.clone().detach()
self.alg.process_env_step(rewards, dones, infos, next_critic_obs)
# print("+++++++++++++++++++++++++")
# print(obs[0])
# print(next_critic_obs[0, :self.env.num_one_step_obs])
# print("+++++++++++++++++++++++++")
if self.log_dir is not None:
# Book keeping
if 'episode' in infos:
ep_infos.append(infos['episode'])
elif 'log' in infos:
ep_infos.append(infos['log'])
# Update rewards
cur_reward_sum += rewards
# Update episode length
cur_episode_length += 1
# Clear data for completed episodes
new_ids = (dones > 0).nonzero(as_tuple=False)
rewbuffer.extend(cur_reward_sum[new_ids][:, 0].cpu().numpy().tolist())
lenbuffer.extend(cur_episode_length[new_ids][:, 0].cpu().numpy().tolist())
cur_reward_sum[new_ids] = 0
cur_episode_length[new_ids] = 0
stop = time.time()
collection_time = stop - start
# Learning step
start = stop
self.alg.compute_returns(critic_obs)
mean_value_loss, mean_surrogate_loss, mean_estimation_loss, mean_swap_loss = self.alg.update()
stop = time.time()
learn_time = stop - start
self.current_learning_iteration = it
# log info
if self.log_dir is not None:
self.log(locals())
# Save model
if it % self.save_interval == 0:
self.save(os.path.join(self.log_dir, 'model_{}.pt'.format(it)))
# Clear episode infos
ep_infos.clear()
# Save the final model after training
if self.log_dir is not None:
self.save(os.path.join(self.log_dir, 'model_{}.pt'.format(self.current_learning_iteration)))
def log(self, locs, width=80, pad=35):
"""Log training information to console and TensorBoard.
Enhanced version compatible with RSL-RL style logging, supporting:
- Dynamic episode info processing
- Loss dictionary iteration
- Detailed performance metrics
"""
self.tot_timesteps += self.num_steps_per_env * self.env.num_envs
self.tot_time += locs['collection_time'] + locs['learn_time']
iteration_time = locs['collection_time'] + locs['learn_time']
# -- Episode info
ep_string = ""
if locs['ep_infos']:
for key in locs['ep_infos'][0]:
infotensor = torch.tensor([], device=self.device)
for ep_info in locs['ep_infos']:
# skip missing keys
if key not in ep_info:
continue
# handle scalar and zero dimensional tensor infos
if not isinstance(ep_info[key], torch.Tensor):
ep_info[key] = torch.Tensor([ep_info[key]])
if len(ep_info[key].shape) == 0:
ep_info[key] = ep_info[key].unsqueeze(0)
infotensor = torch.cat((infotensor, ep_info[key].to(self.device)))
if infotensor.numel() > 0:
value = torch.mean(infotensor)
# log to tensorboard with proper namespace
if "/" in key:
self.writer.add_scalar(key, value, locs['it'])
ep_string += f"""{f'{key}:':>{pad}} {value:.4f}\n"""
else:
self.writer.add_scalar('Episode/' + key, value, locs['it'])
ep_string += f"""{f'Mean episode {key}:':>{pad}} {value:.4f}\n"""
# -- Policy metrics
mean_std = self.alg.actor_critic.std.mean()
fps = int(self.num_steps_per_env * self.env.num_envs / (locs['collection_time'] + locs['learn_time']))
# -- Losses
loss_dict = {
'value_function': locs['mean_value_loss'],
'surrogate': locs['mean_surrogate_loss'],
'estimation': locs['mean_estimation_loss'],
'swap': locs['mean_swap_loss'],
}
for key, value in loss_dict.items():
self.writer.add_scalar(f'Loss/{key}', value, locs['it'])
self.writer.add_scalar('Loss/learning_rate', self.alg.learning_rate, locs['it'])
self.writer.add_scalar('Policy/mean_noise_std', mean_std.item(), locs['it'])
# -- Performance
self.writer.add_scalar('Perf/total_fps', fps, locs['it'])
self.writer.add_scalar('Perf/collection_time', locs['collection_time'], locs['it'])
self.writer.add_scalar('Perf/learning_time', locs['learn_time'], locs['it'])
# -- Training metrics
if len(locs['rewbuffer']) > 0:
self.writer.add_scalar('Train/mean_reward', statistics.mean(locs['rewbuffer']), locs['it'])
self.writer.add_scalar('Train/mean_episode_length', statistics.mean(locs['lenbuffer']), locs['it'])
if self.logger_type != "wandb": # wandb does not support non-integer x-axis logging
self.writer.add_scalar('Train/mean_reward/time', statistics.mean(locs['rewbuffer']), self.tot_time)
self.writer.add_scalar('Train/mean_episode_length/time', statistics.mean(locs['lenbuffer']), self.tot_time)
# -- Console output
str_header = f" \033[1m Learning iteration {locs['it']}/{locs['tot_iter']} \033[0m "
if len(locs['rewbuffer']) > 0:
log_string = (
f"""{'#' * width}\n"""
f"""{str_header.center(width, ' ')}\n\n"""
f"""{'Computation:':>{pad}} {fps:.0f} steps/s (collection: {locs['collection_time']:.3f}s, learning {locs['learn_time']:.3f}s)\n"""
f"""{'Mean action noise std:':>{pad}} {mean_std.item():.2f}\n"""
)
# Add losses
for key, value in loss_dict.items():
log_string += f"""{f'Mean {key} loss:':>{pad}} {value:.4f}\n"""
# Add rewards
log_string += (
f"""{'Mean reward:':>{pad}} {statistics.mean(locs['rewbuffer']):.2f}\n"""
f"""{'Mean episode length:':>{pad}} {statistics.mean(locs['lenbuffer']):.2f}\n"""
)
else:
log_string = (
f"""{'#' * width}\n"""
f"""{str_header.center(width, ' ')}\n\n"""
f"""{'Computation:':>{pad}} {fps:.0f} steps/s (collection: {locs['collection_time']:.3f}s, learning {locs['learn_time']:.3f}s)\n"""
f"""{'Mean action noise std:':>{pad}} {mean_std.item():.2f}\n"""
)
for key, value in loss_dict.items():
log_string += f"""{f'{key}:':>{pad}} {value:.4f}\n"""
# Add episode info
log_string += ep_string
# Add summary
log_string += (
f"""{'-' * width}\n"""
f"""{'Total timesteps:':>{pad}} {self.tot_timesteps}\n"""
f"""{'Iteration time:':>{pad}} {iteration_time:.2f}s\n"""
f"""{'Time elapsed:':>{pad}} {time.strftime("%H:%M:%S", time.gmtime(self.tot_time))}\n"""
f"""{'ETA:':>{pad}} {time.strftime(
"%H:%M:%S",
time.gmtime(
self.tot_time / (locs['it'] - locs['start_iter'] + 1)
* (locs['start_iter'] + locs['num_learning_iterations'] - locs['it'])
)
)}\n"""
)
print(log_string)
def save(self, path, infos=None):
"""Save model checkpoint.
Args:
path: Path to save the model.
infos: Additional information to save.
"""
saved_dict = {
'model_state_dict': self.alg.actor_critic.state_dict(),
'optimizer_state_dict': self.alg.optimizer.state_dict(),
'estimator_optimizer_state_dict': self.alg.actor_critic.estimator.optimizer.state_dict(),
'iter': self.current_learning_iteration,
'infos': infos,
}
torch.save(saved_dict, path)
# Upload model to external logging service
if hasattr(self, 'logger_type') and self.logger_type in ["neptune", "wandb"]:
if hasattr(self.writer, 'save_model'):
self.writer.save_model(path, self.current_learning_iteration)
def load(self, path, load_optimizer=True):
loaded_dict = torch.load(path)
self.alg.actor_critic.load_state_dict(loaded_dict['model_state_dict'])
if load_optimizer:
self.alg.optimizer.load_state_dict(loaded_dict['optimizer_state_dict'])
self.alg.actor_critic.estimator.optimizer.load_state_dict(loaded_dict['estimator_optimizer_state_dict'])
self.current_learning_iteration = loaded_dict['iter']
return loaded_dict['infos']
def get_inference_policy(self, device=None):
self.alg.actor_critic.eval() # switch to evaluation mode (dropout for example)
if device is not None:
self.alg.actor_critic.to(device)
return self.alg.actor_critic.act_inference
@@ -0,0 +1,4 @@
# Copyright 2021 ETH Zurich, NVIDIA CORPORATION
# SPDX-License-Identifier: BSD-3-Clause
from .him_rollout_storage import HIMRolloutStorage
@@ -0,0 +1,167 @@
# SPDX-FileCopyrightText: Copyright (c) 2021 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: BSD-3-Clause
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this
# list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its
# contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
# Copyright (c) 2021 ETH Zurich, Nikita Rudin
import torch
import numpy as np
# from ..utils import split_and_pad_trajectories
class HIMRolloutStorage:
class Transition:
def __init__(self):
self.observations = None
self.critic_observations = None
self.actions = None
self.rewards = None
self.dones = None
self.values = None
self.actions_log_prob = None
self.action_mean = None
self.action_sigma = None
self.next_critic_observations = None
def clear(self):
self.__init__()
def __init__(self, num_envs, num_transitions_per_env, obs_shape, privileged_obs_shape, actions_shape, device='cpu'):
self.device = device
self.obs_shape = obs_shape
self.privileged_obs_shape = privileged_obs_shape
self.actions_shape = actions_shape
# Core
self.observations = torch.zeros(num_transitions_per_env, num_envs, *obs_shape, device=self.device)
if privileged_obs_shape[0] is not None:
self.privileged_observations = torch.zeros(num_transitions_per_env, num_envs, *privileged_obs_shape, device=self.device)
self.next_privileged_observations = torch.zeros(num_transitions_per_env, num_envs, *privileged_obs_shape, device=self.device)
else:
self.privileged_observations = None
self.next_privileged_observations = None
self.rewards = torch.zeros(num_transitions_per_env, num_envs, 1, device=self.device)
self.actions = torch.zeros(num_transitions_per_env, num_envs, *actions_shape, device=self.device)
self.dones = torch.zeros(num_transitions_per_env, num_envs, 1, device=self.device).byte()
# For PPO
self.actions_log_prob = torch.zeros(num_transitions_per_env, num_envs, 1, device=self.device)
self.values = torch.zeros(num_transitions_per_env, num_envs, 1, device=self.device)
self.returns = torch.zeros(num_transitions_per_env, num_envs, 1, device=self.device)
self.advantages = torch.zeros(num_transitions_per_env, num_envs, 1, device=self.device)
self.mu = torch.zeros(num_transitions_per_env, num_envs, *actions_shape, device=self.device)
self.sigma = torch.zeros(num_transitions_per_env, num_envs, *actions_shape, device=self.device)
self.num_transitions_per_env = num_transitions_per_env
self.num_envs = num_envs
self.step = 0
def add_transitions(self, transition: Transition):
if self.step >= self.num_transitions_per_env:
raise AssertionError("Rollout buffer overflow")
self.observations[self.step].copy_(transition.observations)
if self.privileged_observations is not None: self.privileged_observations[self.step].copy_(transition.critic_observations)
if self.next_privileged_observations is not None: self.next_privileged_observations[self.step].copy_(transition.next_critic_observations)
self.actions[self.step].copy_(transition.actions)
self.rewards[self.step].copy_(transition.rewards.view(-1, 1))
self.dones[self.step].copy_(transition.dones.view(-1, 1))
self.values[self.step].copy_(transition.values)
self.actions_log_prob[self.step].copy_(transition.actions_log_prob.view(-1, 1))
self.mu[self.step].copy_(transition.action_mean)
self.sigma[self.step].copy_(transition.action_sigma)
self.step += 1
def clear(self):
self.step = 0
def compute_returns(self, last_values, gamma, lam):
advantage = 0
for step in reversed(range(self.num_transitions_per_env)):
if step == self.num_transitions_per_env - 1:
next_values = last_values
else:
next_values = self.values[step + 1]
next_is_not_terminal = 1.0 - self.dones[step].float()
delta = self.rewards[step] + next_is_not_terminal * gamma * next_values - self.values[step]
advantage = delta + next_is_not_terminal * gamma * lam * advantage
self.returns[step] = advantage + self.values[step]
# Compute and normalize the advantages
self.advantages = self.returns - self.values
self.advantages = (self.advantages - self.advantages.mean()) / (self.advantages.std() + 1e-8)
def get_statistics(self):
done = self.dones
done[-1] = 1
flat_dones = done.permute(1, 0, 2).reshape(-1, 1)
done_indices = torch.cat((flat_dones.new_tensor([-1], dtype=torch.int64), flat_dones.nonzero(as_tuple=False)[:, 0]))
trajectory_lengths = (done_indices[1:] - done_indices[:-1])
return trajectory_lengths.float().mean(), self.rewards.mean()
def mini_batch_generator(self, num_mini_batches, num_epochs=8):
batch_size = self.num_envs * self.num_transitions_per_env
mini_batch_size = batch_size // num_mini_batches
indices = torch.randperm(num_mini_batches*mini_batch_size, requires_grad=False, device=self.device)
observations = self.observations.flatten(0, 1)
if self.privileged_observations is not None:
critic_observations = self.privileged_observations.flatten(0, 1)
next_critic_observations = self.next_privileged_observations.flatten(0, 1)
else:
critic_observations = observations
next_critic_observations = observations
actions = self.actions.flatten(0, 1)
values = self.values.flatten(0, 1)
returns = self.returns.flatten(0, 1)
old_actions_log_prob = self.actions_log_prob.flatten(0, 1)
advantages = self.advantages.flatten(0, 1)
old_mu = self.mu.flatten(0, 1)
old_sigma = self.sigma.flatten(0, 1)
for epoch in range(num_epochs):
for i in range(num_mini_batches):
start = i*mini_batch_size
end = (i+1)*mini_batch_size
batch_idx = indices[start:end]
obs_batch = observations[batch_idx]
next_critic_observations_batch = next_critic_observations[batch_idx]
critic_observations_batch = critic_observations[batch_idx]
actions_batch = actions[batch_idx]
target_values_batch = values[batch_idx]
returns_batch = returns[batch_idx]
old_actions_log_prob_batch = old_actions_log_prob[batch_idx]
advantages_batch = advantages[batch_idx]
old_mu_batch = old_mu[batch_idx]
old_sigma_batch = old_sigma[batch_idx]
yield obs_batch, critic_observations_batch, actions_batch, next_critic_observations_batch, target_values_batch, advantages_batch, returns_batch, \
old_actions_log_prob_batch, old_mu_batch, old_sigma_batch
@@ -0,0 +1,31 @@
# SPDX-FileCopyrightText: Copyright (c) 2021 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: BSD-3-Clause
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this
# list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its
# contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
# Copyright (c) 2021 ETH Zurich, Nikita Rudin
from .utils import split_and_pad_trajectories, unpad_trajectories
@@ -0,0 +1,71 @@
# SPDX-FileCopyrightText: Copyright (c) 2021 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: BSD-3-Clause
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this
# list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its
# contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
# Copyright (c) 2021 ETH Zurich, Nikita Rudin
import torch
def split_and_pad_trajectories(tensor, dones):
""" Splits trajectories at done indices. Then concatenates them and padds with zeros up to the length og the longest trajectory.
Returns masks corresponding to valid parts of the trajectories
Example:
Input: [ [a1, a2, a3, a4 | a5, a6],
[b1, b2 | b3, b4, b5 | b6]
]
Output:[ [a1, a2, a3, a4], | [ [True, True, True, True],
[a5, a6, 0, 0], | [True, True, False, False],
[b1, b2, 0, 0], | [True, True, False, False],
[b3, b4, b5, 0], | [True, True, True, False],
[b6, 0, 0, 0] | [True, False, False, False],
] | ]
Assumes that the inputy has the following dimension order: [time, number of envs, aditional dimensions]
"""
dones = dones.clone()
dones[-1] = 1
# Permute the buffers to have order (num_envs, num_transitions_per_env, ...), for correct reshaping
flat_dones = dones.transpose(1, 0).reshape(-1, 1)
# Get length of trajectory by counting the number of successive not done elements
done_indices = torch.cat((flat_dones.new_tensor([-1], dtype=torch.int64), flat_dones.nonzero()[:, 0]))
trajectory_lengths = done_indices[1:] - done_indices[:-1]
trajectory_lengths_list = trajectory_lengths.tolist()
# Extract the individual trajectories
trajectories = torch.split(tensor.transpose(1, 0).flatten(0, 1),trajectory_lengths_list)
padded_trajectories = torch.nn.utils.rnn.pad_sequence(trajectories)
trajectory_masks = trajectory_lengths > torch.arange(0, tensor.shape[0], device=tensor.device).unsqueeze(1)
return padded_trajectories, trajectory_masks
def unpad_trajectories(trajectories, masks):
""" Does the inverse operation of split_and_pad_trajectories()
"""
# Need to transpose before and after the masking to have proper reshaping
return trajectories.transpose(1, 0)[masks.transpose(1, 0)].view(-1, trajectories.shape[0], trajectories.shape[-1]).transpose(1, 0)
@@ -0,0 +1,113 @@
from pathlib import Path
import mujoco
from mjlab.actuator import BuiltinPositionActuatorCfg, BuiltinVelocityActuatorCfg
from mjlab.entity import EntityArticulationInfoCfg, EntityCfg
from mjlab.utils.spec_config import CollisionCfg
_PROJECT_ROOT = Path(__file__).parent.parent.parent
ROBOT_XML = _PROJECT_ROOT / "mjcf" / "wheelleg.xml"
LEG_JOINT_PATTERNS = (
".*_hip_abduction_joint",
".*_hip_pitch_joint",
".*_knee_joint",
)
WHEEL_JOINT_PATTERNS = (".*_wheel_joint",)
ALL_JOINT_PATTERNS = LEG_JOINT_PATTERNS + WHEEL_JOINT_PATTERNS
def get_spec() -> mujoco.MjSpec:
spec = mujoco.MjSpec.from_file(str(ROBOT_XML))
actuators_to_delete = list(spec.actuators)
for act in actuators_to_delete:
spec.delete(act)
return spec
# All 16 actuators have identical physical specs: effort limit = 17 Nm, max velocity = 13 rad/s.
# Leg joints: Position PD control (referenced from HIMLoco Go2W: kp = 40, kd = 1).
STIFFNESS_LEG = 40.0
DAMPING_LEG = 1.0
EFFORT_LEG = 17.0
# Wheel joints: Velocity control with damping = 0.5 and effort limit = 17 Nm.
DAMPING_WHEEL = 0.5
EFFORT_WHEEL = 17.0
# Maximum joint speed for all actuators (rad/s)
MAX_JOINT_VEL = 13.0
LEG_ACTUATOR_CFG = BuiltinPositionActuatorCfg(
target_names_expr=LEG_JOINT_PATTERNS,
stiffness=STIFFNESS_LEG,
damping=DAMPING_LEG,
effort_limit=EFFORT_LEG,
)
WHEEL_ACTUATOR_CFG = BuiltinVelocityActuatorCfg(
target_names_expr=WHEEL_JOINT_PATTERNS,
damping=DAMPING_WHEEL,
effort_limit=EFFORT_WHEEL,
)
INIT_STATE = EntityCfg.InitialStateCfg(
pos=(0.0, 0.0, 0.40),
joint_pos={
".*_hip_abduction_joint": 0.0,
".*_hip_pitch_joint": 0.9,
".*_knee_joint": -1.8,
".*_wheel_joint": 0.0,
},
joint_vel={".*": 0.0},
)
COLLISION_CFG = CollisionCfg(
geom_names_expr=(".*",),
contype=0,
conaffinity=1,
condim={".*_wheel_Link.*": 6, ".*": 1},
priority={".*_wheel_Link.*": 1},
friction={".*_wheel_Link.*": (0.8, 0.05, 0.01)},
)
ARTICULATION_CFG = EntityArticulationInfoCfg(
actuators=(LEG_ACTUATOR_CFG, WHEEL_ACTUATOR_CFG),
soft_joint_pos_limit_factor=0.95,
)
def get_robot_cfg() -> EntityCfg:
return EntityCfg(
init_state=INIT_STATE,
collisions=(COLLISION_CFG,),
spec_fn=get_spec,
articulation=ARTICULATION_CFG,
)
def get_robot_crawl_cfg() -> EntityCfg:
crawl_init_state = EntityCfg.InitialStateCfg(
pos=(0.0, 0.0, 0.20),
joint_pos={
"(fl|rl)_hip_abduction_joint": 0.4,
"(fr|rr)_hip_abduction_joint": -0.4,
".*_hip_pitch_joint": 1.65,
".*_knee_joint": -2.55,
".*_wheel_joint": 0.0,
},
joint_vel={".*": 0.0},
)
return EntityCfg(
init_state=crawl_init_state,
collisions=(COLLISION_CFG,),
spec_fn=get_spec,
articulation=ARTICULATION_CFG,
)
# Action scale: Leg target position is ±0.25 rad, wheel target velocity is ±10.0 rad/s.
# (Policy output range ±1 maps to wheel speeds ±10 rad/s, staying well within the max velocity of 13 rad/s).
LEG_POS_SCALE = 0.25
WHEEL_VEL_SCALE = 10.0
@@ -0,0 +1,5 @@
from .modules.him_estimator import HIMEstimator
from .modules.him_actor_critic import HIMActorCritic
from .algorithms.him_ppo import HIMPPO
from .storage.him_rollout_storage import HIMRolloutStorage
from .env.vec_env import VecEnv
@@ -0,0 +1 @@
from .him_ppo import HIMPPO
@@ -0,0 +1,185 @@
import torch
from ..modules import HIMActorCritic
from ..storage import HIMRolloutStorage
class HIMPPO:
actor_critic: HIMActorCritic
def __init__(self,
actor_critic,
num_learning_epochs=1,
num_mini_batches=1,
clip_param=0.2,
gamma=0.998,
lam=0.95,
value_loss_coef=1.0,
entropy_coef=0.0,
learning_rate=1e-3,
max_grad_norm=1.0,
use_clipped_value_loss=True,
schedule="fixed",
desired_kl=0.01,
device='cpu',
):
self.device = device
self.desired_kl = desired_kl
self.schedule = schedule
self.learning_rate = learning_rate
# PPO components
self.actor_critic = actor_critic
self.actor_critic.to(self.device)
self.storage = None # initialized later
self.optimizer = torch.optim.Adam(self.actor_critic.parameters(), lr=learning_rate)
self.transition = HIMRolloutStorage.Transition()
# PPO parameters
self.clip_param = clip_param
self.num_learning_epochs = num_learning_epochs
self.num_mini_batches = num_mini_batches
self.value_loss_coef = value_loss_coef
self.entropy_coef = entropy_coef
self.gamma = gamma
self.lam = lam
self.max_grad_norm = max_grad_norm
self.use_clipped_value_loss = use_clipped_value_loss
def init_storage(self, num_envs, num_transitions_per_env, actor_obs_shape, critic_obs_shape, action_shape):
self.storage = HIMRolloutStorage(
num_envs, num_transitions_per_env, actor_obs_shape, critic_obs_shape, action_shape, self.device
)
def test_mode(self):
self.actor_critic.test()
def train_mode(self):
self.actor_critic.train()
def act(self, obs, critic_obs):
self.transition.actions = self.actor_critic.act(obs).detach()
self.transition.values = self.actor_critic.evaluate(critic_obs).detach()
self.transition.actions_log_prob = self.actor_critic.get_actions_log_prob(
self.transition.actions
).detach()
self.transition.action_mean = self.actor_critic.action_mean.detach()
self.transition.action_sigma = self.actor_critic.action_std.detach()
self.transition.observations = obs
self.transition.critic_observations = critic_obs
return self.transition.actions
def process_env_step(self, rewards, dones, infos, next_critic_obs):
self.transition.next_critic_observations = next_critic_obs.clone()
self.transition.rewards = rewards.clone()
self.transition.dones = dones
# Bootstrapping on time outs
if 'time_outs' in infos:
self.transition.rewards += self.gamma * torch.squeeze(
self.transition.values * infos['time_outs'].unsqueeze(1).to(self.device), 1
)
self.storage.add_transitions(self.transition)
self.transition.clear()
self.actor_critic.reset(dones)
def compute_returns(self, last_critic_obs):
last_values = self.actor_critic.evaluate(last_critic_obs).detach()
self.storage.compute_returns(last_values, self.gamma, self.lam)
def update(self):
mean_value_loss = 0
mean_surrogate_loss = 0
mean_estimation_loss = 0
mean_swap_loss = 0
generator = self.storage.mini_batch_generator(
self.num_mini_batches, self.num_learning_epochs
)
for obs_batch, critic_obs_batch, actions_batch, next_critic_obs_batch, \
target_values_batch, advantages_batch, returns_batch, \
old_actions_log_prob_batch, old_mu_batch, old_sigma_batch in generator:
self.actor_critic.act(obs_batch)
actions_log_prob_batch = self.actor_critic.get_actions_log_prob(actions_batch)
value_batch = self.actor_critic.evaluate(critic_obs_batch)
mu_batch = self.actor_critic.action_mean
sigma_batch = self.actor_critic.action_std
entropy_batch = self.actor_critic.entropy
# KL
if self.desired_kl is not None and self.schedule == 'adaptive':
with torch.inference_mode():
kl = torch.sum(
torch.log(sigma_batch / old_sigma_batch + 1.e-5)
+ (torch.square(old_sigma_batch) + torch.square(old_mu_batch - mu_batch))
/ (2.0 * torch.square(sigma_batch)) - 0.5,
axis=-1,
)
kl_mean = torch.mean(kl)
if kl_mean > self.desired_kl * 2.0:
self.learning_rate = max(1e-5, self.learning_rate / 1.5)
elif kl_mean < self.desired_kl / 2.0 and kl_mean > 0.0:
self.learning_rate = min(1e-2, self.learning_rate * 1.5)
for param_group in self.optimizer.param_groups:
param_group['lr'] = self.learning_rate
# Estimator Update
estimation_loss, swap_loss = self.actor_critic.estimator.update(
obs_batch, next_critic_obs_batch, lr=self.learning_rate
)
# Surrogate loss
ratio = torch.exp(
actions_log_prob_batch - torch.squeeze(old_actions_log_prob_batch)
)
surrogate = -torch.squeeze(advantages_batch) * ratio
surrogate_clipped = -torch.squeeze(advantages_batch) * torch.clamp(
ratio, 1.0 - self.clip_param, 1.0 + self.clip_param
)
surrogate_loss = torch.max(surrogate, surrogate_clipped).mean()
# Value function loss
if self.use_clipped_value_loss:
value_clipped = target_values_batch + (
value_batch - target_values_batch
).clamp(-self.clip_param, self.clip_param)
value_losses = (value_batch - returns_batch).pow(2)
value_losses_clipped = (value_clipped - returns_batch).pow(2)
value_loss = torch.max(value_losses, value_losses_clipped).mean()
else:
value_loss = (returns_batch - value_batch).pow(2).mean()
loss = (
surrogate_loss
+ self.value_loss_coef * value_loss
- self.entropy_coef * entropy_batch.mean()
)
# Gradient step
self.optimizer.zero_grad()
loss.backward()
nn.utils.clip_grad_norm_(self.actor_critic.parameters(), self.max_grad_norm)
self.optimizer.step()
mean_value_loss += value_loss.item()
mean_surrogate_loss += surrogate_loss.item()
mean_estimation_loss += estimation_loss
mean_swap_loss += swap_loss
num_updates = self.num_learning_epochs * self.num_mini_batches
mean_value_loss /= num_updates
mean_surrogate_loss /= num_updates
mean_estimation_loss /= num_updates
mean_swap_loss /= num_updates
self.storage.clear()
return mean_value_loss, mean_surrogate_loss, mean_estimation_loss, mean_swap_loss
# Import nn here to avoid circular dependency at module level
import torch.nn as nn
@@ -0,0 +1 @@
from .vec_env import VecEnv
@@ -0,0 +1,40 @@
import torch
class VecEnv:
"""Abstract vectorized environment interface for HIMLoco RSL-RL."""
num_envs: int
num_obs: int | None = None
num_one_step_obs: int | None = None
num_privileged_obs: int | None = None
num_one_step_privileged_obs: int | None = None
num_actions: int
max_episode_length: int
device: str
def get_observations(self) -> torch.Tensor:
raise NotImplementedError
def get_privileged_observations(self) -> torch.Tensor | None:
raise NotImplementedError
def step(self, actions: torch.Tensor) -> tuple:
raise NotImplementedError
def reset(self) -> tuple:
raise NotImplementedError
def seed(self, seed: int = -1) -> int:
raise NotImplementedError
def close(self) -> None:
raise NotImplementedError
@property
def episode_length_buf(self) -> torch.Tensor:
raise NotImplementedError
@episode_length_buf.setter
def episode_length_buf(self, value: torch.Tensor) -> None:
raise NotImplementedError
@@ -0,0 +1,2 @@
from .him_estimator import HIMEstimator
from .him_actor_critic import HIMActorCritic
@@ -0,0 +1,206 @@
import torch
import torch.nn as nn
from torch.distributions import Normal
from ..modules.him_estimator import HIMEstimator
class RunningMeanStd:
def __init__(self, shape, device):
self.n = 1e-4
self.uninitialized = True
self.mean = torch.zeros(shape, device=device)
self.var = torch.ones(shape, device=device)
def update(self, x):
count = self.n
batch_count = x.size(0)
tot_count = count + batch_count
old_mean = self.mean.clone()
delta = torch.mean(x, dim=0) - old_mean
self.mean = old_mean + delta * batch_count / tot_count
m_a = self.var * count
m_b = x.var(dim=0) * batch_count
M2 = m_a + m_b + torch.square(delta) * count * batch_count / tot_count
self.var = M2 / tot_count
self.n = tot_count
class Normalization:
def __init__(self, shape, device='cuda:0'):
self.running_ms = RunningMeanStd(shape=shape, device=device)
def __call__(self, x, update=False):
if update:
self.running_ms.update(x)
x = (x - self.running_ms.mean) / (torch.sqrt(self.running_ms.var) + 1e-4)
return x
class HIMActorCritic(nn.Module):
is_recurrent = False
def __init__(self, num_actor_obs,
num_critic_obs,
num_one_step_obs,
num_actions,
actor_hidden_dims=[512, 256, 128],
critic_hidden_dims=[512, 256, 128],
activation='elu',
init_noise_std=1.0,
**kwargs):
if kwargs:
print("ActorCritic.__init__ got unexpected arguments, which will be ignored: "
+ str([key for key in kwargs.keys()]))
super(HIMActorCritic, self).__init__()
activation = get_activation(activation)
self.history_size = int(num_actor_obs / num_one_step_obs)
self.num_actor_obs = num_actor_obs
self.num_actions = num_actions
self.num_one_step_obs = num_one_step_obs
mlp_input_dim_a = num_one_step_obs + 3 + 16
mlp_input_dim_c = num_critic_obs
# Estimator
self.estimator = HIMEstimator(
temporal_steps=self.history_size,
num_one_step_obs=num_one_step_obs,
)
# Policy
actor_layers = []
actor_layers.append(nn.Linear(mlp_input_dim_a, actor_hidden_dims[0]))
actor_layers.append(activation)
for l in range(len(actor_hidden_dims)):
if l == len(actor_hidden_dims) - 1:
actor_layers.append(nn.Linear(actor_hidden_dims[l], num_actions))
else:
actor_layers.append(nn.Linear(actor_hidden_dims[l], actor_hidden_dims[l + 1]))
actor_layers.append(activation)
self.actor = nn.Sequential(*actor_layers)
# Value function
critic_layers = []
critic_layers.append(nn.Linear(mlp_input_dim_c, critic_hidden_dims[0]))
critic_layers.append(activation)
for l in range(len(critic_hidden_dims)):
if l == len(critic_hidden_dims) - 1:
critic_layers.append(nn.Linear(critic_hidden_dims[l], 1))
else:
critic_layers.append(nn.Linear(critic_hidden_dims[l], critic_hidden_dims[l + 1]))
critic_layers.append(activation)
self.critic = nn.Sequential(*critic_layers)
print(f"Actor MLP: {self.actor}")
print(f"Critic MLP: {self.critic}")
print(f'Estimator: {self.estimator.encoder}')
# Action noise
self.std = nn.Parameter(init_noise_std * torch.ones(num_actions))
self.distribution = None
Normal.set_default_validate_args = False
@staticmethod
def init_weights(sequential, scales):
[torch.nn.init.orthogonal_(module.weight, gain=scales[idx])
for idx, module in
enumerate(mod for mod in sequential if isinstance(mod, nn.Linear))]
def reset(self, dones=None):
pass
def forward(self):
raise NotImplementedError
@property
def action_mean(self):
return self.distribution.mean
@property
def action_std(self):
return self.distribution.stddev
@property
def entropy(self):
return self.distribution.entropy().sum(dim=-1)
def update_distribution(self, obs_history):
with torch.no_grad():
vel, latent = self.estimator(obs_history)
actor_input = torch.cat(
(obs_history[:, :self.num_one_step_obs], vel, latent), dim=-1
)
if torch.isnan(actor_input).any() or torch.isinf(actor_input).any():
print(f"[ERROR] NaN/Inf detected in actor_input before normalization!")
print(f" - obs_history: min={obs_history.min():.4f}, max={obs_history.max():.4f}")
print(f" - vel: min={vel.min():.4f}, max={vel.max():.4f}")
print(f" - latent: min={latent.min():.4f}, max={latent.max():.4f}")
raise ValueError("NaN/Inf in actor_input before normalization")
mean = self.actor(actor_input)
if torch.isnan(mean).any() or torch.isinf(mean).any():
print(f"[ERROR] NaN/Inf in actor output (mean)!")
for name, param in self.actor.named_parameters():
if torch.isnan(param).any():
print(f" - NaN in actor weight: {name}")
if torch.isinf(param).any():
print(f" - Inf in actor weight: {name}")
raise ValueError("NaN or Inf detected in actor network output!")
self.distribution = Normal(mean, mean * 0. + self.std)
def act(self, obs_history=None, **kwargs):
self.update_distribution(obs_history)
return self.distribution.sample()
def get_actions_log_prob(self, actions):
return self.distribution.log_prob(actions).sum(dim=-1)
def act_inference(self, obs_history, observations=None):
vel, latent = self.estimator(obs_history)
actions_mean = self.actor(
torch.cat(
(obs_history[:, :self.num_one_step_obs], vel, latent), dim=-1
)
)
return actions_mean
def test_inference(self, obs_history, observations=None):
vel, latent = self.estimator(obs_history)
actions_mean = self.actor(
torch.cat(
(obs_history[:, :self.num_one_step_obs], vel, latent), dim=-1
)
)
estimator_output = torch.cat((vel, latent), dim=-1)
return actions_mean, estimator_output
def evaluate(self, critic_observations, **kwargs):
value = self.critic(critic_observations)
return value
def get_activation(act_name):
if act_name == "elu":
return nn.ELU()
elif act_name == "selu":
return nn.SELU()
elif act_name == "relu":
return nn.ReLU()
elif act_name == "crelu":
return nn.ReLU()
elif act_name == "lrelu":
return nn.LeakyReLU()
elif act_name == "tanh":
return nn.Tanh()
elif act_name == "sigmoid":
return nn.Sigmoid()
else:
print("invalid activation function!")
return None
@@ -0,0 +1,153 @@
import copy
import math
import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F
class HIMEstimator(nn.Module):
def __init__(self,
temporal_steps,
num_one_step_obs,
enc_hidden_dims=[128, 64, 16],
tar_hidden_dims=[128, 64],
activation='elu',
learning_rate=1e-3,
max_grad_norm=10.0,
num_prototype=32,
temperature=3.0,
**kwargs):
if kwargs:
print("Estimator.__init__ got unexpected arguments, which will be ignored: "
+ str([key for key in kwargs.keys()]))
super(HIMEstimator, self).__init__()
activation = get_activation(activation)
self.temporal_steps = temporal_steps
self.num_one_step_obs = num_one_step_obs
self.num_latent = enc_hidden_dims[-1]
self.max_grad_norm = max_grad_norm
self.temperature = temperature
# Encoder
enc_input_dim = self.temporal_steps * self.num_one_step_obs
enc_layers = []
for l in range(len(enc_hidden_dims) - 1):
enc_layers += [nn.Linear(enc_input_dim, enc_hidden_dims[l]), activation]
enc_input_dim = enc_hidden_dims[l]
enc_layers += [nn.Linear(enc_input_dim, enc_hidden_dims[-1] + 3)]
self.encoder = nn.Sequential(*enc_layers)
# Target
tar_input_dim = self.num_one_step_obs
tar_layers = []
for l in range(len(tar_hidden_dims)):
tar_layers += [nn.Linear(tar_input_dim, tar_hidden_dims[l]), activation]
tar_input_dim = tar_hidden_dims[l]
tar_layers += [nn.Linear(tar_input_dim, enc_hidden_dims[-1])]
self.target = nn.Sequential(*tar_layers)
# Prototype
self.proto = nn.Embedding(num_prototype, enc_hidden_dims[-1])
# Optimizer
self.learning_rate = learning_rate
self.optimizer = optim.Adam(self.parameters(), lr=self.learning_rate)
def get_latent(self, obs_history):
vel, z = self.encode(obs_history)
return vel.detach(), z.detach()
def forward(self, obs_history):
parts = self.encoder(obs_history.detach())
vel, z = parts[..., :3], parts[..., 3:]
z = F.normalize(z, dim=-1, p=2)
return vel.detach(), z.detach()
def encode(self, obs_history):
parts = self.encoder(obs_history.detach())
vel, z = parts[..., :3], parts[..., 3:]
z = F.normalize(z, dim=-1, p=2)
return vel, z
def update(self, obs_history, next_critic_obs, lr=None):
if lr is not None:
self.learning_rate = lr
for param_group in self.optimizer.param_groups:
param_group['lr'] = self.learning_rate
vel = next_critic_obs[:, self.num_one_step_obs:self.num_one_step_obs + 3].detach()
next_obs = next_critic_obs.detach()[:, 3:self.num_one_step_obs + 3]
z_s = self.encoder(obs_history)
z_t = self.target(next_obs)
pred_vel, z_s = z_s[..., :3], z_s[..., 3:]
z_s = F.normalize(z_s, dim=-1, p=2)
z_t = F.normalize(z_t, dim=-1, p=2)
with torch.no_grad():
w = self.proto.weight.data.clone()
w = F.normalize(w, dim=-1, p=2)
self.proto.weight.copy_(w)
score_s = z_s @ self.proto.weight.T
score_t = z_t @ self.proto.weight.T
with torch.no_grad():
q_s = sinkhorn(score_s)
q_t = sinkhorn(score_t)
log_p_s = F.log_softmax(score_s / self.temperature, dim=-1)
log_p_t = F.log_softmax(score_t / self.temperature, dim=-1)
swap_loss = -0.5 * (q_s * log_p_t + q_t * log_p_s).mean()
estimation_loss = F.mse_loss(pred_vel, vel)
losses = estimation_loss + swap_loss
self.optimizer.zero_grad()
losses.backward()
nn.utils.clip_grad_norm_(self.parameters(), self.max_grad_norm)
self.optimizer.step()
return estimation_loss.item(), swap_loss.item()
@torch.no_grad()
def sinkhorn(out, eps=0.05, iters=3):
Q = torch.exp(out / eps).T
K, B = Q.shape[0], Q.shape[1]
Q /= Q.sum()
for it in range(iters):
# normalize each row: total weight per prototype must be 1/K
Q /= torch.sum(Q, dim=1, keepdim=True)
Q /= K
# normalize each column: total weight per sample must be 1/B
Q /= torch.sum(Q, dim=0, keepdim=True)
Q /= B
return (Q * B).T
def get_activation(act_name):
if act_name == "elu":
return nn.ELU()
elif act_name == "selu":
return nn.SELU()
elif act_name == "relu":
return nn.ReLU()
elif act_name == "crelu":
return nn.ReLU()
elif act_name == "silu":
return nn.SiLU()
elif act_name == "lrelu":
return nn.LeakyReLU()
elif act_name == "tanh":
return nn.Tanh()
elif act_name == "sigmoid":
return nn.Sigmoid()
else:
print("invalid activation function!")
return None
@@ -0,0 +1,305 @@
import time
import os
import statistics
from collections import deque
import torch
from ..algorithms import HIMPPO
from ..modules import HIMActorCritic
from ..env import VecEnv
class HIMOnPolicyRunner:
"""On-policy runner for HIMLoco training.
Uses the HIM architecture: asymmetric actor-critic with history-based
estimator for privileged information extraction.
"""
def __init__(self, env: VecEnv, train_cfg, log_dir=None, device='cpu'):
self.cfg = train_cfg
self.alg_cfg = train_cfg["algorithm"]
self.policy_cfg = train_cfg["policy"]
self.device = device
self.env = env
# Determine observation dimensions
if self.env.num_privileged_obs is not None:
num_critic_obs = self.env.num_privileged_obs
else:
num_critic_obs = self.env.num_obs
self.num_actor_obs = self.env.num_obs
self.num_critic_obs = num_critic_obs
# Initialize policy network
actor_critic_class = eval(train_cfg["policy_class_name"])
actor_critic: HIMActorCritic = actor_critic_class(
self.env.num_obs,
num_critic_obs,
self.env.num_one_step_obs,
self.env.num_actions,
**self.policy_cfg
).to(self.device)
# Initialize algorithm
alg_class = eval(train_cfg["algorithm_class_name"])
self.alg: HIMPPO = alg_class(actor_critic, device=self.device, **self.alg_cfg)
# Training configuration
self.num_steps_per_env = train_cfg["num_steps_per_env"]
self.save_interval = train_cfg["save_interval"]
# Initialize storage
self.alg.init_storage(
self.env.num_envs,
self.num_steps_per_env,
[self.env.num_obs],
[self.env.num_privileged_obs],
[self.env.num_actions]
)
# Logging
self.log_dir = log_dir
self.writer = None
self.tot_timesteps = 0
self.tot_time = 0
self.current_learning_iteration = 0
self.logger_type = self.cfg.get("logger", "tensorboard").lower()
def learn(self, num_learning_iterations, init_at_random_ep_len=False):
if self.log_dir is not None and self.writer is None:
if self.logger_type == "neptune":
from rsl_rl.utils.neptune_utils import NeptuneSummaryWriter
self.writer = NeptuneSummaryWriter(
log_dir=self.log_dir, flush_secs=10, cfg=self.alg_cfg
)
elif self.logger_type == "wandb":
from rsl_rl.utils.wandb_utils import WandbSummaryWriter
self.writer = WandbSummaryWriter(
log_dir=self.log_dir, flush_secs=10, cfg=self.cfg
)
if hasattr(self.writer, "store_config"):
env_cfg = getattr(self.env, "cfg", None)
if env_cfg is None and hasattr(self.env, "unwrapped"):
env_cfg = getattr(self.env.unwrapped, "cfg", None)
self.writer.store_config(env_cfg, self.cfg)
elif self.logger_type == "tensorboard":
from torch.utils.tensorboard import SummaryWriter
self.writer = SummaryWriter(log_dir=self.log_dir, flush_secs=10)
else:
raise ValueError(
"Logger type not found. Choose 'neptune', 'wandb' or 'tensorboard'."
)
if init_at_random_ep_len:
self.env.episode_length_buf = torch.randint_like(
self.env.episode_length_buf,
high=int(self.env.max_episode_length)
)
obs = self.env.get_observations()
privileged_obs = self.env.get_privileged_observations()
critic_obs = privileged_obs if privileged_obs is not None else obs
obs, critic_obs = obs.to(self.device), critic_obs.to(self.device)
self.alg.actor_critic.train()
ep_infos = []
rewbuffer = deque(maxlen=100)
lenbuffer = deque(maxlen=100)
cur_reward_sum = torch.zeros(self.env.num_envs, dtype=torch.float, device=self.device)
cur_episode_length = torch.zeros(self.env.num_envs, dtype=torch.float, device=self.device)
start_iter = self.current_learning_iteration
tot_iter = start_iter + num_learning_iterations
for it in range(start_iter, tot_iter):
start = time.time()
# Rollout
with torch.inference_mode():
for i in range(self.num_steps_per_env):
actions = self.alg.act(obs, critic_obs)
obs, privileged_obs, rewards, dones, infos, \
termination_ids, termination_privileged_obs = self.env.step(actions)
critic_obs = privileged_obs if privileged_obs is not None else obs
obs = obs.to(self.device)
critic_obs = critic_obs.to(self.device)
rewards = rewards.to(self.device)
dones = dones.to(self.device)
termination_ids = termination_ids.to(self.device)
termination_privileged_obs = termination_privileged_obs.to(self.device)
next_critic_obs = critic_obs.clone().detach()
next_critic_obs[termination_ids] = termination_privileged_obs.clone().detach()
self.alg.process_env_step(rewards, dones, infos, next_critic_obs)
if self.log_dir is not None:
if 'episode' in infos:
ep_infos.append(infos['episode'])
elif 'log' in infos:
ep_infos.append(infos['log'])
cur_reward_sum += rewards
cur_episode_length += 1
new_ids = (dones > 0).nonzero(as_tuple=False)
rewbuffer.extend(
cur_reward_sum[new_ids][:, 0].cpu().numpy().tolist()
)
lenbuffer.extend(
cur_episode_length[new_ids][:, 0].cpu().numpy().tolist()
)
cur_reward_sum[new_ids] = 0
cur_episode_length[new_ids] = 0
stop = time.time()
collection_time = stop - start
# Learning step
start = stop
self.alg.compute_returns(critic_obs)
mean_value_loss, mean_surrogate_loss, mean_estimation_loss, mean_swap_loss = \
self.alg.update()
stop = time.time()
learn_time = stop - start
self.current_learning_iteration = it
if self.log_dir is not None:
self.log(locals())
if it % self.save_interval == 0:
self.save(os.path.join(self.log_dir, f'model_{it}.pt'))
ep_infos.clear()
if self.log_dir is not None:
self.save(os.path.join(
self.log_dir, f'model_{self.current_learning_iteration}.pt'
))
def log(self, locs, width=80, pad=35):
self.tot_timesteps += self.num_steps_per_env * self.env.num_envs
self.tot_time += locs['collection_time'] + locs['learn_time']
iteration_time = locs['collection_time'] + locs['learn_time']
ep_string = ""
if locs['ep_infos']:
for key in locs['ep_infos'][0]:
infotensor = torch.tensor([], device=self.device)
for ep_info in locs['ep_infos']:
if key not in ep_info:
continue
if not isinstance(ep_info[key], torch.Tensor):
ep_info[key] = torch.Tensor([ep_info[key]])
if len(ep_info[key].shape) == 0:
ep_info[key] = ep_info[key].unsqueeze(0)
infotensor = torch.cat(
(infotensor, ep_info[key].to(self.device))
)
if infotensor.numel() > 0:
value = torch.mean(infotensor)
if "/" in key:
self.writer.add_scalar(key, value, locs['it'])
ep_string += f"""{f'{key}:':>{pad}} {value:.4f}\n"""
else:
self.writer.add_scalar('Episode/' + key, value, locs['it'])
ep_string += f"""{f'Mean episode {key}:':>{pad}} {value:.4f}\n"""
mean_std = self.alg.actor_critic.std.mean()
fps = int(
self.num_steps_per_env * self.env.num_envs
/ (locs['collection_time'] + locs['learn_time'])
)
loss_dict = {
'value_function': locs['mean_value_loss'],
'surrogate': locs['mean_surrogate_loss'],
'estimation': locs['mean_estimation_loss'],
'swap': locs['mean_swap_loss'],
}
for key, value in loss_dict.items():
self.writer.add_scalar(f'Loss/{key}', value, locs['it'])
self.writer.add_scalar('Loss/learning_rate', self.alg.learning_rate, locs['it'])
self.writer.add_scalar('Policy/mean_noise_std', mean_std.item(), locs['it'])
self.writer.add_scalar('Perf/total_fps', fps, locs['it'])
self.writer.add_scalar('Perf/collection_time', locs['collection_time'], locs['it'])
self.writer.add_scalar('Perf/learning_time', locs['learn_time'], locs['it'])
if len(locs['rewbuffer']) > 0:
self.writer.add_scalar(
'Train/mean_reward', statistics.mean(locs['rewbuffer']), locs['it']
)
self.writer.add_scalar(
'Train/mean_episode_length', statistics.mean(locs['lenbuffer']), locs['it']
)
if self.logger_type != "wandb":
self.writer.add_scalar(
'Train/mean_reward/time', statistics.mean(locs['rewbuffer']),
self.tot_time
)
self.writer.add_scalar(
'Train/mean_episode_length/time',
statistics.mean(locs['lenbuffer']), self.tot_time
)
str_header = f" \033[1m Learning iteration {locs['it']}/{locs['tot_iter']} \033[0m "
if len(locs['rewbuffer']) > 0:
log_string = (
f"""{'#' * width}\n"""
f"""{str_header.center(width, ' ')}\n\n"""
f"""{'Computation:':>{pad}} {fps:.0f} steps/s (collection: {locs['collection_time']:.3f}s, learning {locs['learn_time']:.3f}s)\n"""
f"""{'Mean action noise std:':>{pad}} {mean_std.item():.2f}\n"""
)
for key, value in loss_dict.items():
log_string += f"""{f'Mean {key} loss:':>{pad}} {value:.4f}\n"""
log_string += (
f"""{'Mean reward:':>{pad}} {statistics.mean(locs['rewbuffer']):.2f}\n"""
f"""{'Mean episode length:':>{pad}} {statistics.mean(locs['lenbuffer']):.2f}\n"""
)
else:
log_string = (
f"""{'#' * width}\n"""
f"""{str_header.center(width, ' ')}\n\n"""
f"""{'Computation:':>{pad}} {fps:.0f} steps/s (collection: {locs['collection_time']:.3f}s, learning {locs['learn_time']:.3f}s)\n"""
f"""{'Mean action noise std:':>{pad}} {mean_std.item():.2f}\n"""
)
for key, value in loss_dict.items():
log_string += f"""{f'{key}:':>{pad}} {value:.4f}\n"""
log_string += ep_string
log_string += (
f"""{'-' * width}\n"""
f"""{'Total timesteps:':>{pad}} {self.tot_timesteps}\n"""
f"""{'Iteration time:':>{pad}} {iteration_time:.2f}s\n"""
f"""{'Time elapsed:':>{pad}} {time.strftime('%H:%M:%S', time.gmtime(self.tot_time))}\n"""
)
print(log_string)
def save(self, path, infos=None):
saved_dict = {
'model_state_dict': self.alg.actor_critic.state_dict(),
'optimizer_state_dict': self.alg.optimizer.state_dict(),
'estimator_optimizer_state_dict':
self.alg.actor_critic.estimator.optimizer.state_dict(),
'iter': self.current_learning_iteration,
'infos': infos,
}
torch.save(saved_dict, path)
if self.cfg.get("upload_model", False) and hasattr(self.writer, "save_model"):
self.writer.save_model(path, self.current_learning_iteration)
def load(self, path, load_optimizer=True):
loaded_dict = torch.load(path, map_location=self.device, weights_only=False)
self.alg.actor_critic.load_state_dict(loaded_dict['model_state_dict'])
if load_optimizer:
self.alg.optimizer.load_state_dict(loaded_dict['optimizer_state_dict'])
self.alg.actor_critic.estimator.optimizer.load_state_dict(
loaded_dict['estimator_optimizer_state_dict']
)
self.current_learning_iteration = loaded_dict['iter']
return loaded_dict['infos']
def get_inference_policy(self, device=None):
self.alg.actor_critic.eval()
if device is not None:
self.alg.actor_critic.to(device)
return self.alg.actor_critic.act_inference
@@ -0,0 +1 @@
from .him_rollout_storage import HIMRolloutStorage
@@ -0,0 +1,170 @@
import torch
class HIMRolloutStorage:
class Transition:
def __init__(self):
self.observations = None
self.critic_observations = None
self.actions = None
self.rewards = None
self.dones = None
self.values = None
self.actions_log_prob = None
self.action_mean = None
self.action_sigma = None
self.next_critic_observations = None
def clear(self):
self.__init__()
def __init__(self, num_envs, num_transitions_per_env, obs_shape,
privileged_obs_shape, actions_shape, device='cpu'):
self.device = device
self.obs_shape = obs_shape
self.privileged_obs_shape = privileged_obs_shape
self.actions_shape = actions_shape
# Core
self.observations = torch.zeros(
num_transitions_per_env, num_envs, *obs_shape, device=self.device
)
if privileged_obs_shape[0] is not None:
self.privileged_observations = torch.zeros(
num_transitions_per_env, num_envs, *privileged_obs_shape, device=self.device
)
self.next_privileged_observations = torch.zeros(
num_transitions_per_env, num_envs, *privileged_obs_shape, device=self.device
)
else:
self.privileged_observations = None
self.next_privileged_observations = None
self.rewards = torch.zeros(num_transitions_per_env, num_envs, 1, device=self.device)
self.actions = torch.zeros(
num_transitions_per_env, num_envs, *actions_shape, device=self.device
)
self.dones = torch.zeros(
num_transitions_per_env, num_envs, 1, device=self.device
).byte()
# For PPO
self.actions_log_prob = torch.zeros(
num_transitions_per_env, num_envs, 1, device=self.device
)
self.values = torch.zeros(num_transitions_per_env, num_envs, 1, device=self.device)
self.returns = torch.zeros(num_transitions_per_env, num_envs, 1, device=self.device)
self.advantages = torch.zeros(
num_transitions_per_env, num_envs, 1, device=self.device
)
self.mu = torch.zeros(
num_transitions_per_env, num_envs, *actions_shape, device=self.device
)
self.sigma = torch.zeros(
num_transitions_per_env, num_envs, *actions_shape, device=self.device
)
self.num_transitions_per_env = num_transitions_per_env
self.num_envs = num_envs
self.step = 0
def add_transitions(self, transition: Transition):
if self.step >= self.num_transitions_per_env:
raise AssertionError("Rollout buffer overflow")
self.observations[self.step].copy_(transition.observations)
if self.privileged_observations is not None:
self.privileged_observations[self.step].copy_(transition.critic_observations)
if self.next_privileged_observations is not None:
self.next_privileged_observations[self.step].copy_(
transition.next_critic_observations
)
self.actions[self.step].copy_(transition.actions)
self.rewards[self.step].copy_(transition.rewards.view(-1, 1))
self.dones[self.step].copy_(transition.dones.view(-1, 1))
self.values[self.step].copy_(transition.values)
self.actions_log_prob[self.step].copy_(transition.actions_log_prob.view(-1, 1))
self.mu[self.step].copy_(transition.action_mean)
self.sigma[self.step].copy_(transition.action_sigma)
self.step += 1
def clear(self):
self.step = 0
def compute_returns(self, last_values, gamma, lam):
advantage = 0
for step in reversed(range(self.num_transitions_per_env)):
if step == self.num_transitions_per_env - 1:
next_values = last_values
else:
next_values = self.values[step + 1]
next_is_not_terminal = 1.0 - self.dones[step].float()
delta = (
self.rewards[step]
+ next_is_not_terminal * gamma * next_values
- self.values[step]
)
advantage = delta + next_is_not_terminal * gamma * lam * advantage
self.returns[step] = advantage + self.values[step]
self.advantages = self.returns - self.values
self.advantages = (self.advantages - self.advantages.mean()) / (
self.advantages.std() + 1e-8
)
def get_statistics(self):
done = self.dones
done[-1] = 1
flat_dones = done.permute(1, 0, 2).reshape(-1, 1)
done_indices = torch.cat(
(flat_dones.new_tensor([-1], dtype=torch.int64),
flat_dones.nonzero(as_tuple=False)[:, 0])
)
trajectory_lengths = done_indices[1:] - done_indices[:-1]
return trajectory_lengths.float().mean(), self.rewards.mean()
def mini_batch_generator(self, num_mini_batches, num_epochs=8):
batch_size = self.num_envs * self.num_transitions_per_env
mini_batch_size = batch_size // num_mini_batches
indices = torch.randperm(
num_mini_batches * mini_batch_size, requires_grad=False, device=self.device
)
observations = self.observations.flatten(0, 1)
if self.privileged_observations is not None:
critic_observations = self.privileged_observations.flatten(0, 1)
next_critic_observations = self.next_privileged_observations.flatten(0, 1)
else:
critic_observations = observations
next_critic_observations = observations
actions = self.actions.flatten(0, 1)
values = self.values.flatten(0, 1)
returns = self.returns.flatten(0, 1)
old_actions_log_prob = self.actions_log_prob.flatten(0, 1)
advantages = self.advantages.flatten(0, 1)
old_mu = self.mu.flatten(0, 1)
old_sigma = self.sigma.flatten(0, 1)
for epoch in range(num_epochs):
for i in range(num_mini_batches):
start = i * mini_batch_size
end = (i + 1) * mini_batch_size
batch_idx = indices[start:end]
obs_batch = observations[batch_idx]
next_critic_observations_batch = next_critic_observations[batch_idx]
critic_observations_batch = critic_observations[batch_idx]
actions_batch = actions[batch_idx]
target_values_batch = values[batch_idx]
returns_batch = returns[batch_idx]
old_actions_log_prob_batch = old_actions_log_prob[batch_idx]
advantages_batch = advantages[batch_idx]
old_mu_batch = old_mu[batch_idx]
old_sigma_batch = old_sigma[batch_idx]
yield (
obs_batch, critic_observations_batch, actions_batch,
next_critic_observations_batch, target_values_batch,
advantages_batch, returns_batch,
old_actions_log_prob_batch, old_mu_batch, old_sigma_batch,
)
@@ -0,0 +1,223 @@
"""Wrapper to adapt mjlab HimManagerBasedRlEnv for HIMLoco RSL-RL.
Handles:
- Observation history stacking (actor obs get history, critic gets single step)
- Termination privileged observation extraction
- 7-value step return: (obs, privileged_obs, rewards, dones, infos,
termination_ids, termination_privileged_obs)
"""
import torch
from him_mjlab.envs.him_manager_based_rl_env import HimManagerBasedRlEnv
from ..env.vec_env import VecEnv
class HimMjlabVecEnvWrapper(VecEnv):
"""Wraps mjlab HimManagerBasedRlEnv for HIMLoco RSL-RL.
Key features:
- Converts dict observations to flat tensors
- Maintains history buffers for actor observations
- Tracks terminated envs and their pre-reset privileged observations
- Returns 7-value step required by HIMOnPolicyRunner
"""
def __init__(
self,
env: HimManagerBasedRlEnv,
history_length: int = 5,
privileged_history_length: int = 0,
):
if not isinstance(env.unwrapped, HimManagerBasedRlEnv):
raise ValueError(
"The environment must be HimManagerBasedRlEnv. "
f"Got: {type(env)}"
)
self.env = env
self.reorder_indices = [0, 4, 8, 12, 1, 5, 9, 13, 2, 6, 10, 14, 3, 7, 11, 15]
self.num_envs = self.unwrapped.num_envs
self.device = self.unwrapped.device
self.max_episode_length = self.unwrapped.max_episode_length
self.num_actions = self.unwrapped.action_manager.total_action_dim
# Single-step observation dimensions from observation manager groups
self.num_one_step_obs = self.unwrapped.observation_manager.group_obs_dim["policy"][0]
self.history_length = history_length
self.num_obs = self.num_one_step_obs * (self.history_length + 1)
# History buffer for policy observations
self.obs_history_buf = torch.zeros(self.num_envs, self.num_obs, device=self.device)
# Termination tracking
self._termination_ids = torch.tensor([], dtype=torch.long, device=self.device)
# Privileged observation setup if critic group exists
if "critic" in self.unwrapped.observation_manager.group_obs_dim:
self.num_one_step_privileged_obs = (
self.unwrapped.observation_manager.group_obs_dim["critic"][0]
)
self.privileged_history_length = privileged_history_length
self.num_privileged_obs = self.num_one_step_privileged_obs * (
self.privileged_history_length + 1
)
self.privileged_obs_history_buf = torch.zeros(
self.num_envs, self.num_privileged_obs, device=self.device
)
self._termination_privileged_obs = torch.zeros(
0, self.num_privileged_obs, device=self.device
)
else:
self.num_one_step_privileged_obs = None
self.num_privileged_obs = None
self.privileged_obs_history_buf = None
self._termination_privileged_obs = None
# Reset at start since HIM runner does not call reset
self.env.reset()
@property
def cfg(self):
return self.env.cfg
@property
def render_mode(self):
return self.env.render_mode
@property
def observation_space(self):
return self.env.observation_space
@property
def action_space(self):
return self.env.action_space
@classmethod
def class_name(cls) -> str:
return cls.__name__
@property
def unwrapped(self):
return self.env.unwrapped
@property
def episode_length_buf(self) -> torch.Tensor:
return self.unwrapped.episode_length_buf
@episode_length_buf.setter
def episode_length_buf(self, value: torch.Tensor):
self.unwrapped.episode_length_buf = value
def seed(self, seed: int = -1) -> int:
return self.env.seed(seed)
def reset(self):
obs_dict, _ = self.env.reset()
policy_obs = obs_dict.get("policy", obs_dict[next(iter(obs_dict))])
return policy_obs, {"observations": obs_dict}
def get_observations(self) -> torch.Tensor:
return self.obs_history_buf
def get_privileged_observations(self) -> torch.Tensor | None:
return self.privileged_obs_history_buf
def compute_termination_observations(
self, env_ids: torch.Tensor, obs_before_reset: torch.Tensor
) -> torch.Tensor | None:
if len(env_ids) == 0:
return torch.zeros(
0, self.num_one_step_privileged_obs, device=self.device
)
return obs_before_reset[env_ids]
def step(self, actions: torch.Tensor):
"""Execute one time-step and return 7 values for HIMLoco.
Returns:
obs: History-stacked policy observations
privileged_obs: Privileged/critic observations
rewards: Rewards
dones: Done flags (terminated | truncated)
infos: Additional info dict
termination_ids: Indices of envs that terminated this step
termination_privileged_obs: Pre-reset privileged obs for terminated envs
"""
# Reorder actions from policy (Leg-by-Leg) to environment (Group)
actions_reordered = actions[:, self.reorder_indices]
# Step the environment (6-value return)
obs_dict, obs_before_reset, rewards, terminated, truncated, infos = \
self.env.step(actions_reordered)
dones = (terminated | truncated).to(dtype=torch.long)
if not self.unwrapped.cfg.is_finite_horizon:
infos["time_outs"] = truncated
# Extract policy observations from dict
current_obs = obs_dict.get("policy")
if current_obs is None:
first_key = next(iter(obs_dict.keys()))
current_obs = obs_dict[first_key]
# Update policy observation history buffer
if self.history_length > 0:
self.obs_history_buf = torch.cat(
(
current_obs[:, :self.num_one_step_obs],
self.obs_history_buf[:, :-self.num_one_step_obs],
),
dim=-1,
)
else:
self.obs_history_buf = current_obs
# Track terminated environments
self._termination_ids = torch.nonzero(dones, as_tuple=False).squeeze(-1)
# Update privileged observation history buffer
if "critic" in obs_dict and self.privileged_obs_history_buf is not None:
current_privileged_obs = obs_dict["critic"]
termination_observation = obs_before_reset.get("critic")
if termination_observation is None:
termination_observation = obs_before_reset.get("policy")
self._termination_privileged_obs = self.compute_termination_observations(
self._termination_ids, termination_observation
)
if self.privileged_history_length > 0:
self.privileged_obs_history_buf = torch.cat(
(
current_privileged_obs[:, :self.num_one_step_privileged_obs],
self.privileged_obs_history_buf[
:, :-self.num_one_step_privileged_obs
],
),
dim=-1,
)
else:
self.privileged_obs_history_buf = current_privileged_obs
# NaN/Inf guard
if torch.isnan(self.obs_history_buf).any() or torch.isinf(self.obs_history_buf).any():
raise ValueError("NaN/Inf detected in obs_history_buf!")
if torch.isnan(rewards).any() or torch.isinf(rewards).any():
raise ValueError("NaN/Inf detected in rewards!")
return (
self.obs_history_buf,
self.privileged_obs_history_buf,
rewards,
dones,
infos,
self._termination_ids,
self._termination_privileged_obs,
)
def close(self):
return self.env.close()
def __getattr__(self, name):
return getattr(self.env, name)
@@ -0,0 +1,3 @@
from .competition_terrains import RCWallTerrainCfg, RCLowBarTerrainCfg, RCPyramidStairsTerrainCfg
__all__ = ["RCWallTerrainCfg", "RCLowBarTerrainCfg", "RCPyramidStairsTerrainCfg"]
@@ -0,0 +1,188 @@
"""Competition obstacle terrains for locomotion tasks.
Implemented in accordance with the interface specifications in
mjlab/src/mjlab/terrains/primitive_terrains.py. Terrain difficulty (difficulty in [0, 1])
is automatically supplied via TerrainGeneratorCfg and curriculum.
"""
from __future__ import annotations
from dataclasses import dataclass
import mujoco
import numpy as np
from mjlab.terrains.terrain_generator import SubTerrainCfg, TerrainGeometry, TerrainOutput
from mjlab.terrains.utils import make_plane
from mjlab.terrains import BoxPyramidStairsTerrainCfg
from mjlab.utils.color import brand_ramp
# Color constants matching primitive_terrains.py style
_COLOR_ORANGE = (0.95, 0.55, 0.10)
_COLOR_PURPLE = (0.60, 0.20, 0.80)
@dataclass(kw_only=True)
class RCWallTerrainCfg(SubTerrainCfg):
"""Transverse wall obstacle terrain representing the race high wall.
The robot must sprint from the flat platform, vault over the wall, and proceed.
As difficulty scales from 0 to 1, the wall height increases linearly from
wall_height_range[0] to wall_height_range[1].
The actual physical wall specs are 1.0m width, 0.3m height, and 0.05m thickness.
Difficulty range goes up to 0.35m to slightly exceed actual competition difficulty.
"""
wall_height_range: tuple[float, float] = (0.0, 0.35)
"""Wall height range (m), linearly interpolated with difficulty."""
wall_thickness: float = 0.05
"""Wall thickness (m)."""
wall_length_frac: float = 0.8
"""Wall length fraction of the terrain width (leaving gaps for visualization/debugging)."""
platform_width: float = 1.5
"""Sprint platform width (m)."""
def function(
self,
difficulty: float,
spec: mujoco.MjSpec,
rng: np.random.Generator,
) -> TerrainOutput:
del rng
body = spec.body("terrain")
geometries: list[TerrainGeometry] = []
wall_height = self.wall_height_range[0] + difficulty * (
self.wall_height_range[1] - self.wall_height_range[0]
)
# -- Ground floor base plane --
floor_boxes = make_plane(body, self.size, 0.0, center_zero=False)
floor_color = (0.45, 0.45, 0.45, 1.0)
for box in floor_boxes:
geometries.append(TerrainGeometry(geom=box, color=floor_color))
if wall_height < 1e-3:
origin = np.array([self.size[0] / 2, self.size[1] / 2, 0.0])
return TerrainOutput(origin=origin, geometries=geometries)
# -- Wall geometry: centered on x-midline, oriented along y-axis --
wall_length = self.wall_length_frac * self.size[1]
cx = self.size[0] / 2
cy = self.size[1] / 2
wall_color = brand_ramp(_COLOR_ORANGE, difficulty)
wall_geom = body.add_geom(
type=mujoco.mjtGeom.mjGEOM_BOX,
size=(
self.wall_thickness / 2, # half-size x
wall_length / 2, # half-size y
wall_height / 2, # half-size z
),
pos=(cx, cy, wall_height / 2),
)
geometries.append(TerrainGeometry(geom=wall_geom, color=wall_color))
# Spawn origin is set to the left platform area (e.g., 1.5m) to allow
# the robot ample room to sprint/accelerate instead of spawning directly inside the obstacle.
origin = np.array([1.5, cy, 0.0])
return TerrainOutput(origin=origin, geometries=geometries)
@dataclass(kw_only=True)
class RCLowBarTerrainCfg(SubTerrainCfg):
"""Low clearance bar obstacle terrain.
A horizontal bar is placed along the y-axis, requiring the robot to crouch and duck under.
As difficulty scales from 0 to 1, the clearance height decreases linearly from
clearance_range[1] to clearance_range[0] (harder is lower).
The actual competition spec is 0.30m離地 and 1.0m width.
Here the range is configured as 0.25m to 0.35m to cover and exceed the standard specs.
"""
clearance_range: tuple[float, float] = (0.25, 0.35)
"""Bar ground clearance range (m). difficulty=0 -> highest/easiest, difficulty=1 -> lowest/hardest."""
bar_radius: float = 0.025
"""Bar cross-section radius (m)."""
bar_length_frac: float = 0.85
"""Bar length fraction of the terrain width (leaving gaps for visualization/debugging)."""
def function(
self,
difficulty: float,
spec: mujoco.MjSpec,
rng: np.random.Generator,
) -> TerrainOutput:
del rng
body = spec.body("terrain")
geometries: list[TerrainGeometry] = []
clearance = self.clearance_range[1] - difficulty * (
self.clearance_range[1] - self.clearance_range[0]
)
bar_z = clearance + self.bar_radius # Cylinder center height
# -- Ground floor --
floor_boxes = make_plane(body, self.size, 0.0, center_zero=False)
floor_color = (0.45, 0.45, 0.45, 1.0)
for box in floor_boxes:
geometries.append(TerrainGeometry(geom=box, color=floor_color))
# -- Low bar cylinder (oriented along y-axis) --
bar_length = self.bar_length_frac * self.size[1]
cx = self.size[0] / 2
cy = self.size[1] / 2
bar_color = brand_ramp(_COLOR_PURPLE, difficulty)
# MuJoCo cylinder defaults to z-axis orientation. Rotate 90 degrees around x-axis
# to align with y-axis: quat [cos(pi/4), sin(pi/4), 0, 0] -> [0.7071, 0.7071, 0.0, 0.0]
bar_geom = body.add_geom(
type=mujoco.mjtGeom.mjGEOM_CYLINDER,
size=(self.bar_radius, bar_length / 2),
pos=(cx, cy, bar_z),
)
bar_geom.quat = np.array([
np.cos(np.pi / 4), np.sin(np.pi / 4), 0.0, 0.0
])
geometries.append(TerrainGeometry(geom=bar_geom, color=bar_color))
# -- Side supporting posts --
post_color = bar_color
post_half_h = bar_z / 2
for y_off in [-bar_length / 2, bar_length / 2]:
post = body.add_geom(
type=mujoco.mjtGeom.mjGEOM_CYLINDER,
size=(self.bar_radius, post_half_h),
pos=(cx, cy + y_off, post_half_h),
)
geometries.append(TerrainGeometry(geom=post, color=post_color))
# Spawn origin is set to the left platform area (e.g., 1.5m) to allow
# the robot ample room to sprint/accelerate instead of spawning directly under the bar.
origin = np.array([1.5, cy, 0.0])
return TerrainOutput(origin=origin, geometries=geometries)
@dataclass(kw_only=True)
class RCPyramidStairsTerrainCfg(BoxPyramidStairsTerrainCfg):
"""Refactored pyramid stairs terrain with the spawn origin shifted to the left edge.
This ensures the robot can sprint/accelerate on flat ground before ascending,
substantially reducing early falls or flips on vertical steps.
"""
def function(
self,
difficulty: float,
spec: mujoco.MjSpec,
rng: np.random.Generator,
) -> TerrainOutput:
output = super().function(difficulty, spec, rng)
cy = self.size[1] / 2
# Relocate the spawn origin to the left platform zone
output.origin = np.array([1.5, cy, 0.0])
return output