init commit.

This commit is contained in:
wertyuilife
2026-03-23 03:17:59 +08:00
commit fce9166e87
99 changed files with 13778 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
[package]
# Semantic Versioning is used: https://semver.org/
version = "2.3.0"
# Description
category = "isaaclab"
readme = "README.md"
title = "RL Extension Library for Robots, Based on IsaacLab."
author = "Ziqi Fan"
maintainer = "Ziqi Fan"
description="RL Extension Library for Robots, Based on IsaacLab."
repository = "https://github.com/fan-ziqi/robot_lab.git"
keywords = ["extension", "robot_lab", "isaaclab"]
[dependencies]
"isaaclab" = {}
"isaaclab_assets" = {}
"isaaclab_mimic" = {}
"isaaclab_rl" = {}
"isaaclab_tasks" = {}
# NOTE: Add additional dependencies here
[[python.module]]
name = "robot_lab"
[isaaclab_settings]
# TODO: Uncomment and list any apt dependencies here.
# If none, leave it commented out.
# apt_deps = ["example_package"]
# TODO: Uncomment and provide path to a ros_ws
# with rosdeps to be installed. If none,
# leave it commented out.
# ros_ws = "path/from/extension_root/to/ros_ws"

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,761 @@
<?xml version='1.0' encoding='utf-8'?>
<robot name="go2_description">
<link name="base">
<inertial>
<origin xyz="0.021112 0 -0.005366" rpy="0 0 0" />
<mass value="6.921" />
<inertia ixx="0.02448" ixy="0.00012166" ixz="0.0014849" iyy="0.098077" iyz="-3.12E-05" izz="0.107" />
</inertial>
<visual>
<origin xyz="0 0 0" rpy="0 0 0" />
<geometry>
<mesh filename="package://go2_description/meshes/base.dae" />
</geometry>
<material name="深色橡胶_001-effect"><color rgba="0.6717055 0.6924257 0.7742702 1" /></material><material name="白色logo_001-effect"><color rgba="1 1 1 1" /></material><material name="黑色贴纸_007-effect"><color rgba="0.009018 0.009018 0.009018 1" /></material><material name="黑色金属_007-effect"><color rgba="0.01341 0.01341 0.01341 1" /></material><material name="黑色塑料_003-effect"><color rgba="0.023455 0.023455 0.023455 1" /></material></visual>
<collision>
<origin rpy="0 0 0" xyz="0 0 0" />
<geometry>
<box size="0.3762 0.0935 0.114" />
</geometry>
</collision>
</link>
<link name="Head_upper">
<inertial>
<origin xyz="0 0 0" rpy="0 0 0" />
<mass value="0.001" />
<inertia ixx="9.6e-06" ixy="0" ixz="0" iyy="9.6e-06" iyz="0" izz="9.6e-06" />
</inertial>
<collision>
<origin xyz="0 0 0" rpy="0 0 0" />
<geometry>
<cylinder radius="0.05" length="0.09" />
</geometry>
</collision>
</link>
<joint name="Head_upper_joint" type="fixed" dont_collapse="true">
<origin xyz="0.285 0 0.01" rpy="0 0 0" />
<parent link="base" />
<child link="Head_upper" />
<axis xyz="0 0 0" />
</joint>
<link name="Head_lower">
<inertial>
<origin xyz="0 0 0" rpy="0 0 0" />
<mass value="0.001" />
<inertia ixx="9.6e-06" ixy="0" ixz="0" iyy="9.6e-06" iyz="0" izz="9.6e-06" />
</inertial>
<collision>
<origin xyz="0 0 0" rpy="0 0 0" />
<geometry>
<sphere radius="0.047" />
</geometry>
</collision>
</link>
<joint name="Head_lower_joint" type="fixed" dont_collapse="true">
<origin xyz="0.008 0 -0.07" rpy="0 0 0" />
<parent link="Head_upper" />
<child link="Head_lower" />
<axis xyz="0 0 0" />
</joint>
<link name="FL_hip">
<inertial>
<origin xyz="-0.0054 0.00194 -0.000105" rpy="0 0 0" />
<mass value="0.678" />
<inertia ixx="0.00048" ixy="-3.01E-06" ixz="1.11E-06" iyy="0.000884" iyz="-1.42E-06" izz="0.000596" />
</inertial>
<visual>
<origin xyz="0 0 0" rpy="0 0 0" />
<geometry>
<mesh filename="package://go2_description/meshes/hip.dae" />
</geometry>
<material name="深色橡胶_001-effect"><color rgba="0.6717055 0.6924257 0.7742702 1" /></material><material name="Material-effect"><color rgba="0.9 0.95 0.95 1" /></material></visual>
<collision>
<origin rpy="1.5707963267948966 0 0" xyz="0 0.08 0" />
<geometry>
<cylinder length="0.04" radius="0.046" />
</geometry>
</collision>
</link>
<joint name="FL_hip_joint" type="revolute">
<origin xyz="0.1934 0.0465 0" rpy="0 0 0" />
<parent link="base" />
<child link="FL_hip" />
<axis xyz="1 0 0" />
<limit lower="-1.0472" upper="1.0472" effort="23.7" velocity="30.1" />
</joint>
<link name="FL_hip_rotor">
<inertial>
<origin rpy="0 0 0" xyz="0.0 0.0 0.0" />
<mass value="0.089" />
<inertia ixx="0.000111842" ixy="0.0" ixz="0.0" iyy="0.000059647" iyz="0.0" izz="0.000059647" />
</inertial>
</link>
<joint name="FL_hip_rotor_joint" type="fixed">
<origin rpy="0 0 0" xyz="0.11215 0.04675 0" />
<parent link="base" />
<child link="FL_hip_rotor" />
</joint>
<link name="FL_thigh">
<inertial>
<origin xyz="-0.00374 -0.0223 -0.0327" rpy="0 0 0" />
<mass value="1.152" />
<inertia ixx="0.00584" ixy="8.72E-05" ixz="-0.000289" iyy="0.0058" iyz="0.000808" izz="0.00103" />
</inertial>
<visual>
<origin xyz="0 0 0" rpy="0 0 0" />
<geometry>
<mesh filename="package://go2_description/meshes/thigh.dae" />
</geometry>
<material name="深色橡胶_003-effect"><color rgba="0.6717055 0.6924257 0.7742702 1" /></material><material name="Material_009-effect"><color rgba="0.9 0.95 0.95 1" /></material></visual>
<collision>
<origin rpy="0 1.5707963267948966 0" xyz="0 0 -0.1065" />
<geometry>
<box size="0.213 0.0245 0.034" />
</geometry>
</collision>
</link>
<joint name="FL_thigh_joint" type="revolute">
<origin xyz="0 0.0955 0" rpy="0 0 0" />
<parent link="FL_hip" />
<child link="FL_thigh" />
<axis xyz="0 1 0" />
<limit lower="-1.5708" upper="3.4907" effort="23.7" velocity="30.1" />
</joint>
<link name="FL_thigh_rotor">
<inertial>
<origin rpy="0 0 0" xyz="0.0 0.0 0.0" />
<mass value="0.089" />
<inertia ixx="0.000059647" ixy="0.0" ixz="0.0" iyy="0.000111842" iyz="0.0" izz="0.000059647" />
</inertial>
</link>
<joint name="FL_thigh_rotor_joint" type="fixed">
<origin rpy="0 0 0" xyz="0 -0.00015 0" />
<parent link="FL_hip" />
<child link="FL_thigh_rotor" />
</joint>
<link name="FL_calf">
<inertial>
<origin xyz="0.00548 -0.000975 -0.115" rpy="0 0 0" />
<mass value="0.154" />
<inertia ixx="0.00108" ixy="3.4E-07" ixz="1.72E-05" iyy="0.0011" iyz="8.28E-06" izz="3.29E-05" />
</inertial>
<visual>
<origin xyz="0 0 0" rpy="0 0 0" />
<geometry>
<mesh filename="package://go2_description/meshes/calf.dae" />
</geometry>
<material name="黑色足端_005-effect"><color rgba="0 0 0 1" /></material><material name="深色橡胶_005-effect"><color rgba="0.6717055 0.6924257 0.7742702 1" /></material></visual>
<collision>
<origin rpy="0 -0.21 0" xyz="0.008 0 -0.06" />
<geometry>
<cylinder length="0.12" radius="0.012" />
</geometry>
</collision>
</link>
<joint name="FL_calf_joint" type="revolute">
<origin xyz="0 0 -0.213" rpy="0 0 0" />
<parent link="FL_thigh" />
<child link="FL_calf" />
<axis xyz="0 1 0" />
<limit lower="-2.7227" upper="-0.83776" effort="45.43" velocity="15.70" />
</joint>
<link name="FL_calflower">
<collision>
<origin rpy="0 0 0" xyz="0 0 0" />
<geometry>
<cylinder length="0.065" radius="0.011" />
</geometry>
</collision>
</link>
<joint name="FL_calflower_joint" type="fixed">
<origin xyz="0.020 0 -0.148" rpy="0 0.05 0" />
<parent link="FL_calf" />
<child link="FL_calflower" />
<axis xyz="0 0 0" />
</joint>
<link name="FL_calflower1">
<collision>
<origin rpy="0 0 0" xyz="0 0 0" />
<geometry>
<cylinder length="0.03" radius="0.0155" />
</geometry>
</collision>
</link>
<joint name="FL_calflower1_joint" type="fixed">
<origin xyz="-0.01 0 -0.04" rpy="0 0.48 0" />
<parent link="FL_calflower" />
<child link="FL_calflower1" />
<axis xyz="0 0 0" />
</joint>
<link name="FL_calf_rotor">
<inertial>
<origin rpy="0 0 0" xyz="0.0 0.0 0.0" />
<mass value="0.089" />
<inertia ixx="0.000059647" ixy="0.0" ixz="0.0" iyy="0.000111842" iyz="0.0" izz="0.000059647" />
</inertial>
</link>
<joint name="FL_calf_rotor_joint" type="fixed">
<origin rpy="0 0 0" xyz="0 -0.03235 0" />
<parent link="FL_thigh" />
<child link="FL_calf_rotor" />
</joint>
<link name="FL_foot">
<inertial>
<origin xyz="0 0 0" rpy="0 0 0" />
<mass value="0.04" />
<inertia ixx="9.6e-06" ixy="0" ixz="0" iyy="9.6e-06" iyz="0" izz="9.6e-06" />
</inertial>
<visual>
<origin xyz="0 0 0" rpy="0 0 0" />
<geometry>
<mesh filename="package://go2_description/meshes/foot.dae" />
</geometry>
<material name="黑色足端_007-effect"><color rgba="0 0 0 1" /></material></visual>
<collision>
<origin rpy="0 0 0" xyz="-0.002 0 0" />
<geometry>
<sphere radius="0.022" />
</geometry>
</collision>
</link>
<joint name="FL_foot_joint" type="fixed" dont_collapse="true">
<origin xyz="0 0 -0.213" rpy="0 0 0" />
<parent link="FL_calf" />
<child link="FL_foot" />
<axis xyz="0 0 0" />
</joint>
<link name="FR_hip">
<inertial>
<origin xyz="-0.0054 -0.00194 -0.000105" rpy="0 0 0" />
<mass value="0.678" />
<inertia ixx="0.00048" ixy="3.01E-06" ixz="1.11E-06" iyy="0.000884" iyz="1.42E-06" izz="0.000596" />
</inertial>
<visual>
<origin xyz="0 0 0" rpy="3.1415 0 0" />
<geometry>
<mesh filename="package://go2_description/meshes/hip.dae" />
</geometry>
<material name="深色橡胶_001-effect"><color rgba="0.6717055 0.6924257 0.7742702 1" /></material><material name="Material-effect"><color rgba="0.9 0.95 0.95 1" /></material></visual>
<collision>
<origin rpy="1.5707963267948966 0 0" xyz="0 -0.08 0" />
<geometry>
<cylinder length="0.04" radius="0.046" />
</geometry>
</collision>
</link>
<joint name="FR_hip_joint" type="revolute">
<origin xyz="0.1934 -0.0465 0" rpy="0 0 0" />
<parent link="base" />
<child link="FR_hip" />
<axis xyz="1 0 0" />
<limit lower="-1.0472" upper="1.0472" effort="23.7" velocity="30.1" />
</joint>
<link name="FR_hip_rotor">
<inertial>
<origin rpy="0 0 0" xyz="0.0 0.0 0.0" />
<mass value="0.089" />
<inertia ixx="0.000111842" ixy="0.0" ixz="0.0" iyy="0.000059647" iyz="0.0" izz="0.000059647" />
</inertial>
</link>
<joint name="FR_hip_rotor_joint" type="fixed">
<origin rpy="0 0 0" xyz="0.11215 -0.04675 0" />
<parent link="base" />
<child link="FR_hip_rotor" />
</joint>
<link name="FR_thigh">
<inertial>
<origin xyz="-0.00374 0.0223 -0.0327" rpy="0 0 0" />
<mass value="1.152" />
<inertia ixx="0.00584" ixy="-8.72E-05" ixz="-0.000289" iyy="0.0058" iyz="-0.000808" izz="0.00103" />
</inertial>
<visual>
<origin xyz="0 0 0" rpy="0 0 0" />
<geometry>
<mesh filename="package://go2_description/meshes/thigh_mirror.dae" />
</geometry>
<material name="深色橡胶_012-effect"><color rgba="0.6717055 0.6924257 0.7742702 1" /></material><material name="Material_011-effect"><color rgba="0.9 0.95 0.95 1" /></material></visual>
<collision>
<origin rpy="0 1.5707963267948966 0" xyz="0 0 -0.1065" />
<geometry>
<box size="0.213 0.0245 0.034" />
</geometry>
</collision>
</link>
<joint name="FR_thigh_joint" type="revolute">
<origin xyz="0 -0.0955 0" rpy="0 0 0" />
<parent link="FR_hip" />
<child link="FR_thigh" />
<axis xyz="0 1 0" />
<limit lower="-1.5708" upper="3.4907" effort="23.7" velocity="30.1" />
</joint>
<link name="FR_thigh_rotor">
<inertial>
<origin rpy="0 0 0" xyz="0.0 0.0 0.0" />
<mass value="0.089" />
<inertia ixx="0.000059647" ixy="0.0" ixz="0.0" iyy="0.000111842" iyz="0.0" izz="0.000059647" />
</inertial>
</link>
<joint name="FR_thigh_rotor_joint" type="fixed">
<origin rpy="0 0 0" xyz="0 0.00015 0" />
<parent link="FR_hip" />
<child link="FR_thigh_rotor" />
</joint>
<link name="FR_calf">
<inertial>
<origin xyz="0.00548 0.000975 -0.115" rpy="0 0 0" />
<mass value="0.154" />
<inertia ixx="0.00108" ixy="-3.4E-07" ixz="1.72E-05" iyy="0.0011" iyz="-8.28E-06" izz="3.29E-05" />
</inertial>
<visual>
<origin xyz="0 0 0" rpy="0 0 0" />
<geometry>
<mesh filename="package://go2_description/meshes/calf_mirror.dae" />
</geometry>
<material name="黑色足端_006-effect"><color rgba="0 0 0 1" /></material><material name="深色橡胶_007-effect"><color rgba="0.6717055 0.6924257 0.7742702 1" /></material></visual>
<collision>
<origin rpy="0 -0.2 0" xyz="0.01 0 -0.06" />
<geometry>
<cylinder length="0.12" radius="0.013" />
</geometry>
</collision>
</link>
<joint name="FR_calf_joint" type="revolute">
<origin xyz="0 0 -0.213" rpy="0 0 0" />
<parent link="FR_thigh" />
<child link="FR_calf" />
<axis xyz="0 1 0" />
<limit lower="-2.7227" upper="-0.83776" effort="45.43" velocity="15.70" />
</joint>
<link name="FR_calflower">
<collision>
<origin rpy="0 0 0" xyz="0 0 0" />
<geometry>
<cylinder length="0.065" radius="0.011" />
</geometry>
</collision>
</link>
<joint name="FR_calflower_joint" type="fixed">
<origin xyz="0.020 0 -0.148" rpy="0 0.05 0" />
<parent link="FR_calf" />
<child link="FR_calflower" />
<axis xyz="0 0 0" />
</joint>
<link name="FR_calflower1">
<collision>
<origin rpy="0 0 0" xyz="0 0 0" />
<geometry>
<cylinder length="0.03" radius="0.0155" />
</geometry>
</collision>
</link>
<joint name="FR_calflower1_joint" type="fixed">
<origin xyz="-0.01 0 -0.04" rpy="0 0.48 0" />
<parent link="FR_calflower" />
<child link="FR_calflower1" />
<axis xyz="0 0 0" />
</joint>
<link name="FR_calf_rotor">
<inertial>
<origin rpy="0 0 0" xyz="0.0 0.0 0.0" />
<mass value="0.089" />
<inertia ixx="0.000059647" ixy="0.0" ixz="0.0" iyy="0.000111842" iyz="0.0" izz="0.000059647" />
</inertial>
</link>
<joint name="FR_calf_rotor_joint" type="fixed">
<origin rpy="0 0 0" xyz="0 0.03235 0" />
<parent link="FR_thigh" />
<child link="FR_calf_rotor" />
</joint>
<link name="FR_foot">
<inertial>
<origin xyz="0 0 0" rpy="0 0 0" />
<mass value="0.04" />
<inertia ixx="9.6e-06" ixy="0" ixz="0" iyy="9.6e-06" iyz="0" izz="9.6e-06" />
</inertial>
<visual>
<origin xyz="0 0 0" rpy="0 0 0" />
<geometry>
<mesh filename="package://go2_description/meshes/foot.dae" />
</geometry>
<material name="黑色足端_007-effect"><color rgba="0 0 0 1" /></material></visual>
<collision>
<origin rpy="0 0 0" xyz="-0.002 0 0" />
<geometry>
<sphere radius="0.022" />
</geometry>
</collision>
</link>
<joint name="FR_foot_joint" type="fixed" dont_collapse="true">
<origin xyz="0 0 -0.213" rpy="0 0 0" />
<parent link="FR_calf" />
<child link="FR_foot" />
<axis xyz="0 0 0" />
</joint>
<link name="RL_hip">
<inertial>
<origin xyz="0.0054 0.00194 -0.000105" rpy="0 0 0" />
<mass value="0.678" />
<inertia ixx="0.00048" ixy="3.01E-06" ixz="-1.11E-06" iyy="0.000884" iyz="-1.42E-06" izz="0.000596" />
</inertial>
<visual>
<origin xyz="0 0 0" rpy="0 3.1415 0" />
<geometry>
<mesh filename="package://go2_description/meshes/hip.dae" />
</geometry>
<material name="深色橡胶_001-effect"><color rgba="0.6717055 0.6924257 0.7742702 1" /></material><material name="Material-effect"><color rgba="0.9 0.95 0.95 1" /></material></visual>
<collision>
<origin rpy="1.5707963267948966 0 0" xyz="0 0.08 0" />
<geometry>
<cylinder length="0.04" radius="0.046" />
</geometry>
</collision>
</link>
<joint name="RL_hip_joint" type="revolute">
<origin xyz="-0.1934 0.0465 0" rpy="0 0 0" />
<parent link="base" />
<child link="RL_hip" />
<axis xyz="1 0 0" />
<limit lower="-1.0472" upper="1.0472" effort="23.7" velocity="30.1" />
</joint>
<link name="RL_hip_rotor">
<inertial>
<origin rpy="0 0 0" xyz="0.0 0.0 0.0" />
<mass value="0.089" />
<inertia ixx="0.000111842" ixy="0.0" ixz="0.0" iyy="0.000059647" iyz="0.0" izz="0.000059647" />
</inertial>
</link>
<joint name="RL_hip_rotor_joint" type="fixed">
<origin rpy="0 0 0" xyz="-0.11215 0.04675 0" />
<parent link="base" />
<child link="RL_hip_rotor" />
</joint>
<link name="RL_thigh">
<inertial>
<origin xyz="-0.00374 -0.0223 -0.0327" rpy="0 0 0" />
<mass value="1.152" />
<inertia ixx="0.00584" ixy="8.72E-05" ixz="-0.000289" iyy="0.0058" iyz="0.000808" izz="0.00103" />
</inertial>
<visual>
<origin xyz="0 0 0" rpy="0 0 0" />
<geometry>
<mesh filename="package://go2_description/meshes/thigh.dae" />
</geometry>
<material name="深色橡胶_003-effect"><color rgba="0.6717055 0.6924257 0.7742702 1" /></material><material name="Material_009-effect"><color rgba="0.9 0.95 0.95 1" /></material></visual>
<collision>
<origin rpy="0 1.5707963267948966 0" xyz="0 0 -0.1065" />
<geometry>
<box size="0.213 0.0245 0.034" />
</geometry>
</collision>
</link>
<joint name="RL_thigh_joint" type="revolute">
<origin xyz="0 0.0955 0" rpy="0 0 0" />
<parent link="RL_hip" />
<child link="RL_thigh" />
<axis xyz="0 1 0" />
<limit lower="-0.5236" upper="4.5379" effort="23.7" velocity="30.1" />
</joint>
<link name="RL_thigh_rotor">
<inertial>
<origin rpy="0 0 0" xyz="0.0 0.0 0.0" />
<mass value="0.089" />
<inertia ixx="0.000059647" ixy="0.0" ixz="0.0" iyy="0.000111842" iyz="0.0" izz="0.000059647" />
</inertial>
</link>
<joint name="RL_thigh_rotor_joint" type="fixed">
<origin rpy="0 0 0" xyz="0 -0.00015 0" />
<parent link="RL_hip" />
<child link="RL_thigh_rotor" />
</joint>
<link name="RL_calf">
<inertial>
<origin xyz="0.00548 -0.000975 -0.115" rpy="0 0 0" />
<mass value="0.154" />
<inertia ixx="0.00108" ixy="3.4E-07" ixz="1.72E-05" iyy="0.0011" iyz="8.28E-06" izz="3.29E-05" />
</inertial>
<visual>
<origin xyz="0 0 0" rpy="0 0 0" />
<geometry>
<mesh filename="package://go2_description/meshes/calf.dae" />
</geometry>
<material name="黑色足端_005-effect"><color rgba="0 0 0 1" /></material><material name="深色橡胶_005-effect"><color rgba="0.6717055 0.6924257 0.7742702 1" /></material></visual>
<collision>
<origin rpy="0 -0.2 0" xyz="0.01 0 -0.06" />
<geometry>
<cylinder length="0.12" radius="0.013" />
</geometry>
</collision>
</link>
<joint name="RL_calf_joint" type="revolute">
<origin xyz="0 0 -0.213" rpy="0 0 0" />
<parent link="RL_thigh" />
<child link="RL_calf" />
<axis xyz="0 1 0" />
<limit lower="-2.7227" upper="-0.83776" effort="45.43" velocity="15.70" />
</joint>
<link name="RL_calflower">
<collision>
<origin rpy="0 0 0" xyz="0 0 0" />
<geometry>
<cylinder length="0.065" radius="0.011" />
</geometry>
</collision>
</link>
<joint name="RL_calflower_joint" type="fixed">
<origin xyz="0.020 0 -0.148" rpy="0 0.05 0" />
<parent link="RL_calf" />
<child link="RL_calflower" />
<axis xyz="0 0 0" />
</joint>
<link name="RL_calflower1">
<collision>
<origin rpy="0 0 0" xyz="0 0 0" />
<geometry>
<cylinder length="0.03" radius="0.0155" />
</geometry>
</collision>
</link>
<joint name="RL_calflower1_joint" type="fixed">
<origin xyz="-0.01 0 -0.04" rpy="0 0.48 0" />
<parent link="RL_calflower" />
<child link="RL_calflower1" />
<axis xyz="0 0 0" />
</joint>
<link name="RL_calf_rotor">
<inertial>
<origin rpy="0 0 0" xyz="0.0 0.0 0.0" />
<mass value="0.089" />
<inertia ixx="0.000059647" ixy="0.0" ixz="0.0" iyy="0.000111842" iyz="0.0" izz="0.000059647" />
</inertial>
</link>
<joint name="RL_calf_rotor_joint" type="fixed">
<origin rpy="0 0 0" xyz="0 -0.03235 0" />
<parent link="RL_thigh" />
<child link="RL_calf_rotor" />
</joint>
<link name="RL_foot">
<inertial>
<origin xyz="0 0 0" rpy="0 0 0" />
<mass value="0.04" />
<inertia ixx="9.6e-06" ixy="0" ixz="0" iyy="9.6e-06" iyz="0" izz="9.6e-06" />
</inertial>
<visual>
<origin xyz="0 0 0" rpy="0 0 0" />
<geometry>
<mesh filename="package://go2_description/meshes/foot.dae" />
</geometry>
<material name="黑色足端_007-effect"><color rgba="0 0 0 1" /></material></visual>
<collision>
<origin rpy="0 0 0" xyz="-0.002 0 0" />
<geometry>
<sphere radius="0.022" />
</geometry>
</collision>
</link>
<joint name="RL_foot_joint" type="fixed" dont_collapse="true">
<origin xyz="0 0 -0.213" rpy="0 0 0" />
<parent link="RL_calf" />
<child link="RL_foot" />
<axis xyz="0 0 0" />
</joint>
<link name="RR_hip">
<inertial>
<origin xyz="0.0054 -0.00194 -0.000105" rpy="0 0 0" />
<mass value="0.678" />
<inertia ixx="0.00048" ixy="-3.01E-06" ixz="-1.11E-06" iyy="0.000884" iyz="1.42E-06" izz="0.000596" />
</inertial>
<visual>
<origin xyz="0 0 0" rpy="3.1415 3.1415 0" />
<geometry>
<mesh filename="package://go2_description/meshes/hip.dae" />
</geometry>
<material name="深色橡胶_001-effect"><color rgba="0.6717055 0.6924257 0.7742702 1" /></material><material name="Material-effect"><color rgba="0.9 0.95 0.95 1" /></material></visual>
<collision>
<origin rpy="1.5707963267948966 0 0" xyz="0 -0.08 0" />
<geometry>
<cylinder length="0.04" radius="0.046" />
</geometry>
</collision>
</link>
<joint name="RR_hip_joint" type="revolute">
<origin xyz="-0.1934 -0.0465 0" rpy="0 0 0" />
<parent link="base" />
<child link="RR_hip" />
<axis xyz="1 0 0" />
<limit lower="-1.0472" upper="1.0472" effort="23.7" velocity="30.1" />
</joint>
<link name="RR_hip_rotor">
<inertial>
<origin rpy="0 0 0" xyz="0.0 0.0 0.0" />
<mass value="0.089" />
<inertia ixx="0.000111842" ixy="0.0" ixz="0.0" iyy="0.000059647" iyz="0.0" izz="0.000059647" />
</inertial>
</link>
<joint name="RR_hip_rotor_joint" type="fixed">
<origin rpy="0 0 0" xyz="-0.11215 -0.04675 0" />
<parent link="base" />
<child link="RR_hip_rotor" />
</joint>
<link name="RR_thigh">
<inertial>
<origin xyz="-0.00374 0.0223 -0.0327" rpy="0 0 0" />
<mass value="1.152" />
<inertia ixx="0.00584" ixy="-8.72E-05" ixz="-0.000289" iyy="0.0058" iyz="-0.000808" izz="0.00103" />
</inertial>
<visual>
<origin xyz="0 0 0" rpy="0 0 0" />
<geometry>
<mesh filename="package://go2_description/meshes/thigh_mirror.dae" />
</geometry>
<material name="深色橡胶_012-effect"><color rgba="0.6717055 0.6924257 0.7742702 1" /></material><material name="Material_011-effect"><color rgba="0.9 0.95 0.95 1" /></material></visual>
<collision>
<origin rpy="0 1.5707963267948966 0" xyz="0 0 -0.1065" />
<geometry>
<box size="0.213 0.0245 0.034" />
</geometry>
</collision>
</link>
<joint name="RR_thigh_joint" type="revolute">
<origin xyz="0 -0.0955 0" rpy="0 0 0" />
<parent link="RR_hip" />
<child link="RR_thigh" />
<axis xyz="0 1 0" />
<limit lower="-0.5236" upper="4.5379" effort="23.7" velocity="30.1" />
</joint>
<link name="RR_thigh_rotor">
<inertial>
<origin rpy="0 0 0" xyz="0.0 0.0 0.0" />
<mass value="0.089" />
<inertia ixx="0.000059647" ixy="0.0" ixz="0.0" iyy="0.000111842" iyz="0.0" izz="0.000059647" />
</inertial>
</link>
<joint name="RR_thigh_rotor_joint" type="fixed">
<origin rpy="0 0 0" xyz="0 0.00015 0" />
<parent link="RR_hip" />
<child link="RR_thigh_rotor" />
</joint>
<link name="RR_calf">
<inertial>
<origin xyz="0.00548 0.000975 -0.115" rpy="0 0 0" />
<mass value="0.154" />
<inertia ixx="0.00108" ixy="-3.4E-07" ixz="1.72E-05" iyy="0.0011" iyz="-8.28E-06" izz="3.29E-05" />
</inertial>
<visual>
<origin xyz="0 0 0" rpy="0 0 0" />
<geometry>
<mesh filename="package://go2_description/meshes/calf_mirror.dae" />
</geometry>
<material name="黑色足端_006-effect"><color rgba="0 0 0 1" /></material><material name="深色橡胶_007-effect"><color rgba="0.6717055 0.6924257 0.7742702 1" /></material></visual>
<collision>
<origin rpy="0 -0.2 0" xyz="0.01 0 -0.06" />
<geometry>
<cylinder length="0.12" radius="0.013" />
</geometry>
</collision>
</link>
<joint name="RR_calf_joint" type="revolute">
<origin xyz="0 0 -0.213" rpy="0 0 0" />
<parent link="RR_thigh" />
<child link="RR_calf" />
<axis xyz="0 1 0" />
<limit lower="-2.7227" upper="-0.83776" effort="45.43" velocity="15.70" />
</joint>
<link name="RR_calflower">
<collision>
<origin rpy="0 0 0" xyz="0 0 0" />
<geometry>
<cylinder length="0.065" radius="0.011" />
</geometry>
</collision>
</link>
<joint name="RR_calflower_joint" type="fixed">
<origin xyz="0.020 0 -0.148" rpy="0 0.05 0" />
<parent link="RR_calf" />
<child link="RR_calflower" />
<axis xyz="0 0 0" />
</joint>
<link name="RR_calflower1">
<collision>
<origin rpy="0 0 0" xyz="0 0 0" />
<geometry>
<cylinder length="0.03" radius="0.0155" />
</geometry>
</collision>
</link>
<joint name="RR_calflower1_joint" type="fixed">
<origin xyz="-0.01 0 -0.04" rpy="0 0.48 0" />
<parent link="RR_calflower" />
<child link="RR_calflower1" />
<axis xyz="0 0 0" />
</joint>
<link name="RR_calf_rotor">
<inertial>
<origin rpy="0 0 0" xyz="0.0 0.0 0.0" />
<mass value="0.089" />
<inertia ixx="0.000059647" ixy="0.0" ixz="0.0" iyy="0.000111842" iyz="0.0" izz="0.000059647" />
</inertial>
</link>
<joint name="RR_calf_rotor_joint" type="fixed">
<origin rpy="0 0 0" xyz="0 0.03235 0" />
<parent link="RR_thigh" />
<child link="RR_calf_rotor" />
</joint>
<link name="RR_foot">
<inertial>
<origin xyz="0 0 0" rpy="0 0 0" />
<mass value="0.04" />
<inertia ixx="9.6e-06" ixy="0" ixz="0" iyy="9.6e-06" iyz="0" izz="9.6e-06" />
</inertial>
<visual>
<origin xyz="0 0 0" rpy="0 0 0" />
<geometry>
<mesh filename="package://go2_description/meshes/foot.dae" />
</geometry>
<material name="黑色足端_007-effect"><color rgba="0 0 0 1" /></material></visual>
<collision>
<origin rpy="0 0 0" xyz="-0.002 0 0" />
<geometry>
<sphere radius="0.022" />
</geometry>
</collision>
</link>
<joint name="RR_foot_joint" type="fixed" dont_collapse="true">
<origin xyz="0 0 -0.213" rpy="0 0 0" />
<parent link="RR_calf" />
<child link="RR_foot" />
<axis xyz="0 0 0" />
</joint>
<link name="imu">
<inertial>
<origin xyz="0 0 0" rpy="0 0 0" />
<mass value="0" />
<inertia ixx="0" ixy="0" ixz="0" iyy="0" iyz="0" izz="0" />
</inertial>
</link>
<joint name="imu_joint" type="fixed">
<origin xyz="-0.02557 0 0.04232" rpy="0 0 0" />
<parent link="base" />
<child link="imu" />
<axis xyz="0 0 0" />
</joint>
<link name="radar">
<inertial>
<origin xyz="0 0 0" rpy="0 0 0" />
<mass value="0" />
<inertia ixx="0" ixy="0" ixz="0" iyy="0" iyz="0" izz="0" />
</inertial>
</link>
<joint name="radar_joint" type="fixed">
<origin xyz="0.28945 0 -0.046825" rpy="0 2.8782 0" />
<parent link="base" />
<child link="radar" />
<axis xyz="0 0 0" />
</joint>
<joint name="front_camera_joint" type="fixed">
<origin rpy="0 0 0" xyz="0.32715 -0.00003 0.04297" />
<parent link="base" />
<child link="front_camera" />
</joint>
<link name="front_camera">
</link>
</robot>

View File

@@ -0,0 +1,3 @@
[build-system]
requires = ["setuptools", "wheel", "toml"]
build-backend = "setuptools.build_meta"

View File

@@ -0,0 +1,12 @@
# Copyright (c) 2024-2025 Ziqi Fan
# SPDX-License-Identifier: Apache-2.0
"""
Python module serving as a project/extension template.
"""
# Register Gym environments.
# from .tasks import *
# Register UI extensions.
from .ui_extension_example import *

View File

@@ -0,0 +1,29 @@
# Copyright (c) 2024-2025 Ziqi Fan
# SPDX-License-Identifier: Apache-2.0
# Copyright (c) 2022-2025, The Isaac Lab Project Developers.
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
"""Package containing asset and sensor configurations."""
import os
import toml
##
# Configuration for different assets.
##
# Conveniences to other module directories via relative paths
ISAACLAB_ASSETS_EXT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))
"""Path to the extension source directory."""
ISAACLAB_ASSETS_DATA_DIR = os.path.join(ISAACLAB_ASSETS_EXT_DIR, "data")
"""Path to the extension data directory."""
ISAACLAB_ASSETS_METADATA = toml.load(os.path.join(ISAACLAB_ASSETS_EXT_DIR, "config", "extension.toml"))
"""Extension metadata dictionary parsed from the extension.toml file."""
# Configure the module-level variables
__version__ = ISAACLAB_ASSETS_METADATA["package"]["version"]

View File

@@ -0,0 +1,70 @@
# Copyright (c) 2024-2025 Ziqi Fan
# SPDX-License-Identifier: Apache-2.0
"""Configuration for Unitree robots.
Reference: https://github.com/unitreerobotics/unitree_ros
"""
import isaaclab.sim as sim_utils
from isaaclab.actuators import DCMotorCfg
from isaaclab.assets.articulation import ArticulationCfg
from robot_lab.assets import ISAACLAB_ASSETS_DATA_DIR
##
# Configuration
##
UNITREE_GO2_CFG = ArticulationCfg(
spawn=sim_utils.UrdfFileCfg(
fix_base=False,
merge_fixed_joints=True,
replace_cylinders_with_capsules=False,
asset_path=f"{ISAACLAB_ASSETS_DATA_DIR}/Robots/unitree/go2_description/urdf/go2_description.urdf",
activate_contact_sensors=True,
rigid_props=sim_utils.RigidBodyPropertiesCfg(
disable_gravity=False,
retain_accelerations=False,
linear_damping=0.0,
angular_damping=0.0,
max_linear_velocity=1000.0,
max_angular_velocity=1000.0,
max_depenetration_velocity=1.0,
),
articulation_props=sim_utils.ArticulationRootPropertiesCfg(
enabled_self_collisions=False,
solver_position_iteration_count=4,
solver_velocity_iteration_count=0,
),
joint_drive=sim_utils.UrdfConverterCfg.JointDriveCfg(
gains=sim_utils.UrdfConverterCfg.JointDriveCfg.PDGainsCfg(
stiffness=0, damping=0
)
),
),
init_state=ArticulationCfg.InitialStateCfg(
pos=(0.0, 0.0, 0.38),
joint_pos={
".*L_hip_joint": 0.0,
".*R_hip_joint": -0.0,
"F.*_thigh_joint": 0.8,
"R.*_thigh_joint": 0.8,
".*_calf_joint": -1.5,
},
joint_vel={".*": 0.0},
),
soft_joint_pos_limit_factor=0.9,
actuators={
"legs": DCMotorCfg(
joint_names_expr=[".*"],
effort_limit=23.5,
saturation_effort=23.5,
velocity_limit=30.0,
stiffness=25.0,
damping=0.5,
friction=0.0,
),
},
)
"""Configuration of Unitree Go2 using DC motor.
"""

View File

@@ -0,0 +1,24 @@
# Copyright (c) 2024-2025 Ziqi Fan
# SPDX-License-Identifier: Apache-2.0
# Copyright (c) 2022-2025, The Isaac Lab Project Developers.
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
"""Package containing task implementations for various robotic environments."""
import os
import toml
from isaaclab_tasks.utils import import_packages
##
# Register Gym environments.
##
# The blacklist is used to prevent importing configs from sub-packages
_BLACKLIST_PKGS = ["utils"]
# Import all configs in this package
import_packages(__name__, _BLACKLIST_PKGS)

View File

@@ -0,0 +1,34 @@
# Copyright (c) 2024-2025 Ziqi Fan
# SPDX-License-Identifier: Apache-2.0
# Copyright (c) 2022-2025, The Isaac Lab Project Developers.
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
"""Package containing task implementations for various robotic environments."""
import os
import toml
import gymnasium as gym
from isaaclab_tasks.utils import import_packages
##
# Register Gym environments.
##
gym.register(
id="Robotlab-Go2-v0",
entry_point="robot_lab.tasks.go2.env.go2_env:ActionDelayGo2Env",
disable_env_checker=True,
kwargs={
"env_cfg_entry_point": f"{__name__}.env_cfg:Go2EnvCfg",
# "env_cfg_entry_point": f"{__name__}.origin_env_cfg:UnitreeGo2RoughEnvCfg",
"rsl_rl_cfg_entry_point": f"{__name__}.rsl_rl_cfg:MoECTSRunnerCfg",
},
)
# The blacklist is used to prevent importing configs from sub-packages
_BLACKLIST_PKGS = ["utils"]
# Import all configs in this package
import_packages(__name__, _BLACKLIST_PKGS)

View File

@@ -0,0 +1,128 @@
from isaaclab.envs import ManagerBasedRLEnv, ManagerBasedRLEnvCfg, VecEnvStepReturn
from robot_lab.tasks.go2.manager.action_manager import ActionManagerWithDelay
import torch
from isaaclab.ui.widgets import ManagerLiveVisualizer
class ActionDelayGo2Env(ManagerBasedRLEnv):
cfg: ManagerBasedRLEnvCfg
def __init__(self, cfg: ManagerBasedRLEnvCfg, render_mode: str | None = None, **kwargs):
"""
Initialize the ActionDelayGo2Env with the given configuration.
Args:
cfg: The configuration for the environment.
render_mode: Rendering mode for the environment, e.g., "human" or "rgb_array". Default is None.
**kwargs: Additional keyword arguments for customization.
"""
# Call the parent class initializer
super().__init__(cfg=cfg, render_mode=render_mode, **kwargs)
print(
"[WARNING] You are using ActionDelayGo2Env; "
"make sure all ActionTerms support multiple calls to process_actions() "
"within a single step()."
)
def load_managers(self):
super().load_managers()
# override action manager
self.action_manager = ActionManagerWithDelay(self.cfg.actions, self)
print("[INFO] Overriding action manager with ActionManagerWithDelay: ", self.action_manager)
def step(self, action: torch.Tensor) -> VecEnvStepReturn:
"""Execute one time-step of the environment's dynamics and reset terminated environments.
Args:
action: The actions to apply on the environment. Shape is (num_envs, action_dim).
Returns:
A tuple containing the observations, rewards, resets (terminated and truncated) and extras.
IMPORTANT NOTE:
We intentionally call action_manager.process_action_with_delay() multiple times inside step
(contrary to its original intent) to implement action-delay functionality.
So, we assume that all ActionTerm.process_actions() are allowed to be
called multiple times within a single step call.
"""
# call update_action once per step to set action and prev action
self.action_manager.update_action(action.to(self.device))
# randomly determine when to start applying actions within the decimation steps for each environment
actions_start_decimation = torch.randint(0, self.cfg.decimation+1, (self.num_envs, 1), device=self.device)
self.recorder_manager.record_pre_step()
# check if we need to do rendering within the physics loop
# note: checked here once to avoid multiple checks within the loop
is_rendering = self.sim.has_gui() or self.sim.has_rtx_sensors()
# perform physics stepping
for i in range(self.cfg.decimation):
self._sim_step_counter += 1
# determine which envs should apply delayed action at this decimation step
action_delay_masks = (i <= actions_start_decimation)
self.action_manager.process_action_with_delay(action_delay_masks)
# set actions into buffers
self.action_manager.apply_action()
# set actions into simulator
self.scene.write_data_to_sim()
# simulate
self.sim.step(render=False)
# render between steps only if the GUI or an RTX sensor needs it
# note: we assume the render interval to be the shortest accepted rendering interval.
# If a camera needs rendering at a faster frequency, this will lead to unexpected behavior.
if self._sim_step_counter % self.cfg.sim.render_interval == 0 and is_rendering:
self.sim.render()
# update buffers at sim dt
self.scene.update(dt=self.physics_dt)
# post-step:
# -- update env counters (used for curriculum generation)
self.episode_length_buf += 1 # step in current episode (per env)
self.common_step_counter += 1 # total step (common for all envs)
# -- check terminations
self.reset_buf = self.termination_manager.compute()
self.reset_terminated = self.termination_manager.terminated
self.reset_time_outs = self.termination_manager.time_outs
# -- reward computation
self.reward_buf = self.reward_manager.compute(dt=self.step_dt)
if len(self.recorder_manager.active_terms) > 0:
# update observations for recording if needed
self.obs_buf = self.observation_manager.compute()
self.recorder_manager.record_post_step()
# -- reset envs that terminated/timed-out and log the episode information
reset_env_ids = self.reset_buf.nonzero(as_tuple=False).squeeze(-1)
if len(reset_env_ids) > 0:
# trigger recorder terms for pre-reset calls
self.recorder_manager.record_pre_reset(reset_env_ids)
self._reset_idx(reset_env_ids)
# update articulation kinematics
self.scene.write_data_to_sim()
self.sim.forward()
# if sensors are added to the scene, make sure we render to reflect changes in reset
if self.sim.has_rtx_sensors() and self.cfg.rerender_on_reset:
self.sim.render()
# trigger recorder terms for post-reset calls
self.recorder_manager.record_post_reset(reset_env_ids)
# -- update command
self.command_manager.compute(dt=self.step_dt)
# -- step interval events
if "interval" in self.event_manager.available_modes:
self.event_manager.apply(mode="interval", dt=self.step_dt)
# -- compute observations
# note: done after reset to get the correct observations for reset envs
self.obs_buf = self.observation_manager.compute(update_history=True)
# return observations, rewards, resets and extras
return self.obs_buf, self.reward_buf, self.reset_terminated, self.reset_time_outs, self.extras

View File

@@ -0,0 +1,615 @@
import math
import isaaclab.sim as sim_utils
from isaaclab.assets import ArticulationCfg, AssetBaseCfg
from isaaclab.envs import ManagerBasedRLEnvCfg
from isaaclab.managers import CurriculumTermCfg as CurrTerm
from isaaclab.managers import EventTermCfg as EventTerm
from isaaclab.managers import ObservationGroupCfg as ObsGroup
from isaaclab.managers import ObservationTermCfg as ObsTerm
from isaaclab.managers import RewardTermCfg as RewTerm
from isaaclab.managers import SceneEntityCfg
from isaaclab.managers import TerminationTermCfg as DoneTerm
from isaaclab.scene import InteractiveSceneCfg
from isaaclab.sensors import ContactSensorCfg, RayCasterCfg, patterns
from isaaclab.terrains import TerrainImporterCfg
from isaaclab.utils import configclass
from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, ISAACLAB_NUCLEUS_DIR
from isaaclab.utils.noise import AdditiveUniformNoiseCfg as Unoise
import isaaclab.terrains as terrain_gen
import robot_lab.tasks.go2.mdp as mdp
from robot_lab.assets.unitree import UNITREE_GO2_CFG
JOINT_NAMES = [
"FL_hip_joint", "FL_thigh_joint", "FL_calf_joint",
"FR_hip_joint", "FR_thigh_joint", "FR_calf_joint",
"RL_hip_joint", "RL_thigh_joint", "RL_calf_joint",
"RR_hip_joint", "RR_thigh_joint", "RR_calf_joint",
]
BASE_LINK_NAME = "base"
FOOT_LINK_NAME = ".*_foot"
##
# Terrain definition
##
TERRAIN_CFG = terrain_gen.TerrainGeneratorCfg(
size=(8.0, 8.0),
border_width=20.0,
num_rows=10,
num_cols=20,
horizontal_scale=0.1,
vertical_scale=0.005,
slope_threshold=0.75,
use_cache=False,
sub_terrains={
"pyramid_stairs": terrain_gen.MeshPyramidStairsTerrainCfg(
proportion=0.15,
step_height_range=(0.05, 0.23),
step_width=0.3,
platform_width=3.0,
border_width=1.0,
holes=False,
),
"pyramid_stairs_inv": terrain_gen.MeshInvertedPyramidStairsTerrainCfg(
proportion=0.20,
step_height_range=(0.05, 0.23),
step_width=0.3,
platform_width=3.0,
border_width=1.0,
holes=False,
),
"boxes": terrain_gen.MeshRandomGridTerrainCfg(
proportion=0.15, grid_width=0.45, grid_height_range=(0.05, 0.2), platform_width=2.0
),
"random_rough": terrain_gen.HfRandomUniformTerrainCfg(
proportion=0.1, noise_range=(0.02, 0.10), noise_step=0.02, border_width=0.25
),
"flat": terrain_gen.MeshPlaneTerrainCfg(proportion=0.2),
"hf_pyramid_slope": terrain_gen.HfPyramidSlopedTerrainCfg(
proportion=0.1, slope_range=(0.0, 0.4), platform_width=2.0, border_width=0.25
),
"hf_pyramid_slope_inv": terrain_gen.HfInvertedPyramidSlopedTerrainCfg(
proportion=0.1, slope_range=(0.0, 0.4), platform_width=2.0, border_width=0.25
),
},
)
##
# Scene definition
##
@configclass
class Go2SceneCfg(InteractiveSceneCfg):
"""Configuration for the terrain scene with the Go2 robot."""
terrain = TerrainImporterCfg(
prim_path="/World/ground",
terrain_type="generator",
terrain_generator=TERRAIN_CFG,
max_init_terrain_level=5,
collision_group=-1,
physics_material=sim_utils.RigidBodyMaterialCfg(
friction_combine_mode="multiply",
static_friction=1.0,
dynamic_friction=1.0,
),
visual_material=sim_utils.MdlFileCfg(
mdl_path=f"{ISAACLAB_NUCLEUS_DIR}/Materials/TilesMarbleSpiderWhiteBrickBondHoned/TilesMarbleSpiderWhiteBrickBondHoned.mdl",
project_uvw=True,
texture_scale=(0.25, 0.25),
),
debug_vis=False
)
robot: ArticulationCfg = UNITREE_GO2_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot")
height_scanner = RayCasterCfg(
prim_path="{ENV_REGEX_NS}/Robot/base",
offset=RayCasterCfg.OffsetCfg(pos=(0.0, 0.0, 20.0)),
ray_alignment="yaw",
pattern_cfg=patterns.GridPatternCfg(resolution=0.1, size=[1.6, 1.0]),
debug_vis=False,
mesh_prim_paths=["/World/ground"],
)
height_scanner_base = RayCasterCfg(
prim_path="{ENV_REGEX_NS}/Robot/base",
offset=RayCasterCfg.OffsetCfg(pos=(0.0, 0.0, 20.0)),
ray_alignment="yaw",
pattern_cfg=patterns.GridPatternCfg(resolution=0.05, size=(0.1, 0.1)),
debug_vis=False,
mesh_prim_paths=["/World/ground"],
)
contact_forces = ContactSensorCfg(
prim_path="{ENV_REGEX_NS}/Robot/.*",
history_length=3,
track_air_time=True,
)
# 灯光
sky_light = AssetBaseCfg(
prim_path="/World/skyLight",
spawn=sim_utils.DomeLightCfg(
intensity=750.0,
texture_file=f"{ISAAC_NUCLEUS_DIR}/Materials/Textures/Skies/PolyHaven/kloofendal_43d_clear_puresky_4k.hdr",
),
)
##
# MDP settings
##
@configclass
class CommandsCfg:
"""Command specifications for the MDP."""
base_velocity = mdp.UniformVelTerrainCmdCfg(
asset_name="robot",
resampling_time_range=(5.0, 5.0),
rel_standing_envs=0.1,
rel_heading_envs=1.0,
heading_command=False, # note here, no heading command!
heading_control_stiffness=0.5,
debug_vis=True,
cycle_time=0.5,
use_phase_cmd=False,
ranges = mdp.UniformVelocityCommandCfg.Ranges(
lin_vel_x=(-0.5, 0.5), lin_vel_y=(-0.5, 0.5), ang_vel_z=(-1.0, 1.0), heading=(-1.57, 1.57)
),
terrain_max_ranges={
"pyramid_stairs": mdp.UniformVelocityCommandCfg.Ranges(
lin_vel_x=(-1.0, 1.0), lin_vel_y=(-1.0, 1.0), ang_vel_z=(-1.0, 1.0), heading=(-1.57, 1.57)
),
"pyramid_stairs_inv": mdp.UniformVelocityCommandCfg.Ranges(
lin_vel_x=(-1.0, 1.0), lin_vel_y=(-1.0, 1.0), ang_vel_z=(-1.0, 1.0), heading=(-1.57, 1.57)
),
"box": mdp.UniformVelocityCommandCfg.Ranges(
lin_vel_x=(-1.0, 1.0), lin_vel_y=(-1.0, 1.0), ang_vel_z=(-1.5, 1.5), heading=(-1.57, 1.57)
),
"random_rough": mdp.UniformVelocityCommandCfg.Ranges(
lin_vel_x=(-1.5, 1.5), lin_vel_y=(-1.0, 1.0), ang_vel_z=(-1.5, 1.5), heading=(-1.57, 1.57)
),
"flat": mdp.UniformVelocityCommandCfg.Ranges(
lin_vel_x=(-2.0, 2.0), lin_vel_y=(-1.0, 1.0), ang_vel_z=(-2.0, 2.0), heading=(-1.57, 1.57)
),
"hf_pyramid_slope": mdp.UniformVelocityCommandCfg.Ranges(
lin_vel_x=(-1.5, 1.5), lin_vel_y=(-1.0, 1.0), ang_vel_z=(-1.5, 1.5), heading=(-1.57, 1.57)
),
"hf_pyramid_slope_inv": mdp.UniformVelocityCommandCfg.Ranges(
lin_vel_x=(-1.5, 1.5), lin_vel_y=(-1.0, 1.0), ang_vel_z=(-1.5, 1.5), heading=(-1.57, 1.57)
),
},
curriculum_schedule=[
{'iter': 50000, 'lin_vel_x': [-2.0, 2.0], 'lin_vel_y': [-1.0, 1.0], 'ang_vel_yaw': [-2.0, 2.0], 'heading': [-1.57, 1.57]},
{'iter': 20000, 'lin_vel_x': [-1.0, 1.0], 'lin_vel_y': [-1.0, 1.0], 'ang_vel_yaw': [-1.5, 1.5], 'heading': [-1.57, 1.57]},
], # 根据iter倒序排布
)
@configclass
class ActionsCfg:
"""Action specifications for the MDP."""
# 腿部关节:位置控制
joint_pos = mdp.JointPositionActionCfg(
asset_name="robot",
joint_names=JOINT_NAMES,
scale={".*_hip_joint": 0.25, "^(?!.*_hip_joint).*": 0.25},
use_default_offset=True,
clip=None,
preserve_order=True
)
@configclass
class ObservationsCfg:
@configclass
class PolicyCfg(ObsGroup):
"""Observations for policy group."""
base_ang_vel = ObsTerm(
func=mdp.base_ang_vel,
noise=Unoise(n_min=-0.2, n_max=0.2),
clip=(-100.0, 100.0),
scale=0.25,
)
projected_gravity = ObsTerm(
func=mdp.projected_gravity,
noise=Unoise(n_min=-0.05, n_max=0.05),
clip=(-100.0, 100.0),
scale=1.0,
)
velocity_commands = ObsTerm(
func=mdp.generated_commands,
params={"command_name": "base_velocity"},
clip=(-100.0, 100.0),
scale=1.0,
)
joint_pos = ObsTerm(
func=mdp.joint_pos_rel,
params={"asset_cfg": SceneEntityCfg("robot", joint_names=JOINT_NAMES, preserve_order=True)},
noise=Unoise(n_min=-0.03, n_max=0.03),
clip=(-100.0, 100.0),
scale=1.0,
)
joint_vel = ObsTerm(
func=mdp.joint_vel_rel,
params={"asset_cfg": SceneEntityCfg("robot", joint_names=JOINT_NAMES, preserve_order=True)},
noise=Unoise(n_min=-2.0, n_max=2.0),
clip=(-100.0, 100.0),
scale=0.05,
)
actions = ObsTerm(
func=mdp.last_action,
clip=(-100.0, 100.0),
scale=1.0,
)
def __post_init__(self):
self.history_length = 5
self.enable_corruption = True
self.concatenate_terms = True
self.flatten_history_dim = True
@configclass
class CriticCfg(ObsGroup):
base_lin_vel = ObsTerm(
func=mdp.base_lin_vel,
clip=(-100.0, 100.0),
scale=2.0,
)
base_ang_vel = ObsTerm(
func=mdp.base_ang_vel,
clip=(-100.0, 100.0),
scale=0.25,
)
projected_gravity = ObsTerm(
func=mdp.projected_gravity,
clip=(-100.0, 100.0),
scale=1.0,
)
velocity_commands = ObsTerm(
func=mdp.generated_commands,
params={"command_name": "base_velocity"},
clip=(-100.0, 100.0),
scale=1.0,
)
joint_pos = ObsTerm(
func=mdp.joint_pos_rel,
params={"asset_cfg": SceneEntityCfg("robot", joint_names=JOINT_NAMES, preserve_order=True)},
clip=(-100.0, 100.0),
scale=1.0,
)
joint_vel = ObsTerm(
func=mdp.joint_vel_rel,
params={"asset_cfg": SceneEntityCfg("robot", joint_names=JOINT_NAMES, preserve_order=True)},
clip=(-100.0, 100.0),
scale=0.05,
)
actions = ObsTerm(
func=mdp.last_action,
clip=(-100.0, 100.0),
scale=1.0,
)
joint_acc = ObsTerm(
func=mdp.joint_acc,
params={"asset_cfg": SceneEntityCfg("robot", joint_names=JOINT_NAMES, preserve_order=True)},
clip=(-100.0, 100.0),
scale=1e-4,
)
joint_torque = ObsTerm(
func=mdp.joint_effort,
params={"asset_cfg": SceneEntityCfg("robot", joint_names=JOINT_NAMES, preserve_order=True)},
clip=(-100.0, 100.0),
scale=0.01,
)
contact_force = ObsTerm(
func=mdp.foot_contact_force_norm,
params={"sensor_cfg": SceneEntityCfg("contact_forces", body_names=FOOT_LINK_NAME)},
clip=(-100.0, 100.0),
scale=1e-3,
)
height_scan = ObsTerm(
func=mdp.height_scan,
params={"sensor_cfg": SceneEntityCfg("height_scanner")},
clip=(-1.0, 1.0),
scale=2.5,
)
def __post_init__(self):
self.enable_corruption = False
self.concatenate_terms = True
@configclass
class SingleObsCfg(PolicyCfg):
def __post_init__(self):
super().__post_init__()
self.history_length = 1
policy: PolicyCfg = PolicyCfg()
critic: CriticCfg = CriticCfg()
single_obs: SingleObsCfg = SingleObsCfg() # Used to obtain the current-timestep observation for the MoE CTS model
@configclass
class EventCfg:
"""Configuration for events."""
randomize_rigid_body_mass_base = EventTerm(
func=mdp.randomize_rigid_body_mass,
mode="startup",
params={
"asset_cfg": SceneEntityCfg("robot", body_names=BASE_LINK_NAME), # Override
"mass_distribution_params": (-1.0, 1.0),
"operation": "add",
"recompute_inertia": True,
},
)
randomize_rigid_body_mass_others = EventTerm(
func=mdp.randomize_rigid_body_mass,
mode="startup",
params={
"asset_cfg": SceneEntityCfg("robot", body_names="^(?!.*base).*"), # Override
"mass_distribution_params": (0.8, 1.2),
"operation": "scale",
"recompute_inertia": True,
},
)
randomize_com_positions = EventTerm(
func=mdp.randomize_rigid_body_com,
mode="startup",
params={
"asset_cfg": SceneEntityCfg("robot", body_names=BASE_LINK_NAME), # Override
"com_range": {"x": (-0.05, 0.05), "y": (-0.05, 0.05), "z": (-0.05, 0.05)},
},
)
randomize_com_positions_other = EventTerm(
func=mdp.randomize_rigid_body_com,
mode="startup",
params={
"asset_cfg": SceneEntityCfg("robot", body_names="^(?!.*base).*"), # Override
"com_range": {"x": (-0.05, 0.05), "y": (-0.05, 0.05), "z": (-0.05, 0.05)},
},
)
reset_robot_joints = EventTerm(
func=mdp.reset_joints_by_scale,
mode="reset",
params={
"position_range": (0.8, 1.2),
"velocity_range": (0.0, 0.0),
},
)
# when use DelayedPDActuator, the range should be narrowed, range that is too wide like (0.5, 2.0) will cause training to fail.
randomize_actuator_gains = EventTerm(
func=mdp.randomize_actuator_gains,
mode="reset",
params={
"asset_cfg": SceneEntityCfg("robot", joint_names=".*"),
"stiffness_distribution_params": (0.9, 1.1),
"damping_distribution_params": (0.9, 1.1),
"operation": "scale",
"distribution": "uniform",
},
)
randomize_push_robot = EventTerm(
func=mdp.push_by_setting_velocity,
mode="interval",
interval_range_s=(4.0, 4.0),
params={
"velocity_range": {
"x": (-0.5, 0.5),
"y": (-0.5, 0.5),
"roll": (-0.7, 0.7),
"pitch": (-0.7, 0.7),
"yaw": (-0.7, 0.7)
}
}
)
randomize_rigid_body_mass_base = EventTerm(
func=mdp.randomize_rigid_body_mass,
mode="startup",
params={
"asset_cfg": SceneEntityCfg("robot", body_names=BASE_LINK_NAME),
"mass_distribution_params": (-5.0, 10.0),
"operation": "add",
"recompute_inertia": True,
},
)
randomize_rigid_body_material = EventTerm(
func=mdp.randomize_rigid_body_material,
mode="startup",
params={
"asset_cfg": SceneEntityCfg("robot", body_names=".*"),
"static_friction_range": (0.5, 1.5),
"dynamic_friction_range": (0.5, 1.5),
"restitution_range": (0.0, 0.5),
"num_buckets": 512,
"make_consistent": True
},
)
randomize_apply_external_force_torque = EventTerm(
func=mdp.apply_external_force_torque,
mode="reset",
params={
"asset_cfg": SceneEntityCfg("robot", body_names=BASE_LINK_NAME),
"force_range": (-10.0, 10.0),
"torque_range": (-10.0, 10.0),
},
)
reset_base = EventTerm(
func=mdp.reset_root_state_uniform,
mode="reset",
params={
"pose_range": {"x": (-0.5, 0.5), "y": (-0.5, 0.5), "z": (0.0, 0.2), "yaw": (-3.14, 3.14)},
"velocity_range": {
"x": (-0.5, 0.5),
"y": (-0.5, 0.5),
"z": (-0.5, 0.5),
"roll": (-0.5, 0.5),
"pitch": (-0.5, 0.5),
"yaw": (-0.5, 0.5),
},
},
)
@configclass
class RewardsCfg:
"""Reward terms for the MDP."""
track_lin_vel_xy_exp = RewTerm(
func=mdp.track_lin_vel_xy_exp,
weight=1.0,
params={"command_name": "base_velocity", "std": 0.5}
)
track_ang_vel_z_exp = RewTerm(
func=mdp.track_ang_vel_z_exp,
weight=0.5,
params={"command_name": "base_velocity", "std": 0.5}
)
lin_vel_z_l2 = RewTerm(func=mdp.lin_vel_z_l2, weight=-2.0)
ang_vel_xy_l2 = RewTerm(func=mdp.ang_vel_xy_l2, weight=-0.05)
flat_orientation_l2 = RewTerm(
func=mdp.flat_orientation_l2,
weight=-3.0
)
base_height_l2 = RewTerm(
func=mdp.base_height_l2,
weight=-50.0,
params={
"asset_cfg": SceneEntityCfg("robot", body_names=BASE_LINK_NAME),
"target_height": 0.38
}
)
dof_acc_l2 = RewTerm(
func=mdp.joint_acc_l2,
weight=-2.0e-7,
params={"asset_cfg": SceneEntityCfg("robot", joint_names=JOINT_NAMES)}
)
action_rate_l2 = RewTerm(func=mdp.action_rate_l2, weight=-0.01)
action_smoothness_l2 = RewTerm(func=mdp.action_smoothness_l2, weight=-0.01)
undesired_contacts = RewTerm(
func=mdp.undesired_contacts,
weight=-1.0,
params={"sensor_cfg": SceneEntityCfg("contact_forces", body_names=".*_thigh|.*_calf"), "threshold": 5.0},
)
hip_pos_penalty = RewTerm(
func=mdp.hip_pos_penalty,
weight=-0.85,
params={
"command_name": "base_velocity",
"asset_cfg": SceneEntityCfg("robot", joint_names=".*_hip_joint"),
"stand_still_scale": 1.0,
"command_threshold": 0.1,
},
)
joint_torques_l2 = RewTerm(
func=mdp.joint_torques_l2,
weight=-1e-4,
params={"asset_cfg": SceneEntityCfg("robot", joint_names=JOINT_NAMES)}
)
joint_power = RewTerm(
func=mdp.joint_power,
weight=-2e-5,
params={"asset_cfg": SceneEntityCfg("robot", joint_names=JOINT_NAMES)}
)
@configclass
class TerminationsCfg:
"""Termination terms for the MDP."""
time_out = DoneTerm(func=mdp.time_out, time_out=True)
bad_orientation = DoneTerm(
func=mdp.bad_orientation,
params={
"asset_cfg": SceneEntityCfg("robot", body_names=BASE_LINK_NAME),
"limit_angle": 2 * math.pi / 3
},
)
@configclass
class CurriculumCfg:
"""Curriculum terms for the MDP."""
terrain_levels = CurrTerm(func=mdp.terrain_levels_vel_gym)
base_linear_velocity = CurrTerm(mdp.gradual_reward_weight_modification, params={
"term_name": "lin_vel_z_l2", "initial_weight": -2.0, "final_weight": -0.0, "start_it": 0, "end_it": 2000
})
ref_stand_envs = CurrTerm(mdp.gradual_ref_stand_modification, params={
"term_name": "base_velocity", "initial": 0.0, "final": 0.1, "start_it": 0, "end_it": 1500
})
command_curr = CurrTerm(mdp.command_curriculum, params={
"command_term_name": "base_velocity", "num_steps_per_iter": 24,
})
##
# Environment configuration
##
@configclass
class Go2EnvCfg(ManagerBasedRLEnvCfg):
"""Merged configuration for the Go2 robot on rough terrain."""
# Scene settings
scene: Go2SceneCfg = Go2SceneCfg(num_envs=8192, env_spacing=2.5)
# Basic settings
observations: ObservationsCfg = ObservationsCfg()
actions: ActionsCfg = ActionsCfg()
commands: CommandsCfg = CommandsCfg()
# MDP settings
rewards: RewardsCfg = RewardsCfg()
terminations: TerminationsCfg = TerminationsCfg()
events: EventCfg = EventCfg()
curriculum: CurriculumCfg = CurriculumCfg()
def __post_init__(self):
"""Post initialization."""
# General settings
self.decimation = 4
self.episode_length_s = 25.0
# Simulation settings
self.sim.dt = 0.005
self.sim.render_interval = self.decimation
# Physics material settings from subclass
self.sim.physics_material = self.scene.terrain.physics_material
self.sim.physx.gpu_max_rigid_patch_count = 10 * 2**15
self.sim.physx.gpu_collision_stack_size = int(128 * 1024 * 1024) # 128 MB
self.sim.physx.enable_external_forces_every_iteration = True
# Update sensor periods
if self.scene.height_scanner is not None:
self.scene.height_scanner.update_period = self.decimation * self.sim.dt
if self.scene.contact_forces is not None:
self.scene.contact_forces.update_period = self.sim.dt
# Scale down terrain grid for small robot
if self.scene.terrain.terrain_generator is not None:
if "boxes" in self.scene.terrain.terrain_generator.sub_terrains:
self.scene.terrain.terrain_generator.sub_terrains["boxes"].grid_height_range = (0.025, 0.1)
if "random_rough" in self.scene.terrain.terrain_generator.sub_terrains:
self.scene.terrain.terrain_generator.sub_terrains["random_rough"].noise_range = (0.01, 0.06)
self.scene.terrain.terrain_generator.sub_terrains["random_rough"].noise_step = 0.01
# Handle curriculum for terrain generator
if getattr(self.curriculum, "terrain_levels", None) is not None:
if self.scene.terrain.terrain_generator is not None:
self.scene.terrain.terrain_generator.curriculum = True
else:
if self.scene.terrain.terrain_generator is not None:
self.scene.terrain.terrain_generator.curriculum = False

View File

@@ -0,0 +1,47 @@
from isaaclab.managers import ActionManager
import torch
from collections.abc import Sequence
class ActionManagerWithDelay(ActionManager):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._prev_prev_action = torch.zeros_like(self._action)
def reset(self, env_ids: Sequence[int] | None = None) -> dict[str, torch.Tensor]:
super().reset(env_ids)
self._prev_prev_action.zero_()
return {}
def process_action(self, action: torch.Tensor):
raise NotImplementedError("Use update_action() and process_action_with_delay() instead for ActionManagerWithDelay.")
def process_action_with_delay(self, action_delay_masks: torch.Tensor):
"""Processes the actions sent to the environment.
Important Note:
This function can be called multiple times within a single step() call to implement action delay.
Args:
action_delay_masks: A tensor of shape (num_envs, 1) indicating which actions to apply at this time step.
"""
# action_delay_masks == True means delay (use prev action)
action = torch.where(action_delay_masks, self._prev_action, self._action)
# split the actions and apply to each tensor
# NOTE: we also assume that all term.process_actions can be called multiple times within a single step() call
idx = 0
for term in self._terms.values():
term_actions = action[:, idx : idx + term.action_dim]
term.process_actions(term_actions)
idx += term.action_dim
def update_action(self, action: torch.Tensor):
if self.total_action_dim != action.shape[1]:
raise ValueError(f"Invalid action shape, expected: {self.total_action_dim}, received: {action.shape[1]}.")
self._prev_prev_action[:] = self._prev_action
self._prev_action[:] = self._action
self._action[:] = action.to(self.device)
@property
def prev_prev_action(self):
return self._prev_prev_action

View File

@@ -0,0 +1,19 @@
# Copyright (c) 2024-2025 Ziqi Fan
# SPDX-License-Identifier: Apache-2.0
# Copyright (c) 2022-2025, The Isaac Lab Project Developers.
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
"""This sub-module contains the functions that are specific to the locomotion environments."""
from isaaclab.envs.mdp import * # noqa: F401, F403
from isaaclab_tasks.manager_based.locomotion.velocity.mdp import * # noqa: F401, F403
from .commands import * # noqa: F401, F403
from .curriculums import * # noqa: F401, F403
from .events import * # noqa: F401, F403
from .observations import * # noqa: F401, F403
from .rewards import * # noqa: F401, F403
from .utils import * # noqa: F401, F403

View File

@@ -0,0 +1,397 @@
# Copyright (c) 2024-2025 Ziqi Fan
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import time
import torch
import copy
from collections.abc import Sequence
from typing import TYPE_CHECKING
from dataclasses import MISSING
from itertools import product
from isaaclab.managers import CommandTerm
from isaaclab.utils import configclass
from isaaclab.assets import Articulation
from isaaclab.envs.mdp import UniformVelocityCommandCfg
import isaaclab.utils.math as math_utils
from isaaclab.markers import VisualizationMarkers
# SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2022-2025, The Isaac Lab Project Developers
from .utils import is_robot_on_terrain
if TYPE_CHECKING:
from isaaclab.envs import ManagerBasedEnv
class UniformVelTerrainCmd(CommandTerm):
cfg: UniformVelTerrainCmdCfg
def __init__(self, cfg: UniformVelTerrainCmdCfg, env: ManagerBasedRLEnv):
super().__init__(cfg, env)
# obtain the robot asset
# -- robot
self.robot: Articulation = env.scene[cfg.asset_name]
# crete buffers to store the command
# -- command: x vel, y vel, yaw vel, heading
self.vel_command_b = torch.zeros(self.num_envs, 3, device=self.device)
self.heading_target = torch.zeros(self.num_envs, device=self.device)
self.is_heading_env = torch.zeros(self.num_envs, dtype=torch.bool, device=self.device)
self.is_standing_env = torch.zeros_like(self.is_heading_env)
# -- metrics
self.metrics["error_vel_xy"] = torch.zeros(self.num_envs, device=self.device)
self.metrics["error_vel_yaw"] = torch.zeros(self.num_envs, device=self.device)
if self.cfg.use_phase_cmd:
self.phase = torch.zeros(self.num_envs, 1, device=self.device)
self.cycle_time = cfg.cycle_time
self.max_angular_envs = cfg.max_angular_envs
self.limit_vel_envs = cfg.limit_vel_envs
self.stop_heading = torch.zeros(self.num_envs, dtype=torch.bool, device=self.device)
# 获取当前环境的地形类型索引
self.terrain_types = list(env.scene.terrain.cfg.terrain_generator.sub_terrains.keys())
if not isinstance(self.cfg.ranges, dict):
single_range = cfg.ranges
expanded_ranges = {}
for t_name in self.terrain_types:
expanded_ranges[t_name] = copy.deepcopy(single_range)
self.cfg.ranges = expanded_ranges
assert set(self.terrain_types) == set(list(self.cfg.ranges.keys())), \
"Terrain types in cfg.ranges do not match those in terrain generator config."
self.terrain_type_to_id = {name: i for i, name in enumerate(self.terrain_types)}
self.global_env_terrain_idx = torch.full((self.num_envs,), -1, dtype=torch.long, device=self.device)
for t_type in self.terrain_types:
ids = is_robot_on_terrain(self._env, t_type).nonzero(as_tuple=True)[0]
if len(ids) > 0:
self.global_env_terrain_idx[ids] = self.terrain_type_to_id[t_type]
# 初始化累计指令和最大位移
self.commands_xy_accumulation = torch.zeros(self.num_envs, 2, device=self.device)
self.dt = self._env.step_dt
self.max_episode_length = self._env.max_episode_length
self.target_dist = env.scene.terrain.cfg.terrain_generator.size[0] * 0.625
self.max_move_distance = torch.zeros(self.num_envs, device=self.device)
self.env_origins = env.scene.env_origins
# 极限指令组合
limit_options_x = [-1, 1]
limit_options_y = [-1, 1]
limit_options_z = [-1, 0, 1] # 允许 Yaw 为 0即直线冲刺
self.limit_vel_combinations = torch.tensor(
list(product(limit_options_x, limit_options_y, limit_options_z)),
dtype=torch.long,
device=self.device
)
self.last_is_limit_vel = torch.zeros(self.num_envs, dtype=torch.bool, device=self.device)
def __str__(self) -> str:
"""Return a string representation of the command generator."""
msg = "UniformVelocityCommand:\n"
msg += f"\tCommand dimension: {tuple(self.command.shape[1:])}\n"
msg += f"\tResampling time range: {self.cfg.resampling_time_range}\n"
msg += f"\tHeading command: {self.cfg.heading_command}\n"
if self.cfg.heading_command:
msg += f"\tHeading probability: {self.cfg.rel_heading_envs}\n"
msg += f"\tStanding probability: {self.cfg.rel_standing_envs}\n"
msg += f"\tMax angular vel envs: {self.max_angular_envs}\n"
msg += f"\tLimit linear vel envs: {self.limit_vel_envs}\n"
for t_type in self.terrain_types:
msg += f"\tTerrain type '{t_type}' command ranges: {self.cfg.ranges[t_type]}"
return msg
@property
def command(self) -> torch.Tensor:
if self.cfg.use_phase_cmd:
phase = self.phase * 2 * torch.pi
return torch.cat([self.vel_command_b, torch.sin(phase), torch.cos(phase)], dim=-1)
return self.vel_command_b
def _update_metrics(self):
# time for which the command was executed
max_command_time = self.cfg.resampling_time_range[1]
max_command_step = max_command_time / self._env.step_dt
# logs data
self.metrics["error_vel_xy"] += (
torch.norm(self.vel_command_b[:, :2] - self.robot.data.root_lin_vel_b[:, :2], dim=-1) / max_command_step
)
self.metrics["error_vel_yaw"] += (
torch.abs(self.vel_command_b[:, 2] - self.robot.data.root_ang_vel_b[:, 2]) / max_command_step
)
def reset(self, env_ids: Sequence[int] | None = None):
self.commands_xy_accumulation[env_ids] = 0.0
self.max_move_distance[env_ids] = 0.0
extra = super().reset(env_ids)
return extra
def _terrain_cmd_sample(self, dim: str, env_ids: torch.Tensor, min_abs_vel: torch.Tensor | None = None):
"""根据不同地形sample不同指令"""
cmd = torch.zeros(len(env_ids), device=self.device)
batch_terrain_idxs = self.global_env_terrain_idx[env_ids]
for t_type in self.terrain_types:
t_id = self.terrain_type_to_id[t_type]
mask = (batch_terrain_idxs == t_id)
if mask.any():
count = mask.sum().item()
if dim == "x":
r = self.cfg.ranges[t_type].lin_vel_x
elif dim == "y":
r = self.cfg.ranges[t_type].lin_vel_y
elif dim == "z":
r = self.cfg.ranges[t_type].ang_vel_z
elif dim == "heading":
r = self.cfg.ranges[t_type].heading
sampled_vals = torch.empty(count, device=self.device).uniform_(*r) # type: ignore
if min_abs_vel is not None and (dim == "x" or dim == "y"):
lower_bound = min_abs_vel[mask]
max_abs = max(abs(r[0]), abs(r[1]))
lower_bound = torch.clamp(lower_bound, max=max_abs)
# 重新采样 Magnitude: [lower_bound, max_abs]
mag = torch.empty(count, device=self.device).uniform_(0, 1) * (max_abs - lower_bound) + lower_bound
if r[0] >= 0:
sampled_vals = mag
elif r[1] <= 0:
sampled_vals = -mag
else:
sign = torch.sign(torch.empty(count, device=self.device).uniform_(-1, 1))
sign = torch.where(sign == 0, torch.ones_like(sign), sign)
sampled_vals = mag * sign
cmd[mask] = sampled_vals
return cmd
def _get_cmd_board(self, dim: str, env_ids: torch.Tensor):
"""根据预计算的地形类型获取速度指令边界"""
cmd_min = torch.zeros(len(env_ids), device=self.device)
cmd_max = torch.zeros(len(env_ids), device=self.device)
batch_terrain_idxs = self.global_env_terrain_idx[env_ids]
for t_type in self.terrain_types:
t_id = self.terrain_type_to_id[t_type]
mask = (batch_terrain_idxs == t_id)
if mask.any():
if dim == "x":
r = self.cfg.ranges[t_type].lin_vel_x
elif dim == "y":
r = self.cfg.ranges[t_type].lin_vel_y
elif dim == "z":
r = self.cfg.ranges[t_type].ang_vel_z
else:
continue
cmd_min[mask] = r[0]
cmd_max[mask] = r[1]
return cmd_min, cmd_max
def _resample_command(self, env_ids: Sequence[int]):
_env_ids = torch.tensor(env_ids, device=self.device)
# 计算剩余距离: 目标距离 - 已经指令累积走过的距离 * 上一次重采样时间
dist_covered = torch.norm(self.commands_xy_accumulation[env_ids], dim=1) * self.cfg.resampling_time_range[0]
remaining_dist = torch.clamp(self.target_dist - dist_covered, min=0.0)
# 计算剩余时间: (最大步数 - 当前步数) * dt
time_left = (self.max_episode_length - self._env.episode_length_buf[env_ids]) * self.dt
# 计算下限速度: 距离 / 时间
vel_low_bound = torch.zeros(len(env_ids), device=self.device)
# 避免除以零或负数时间
valid_time_mask = time_left > 1e-4
if valid_time_mask.any():
vel_low_bound[valid_time_mask] = remaining_dist[valid_time_mask] / time_left[valid_time_mask]
# 根据不同地形类型采样速度指令
self.vel_command_b[env_ids, 0] = self._terrain_cmd_sample("x", _env_ids, min_abs_vel=vel_low_bound)
self.vel_command_b[env_ids, 0] = torch.where(
self.vel_command_b[env_ids, 0].abs() < 0.1,
torch.zeros_like(self.vel_command_b[env_ids, 0]),
self.vel_command_b[env_ids, 0]
)
self.vel_command_b[env_ids, 1] = self._terrain_cmd_sample("y", _env_ids, min_abs_vel=vel_low_bound)
self.vel_command_b[env_ids, 1] = torch.where(
self.vel_command_b[env_ids, 1].abs() < 0.1,
torch.zeros_like(self.vel_command_b[env_ids, 1]),
self.vel_command_b[env_ids, 1]
)
self.vel_command_b[env_ids, 2] = self._terrain_cmd_sample("z", _env_ids)
self.vel_command_b[env_ids, 2] = torch.where(
self.vel_command_b[env_ids, 2].abs() < 0.1,
torch.zeros_like(self.vel_command_b[env_ids, 2]),
self.vel_command_b[env_ids, 2]
)
# 计算heading和stand
r = torch.empty(len(env_ids), device=self.device)
if self.cfg.heading_command:
self.heading_target[env_ids] = self._terrain_cmd_sample("heading", _env_ids)
# update heading envs
self.is_heading_env[env_ids] = r.uniform_(0.0, 1.0) <= self.cfg.rel_heading_envs
# update standing envs
self.is_standing_env[env_ids] = r.uniform_(0.0, 1.0) <= self.cfg.rel_standing_envs
# 静止环境sample最大转向
standing_env_ids = self.is_standing_env.nonzero(as_tuple=False).flatten()
if len(standing_env_ids) > 0:
self.vel_command_b[standing_env_ids, :] = 0.0
ang_vel_rand = torch.rand(len(standing_env_ids), device=self.device)
add_ang_mask = ang_vel_rand < self.max_angular_envs
add_ang_env_ids = standing_env_ids[add_ang_mask]
if self.max_angular_envs > 0 and len(add_ang_env_ids) > 0:
direction_rand = torch.rand(len(add_ang_env_ids), device=self.device)
min_z, max_z = self._get_cmd_board("z", add_ang_env_ids)
self.vel_command_b[add_ang_env_ids, 2] = torch.where(direction_rand < 0.5, min_z, max_z)
self.stop_heading[add_ang_env_ids] = True
limit_vel_env_ids = (self.is_standing_env == 0).nonzero(as_tuple=False).flatten()
# 非静止环境sample极限速度
current_ids = env_ids
mask = torch.zeros(self.num_envs, dtype=torch.bool, device=self.device)
mask[current_ids] = True
target_limit_ids = limit_vel_env_ids[mask[limit_vel_env_ids]]
if self.limit_vel_envs > 0 and len(target_limit_ids) > 0:
limit_prob = torch.rand(len(target_limit_ids), device=self.device)
execute_limit_mask = limit_prob < self.limit_vel_envs
execute_ids = target_limit_ids[execute_limit_mask]
if len(execute_ids) > 0:
num_combs = self.limit_vel_combinations.shape[0]
comb_indices = torch.randint(0, num_combs, (len(execute_ids),), device=self.device)
selected_combs = self.limit_vel_combinations[comb_indices] # (N, 3)
min_x, max_x = self._get_cmd_board("x", execute_ids)
min_y, max_y = self._get_cmd_board("y", execute_ids)
min_z, max_z = self._get_cmd_board("z", execute_ids)
vals_x = torch.zeros_like(min_x)
vals_x = torch.where(selected_combs[:, 0] == -1, min_x, vals_x)
vals_x = torch.where(selected_combs[:, 0] == 1, max_x, vals_x)
vals_y = torch.zeros_like(min_y)
vals_y = torch.where(selected_combs[:, 1] == -1, min_y, vals_y)
vals_y = torch.where(selected_combs[:, 1] == 1, max_y, vals_y)
vals_z = torch.zeros_like(min_z)
vals_z = torch.where(selected_combs[:, 2] == -1, min_z, vals_z)
vals_z = torch.where(selected_combs[:, 2] == 1, max_z, vals_z)
self.vel_command_b[execute_ids, 0] = vals_x
self.vel_command_b[execute_ids, 1] = vals_y
self.vel_command_b[execute_ids, 2] = vals_z
self.stop_heading[execute_ids] = True
if self.cfg.use_phase_cmd:
self.phase[env_ids] = torch.rand((len(env_ids), 1), device=self.device)
self.commands_xy_accumulation[env_ids] += self.vel_command_b[env_ids, :2]
def _update_command(self):
# 停止heading更新
stop_heading_env_ids = self.stop_heading.nonzero(as_tuple=False).flatten()
if len(stop_heading_env_ids) > 0:
self.is_heading_env[stop_heading_env_ids] = False
# Compute angular velocity from heading direction
if self.cfg.heading_command:
# resolve indices of heading envs
env_ids = self.is_heading_env.nonzero(as_tuple=False).flatten()
# compute angular velocity
if len(env_ids) > 0:
heading_error = math_utils.wrap_to_pi(self.heading_target[env_ids] - self.robot.data.heading_w[env_ids])
min_z, max_z = self._get_cmd_board("z", env_ids)
self.vel_command_b[env_ids, 2] = torch.clip(
self.cfg.heading_control_stiffness * heading_error,
min=min_z,
max=max_z,
)
if self.cfg.use_phase_cmd:
self.phase = self._env.episode_length_buf[:, None] * self._env.step_dt / self.cycle_time
current_dist = torch.norm(self.robot.data.root_pos_w[:, :2] - self.env_origins[:, :2], dim=1)
self.max_move_distance = torch.max(self.max_move_distance, current_dist)
def _set_debug_vis_impl(self, debug_vis: bool):
# set visibility of markers
# note: parent only deals with callbacks. not their visibility
if debug_vis:
# create markers if necessary for the first time
if not hasattr(self, "goal_vel_visualizer"):
# -- goal
self.goal_vel_visualizer = VisualizationMarkers(self.cfg.goal_vel_visualizer_cfg)
# -- current
self.current_vel_visualizer = VisualizationMarkers(self.cfg.current_vel_visualizer_cfg)
# set their visibility to true
self.goal_vel_visualizer.set_visibility(True)
self.current_vel_visualizer.set_visibility(True)
else:
if hasattr(self, "goal_vel_visualizer"):
self.goal_vel_visualizer.set_visibility(False)
self.current_vel_visualizer.set_visibility(False)
def _debug_vis_callback(self, event):
# check if robot is initialized
# note: this is needed in-case the robot is de-initialized. we can't access the data
if not self.robot.is_initialized:
return
# get marker location
# -- base state
base_pos_w = self.robot.data.root_pos_w.clone()
base_pos_w[:, 2] += 0.5
# -- resolve the scales and quaternions
vel_des_arrow_scale, vel_des_arrow_quat = self._resolve_xy_velocity_to_arrow(self.command[:, :2])
vel_arrow_scale, vel_arrow_quat = self._resolve_xy_velocity_to_arrow(self.robot.data.root_lin_vel_b[:, :2])
# display markers
self.goal_vel_visualizer.visualize(base_pos_w, vel_des_arrow_quat, vel_des_arrow_scale)
self.current_vel_visualizer.visualize(base_pos_w, vel_arrow_quat, vel_arrow_scale)
"""
Internal helpers.
"""
def _resolve_xy_velocity_to_arrow(self, xy_velocity: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
"""Converts the XY base velocity command to arrow direction rotation."""
# obtain default scale of the marker
default_scale = self.goal_vel_visualizer.cfg.markers["arrow"].scale
# arrow-scale
arrow_scale = torch.tensor(default_scale, device=self.device).repeat(xy_velocity.shape[0], 1)
arrow_scale[:, 0] *= torch.linalg.norm(xy_velocity, dim=1) * 3.0
# arrow-direction
heading_angle = torch.atan2(xy_velocity[:, 1], xy_velocity[:, 0])
zeros = torch.zeros_like(heading_angle)
arrow_quat = math_utils.quat_from_euler_xyz(zeros, zeros, heading_angle)
# convert everything back from base to world frame
base_quat_w = self.robot.data.root_quat_w
arrow_quat = math_utils.quat_mul(base_quat_w, arrow_quat)
return arrow_scale, arrow_quat
@configclass
class UniformVelTerrainCmdCfg(UniformVelocityCommandCfg):
class_type: type = UniformVelTerrainCmd
cycle_time:float = 0.5
max_angular_envs: float = 0.2
limit_vel_envs: float = 0.2
ranges: dict[str, UniformVelocityCommandCfg.Ranges] | UniformVelocityCommandCfg.Ranges = MISSING # 当前指令范围
terrain_max_ranges: dict[str, UniformVelocityCommandCfg.Ranges] = MISSING # type:ignore 地形最大指令范围
curriculum_schedule: list[dict] | None = None
use_phase_cmd: bool = True

View File

@@ -0,0 +1,216 @@
# Copyright (c) 2024-2025 Ziqi Fan
# SPDX-License-Identifier: Apache-2.0
"""Common functions that can be used to create curriculum for the learning environment.
The functions can be passed to the :class:`isaaclab.managers.CurriculumTermCfg` object to enable
the curriculum introduced by the function.
"""
from __future__ import annotations
import torch
from collections.abc import Sequence
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from isaaclab.envs import ManagerBasedRLEnv
def command_levels_lin_vel(
env: ManagerBasedRLEnv,
env_ids: Sequence[int],
reward_term_name: str,
range_multiplier: Sequence[float] = (0.1, 1.0),
) -> None:
"""command_levels_lin_vel"""
base_velocity_ranges = env.command_manager.get_term("base_velocity").cfg.ranges
# Get original velocity ranges (ONLY ON FIRST EPISODE)
if env.common_step_counter == 0:
env._original_vel_x = torch.tensor(base_velocity_ranges.lin_vel_x, device=env.device)
env._original_vel_y = torch.tensor(base_velocity_ranges.lin_vel_y, device=env.device)
env._initial_vel_x = env._original_vel_x * range_multiplier[0]
env._final_vel_x = env._original_vel_x * range_multiplier[1]
env._initial_vel_y = env._original_vel_y * range_multiplier[0]
env._final_vel_y = env._original_vel_y * range_multiplier[1]
# Initialize command ranges to initial values
base_velocity_ranges.lin_vel_x = env._initial_vel_x.tolist()
base_velocity_ranges.lin_vel_y = env._initial_vel_y.tolist()
# avoid updating command curriculum at each step since the maximum command is common to all envs
if env.common_step_counter % env.max_episode_length == 0:
episode_sums = env.reward_manager._episode_sums[reward_term_name]
reward_term_cfg = env.reward_manager.get_term_cfg(reward_term_name)
delta_command = torch.tensor([-0.1, 0.1], device=env.device)
# If the tracking reward is above 80% of the maximum, increase the range of commands
if torch.mean(episode_sums[env_ids]) / env.max_episode_length_s > 0.8 * reward_term_cfg.weight:
new_vel_x = torch.tensor(base_velocity_ranges.lin_vel_x, device=env.device) + delta_command
new_vel_y = torch.tensor(base_velocity_ranges.lin_vel_y, device=env.device) + delta_command
# Clamp to ensure we don't exceed final ranges
new_vel_x = torch.clamp(new_vel_x, min=env._final_vel_x[0], max=env._final_vel_x[1])
new_vel_y = torch.clamp(new_vel_y, min=env._final_vel_y[0], max=env._final_vel_y[1])
# Update ranges
base_velocity_ranges.lin_vel_x = new_vel_x.tolist()
base_velocity_ranges.lin_vel_y = new_vel_y.tolist()
return torch.tensor(base_velocity_ranges.lin_vel_x[1], device=env.device)
def command_levels_ang_vel(
env: ManagerBasedRLEnv,
env_ids: Sequence[int],
reward_term_name: str,
range_multiplier: Sequence[float] = (0.1, 1.0),
) -> None:
"""command_levels_ang_vel"""
base_velocity_ranges = env.command_manager.get_term("base_velocity").cfg.ranges
# Get original angular velocity ranges (ONLY ON FIRST EPISODE)
if env.common_step_counter == 0:
env._original_ang_vel_z = torch.tensor(base_velocity_ranges.ang_vel_z, device=env.device)
env._initial_ang_vel_z = env._original_ang_vel_z * range_multiplier[0]
env._final_ang_vel_z = env._original_ang_vel_z * range_multiplier[1]
# Initialize command ranges to initial values
base_velocity_ranges.ang_vel_z = env._initial_ang_vel_z.tolist()
# avoid updating command curriculum at each step since the maximum command is common to all envs
if env.common_step_counter % env.max_episode_length == 0:
episode_sums = env.reward_manager._episode_sums[reward_term_name]
reward_term_cfg = env.reward_manager.get_term_cfg(reward_term_name)
delta_command = torch.tensor([-0.1, 0.1], device=env.device)
# If the tracking reward is above 80% of the maximum, increase the range of commands
if torch.mean(episode_sums[env_ids]) / env.max_episode_length_s > 0.8 * reward_term_cfg.weight:
new_ang_vel_z = torch.tensor(base_velocity_ranges.ang_vel_z, device=env.device) + delta_command
# Clamp to ensure we don't exceed final ranges
new_ang_vel_z = torch.clamp(new_ang_vel_z, min=env._final_ang_vel_z[0], max=env._final_ang_vel_z[1])
# Update ranges
base_velocity_ranges.ang_vel_z = new_ang_vel_z.tolist()
return torch.tensor(base_velocity_ranges.ang_vel_z[1], device=env.device)
def command_curriculum(
env: ManagerBasedRLEnv,
env_ids: Sequence[int],
command_term_name: str,
num_steps_per_iter: int = 24,
) -> float:
"""
阶跃式指令课程 (数据存储在 CommandCfg 中)。
"""
try:
cmd_term = env.command_manager.get_term(command_term_name)
cmd_cfg = cmd_term.cfg
except LookupError:
return 0.0
current_iter = env.common_step_counter // num_steps_per_iter
schedule = cmd_cfg.curriculum_schedule
for i in range(len(schedule) - 1, -1, -1):
stage = schedule[i]
if current_iter >= stage['iter']:
for t_name, active_range in cmd_cfg.ranges.items():
hard_limit = cmd_cfg.terrain_max_ranges.get(t_name)
if hard_limit is None:
continue
def get_intersection(target_val, limit_val):
new_min = max(target_val[0], limit_val[0])
new_max = min(target_val[1], limit_val[1])
return (new_min, new_max)
if 'lin_vel_x' in stage:
new_range = get_intersection(stage['lin_vel_x'], hard_limit.lin_vel_x)
active_range.lin_vel_x = new_range
if 'lin_vel_y' in stage:
new_range = get_intersection(stage['lin_vel_y'], hard_limit.lin_vel_y)
active_range.lin_vel_y = new_range
if 'ang_vel_yaw' in stage:
new_range = get_intersection(stage['ang_vel_yaw'], hard_limit.ang_vel_z)
active_range.ang_vel_z = new_range
if 'heading' in stage and hasattr(active_range, 'heading'):
new_range = get_intersection(stage['heading'], hard_limit.heading)
active_range.heading = new_range
schedule.pop(i)
break
last_key = list(cmd_cfg.ranges.keys())[-1]
return cmd_cfg.ranges[last_key].lin_vel_x[1]
def gradual_ref_stand_modification(
env: ManagerBasedRLEnv,
env_ids: Sequence[int],
term_name: str,
initial: float,
final: float,
start_it: int,
end_it: int,
):
current_it = env.common_step_counter // 24
if current_it < start_it:
return
if current_it >= end_it:
new = final
else:
new = (current_it - start_it) / (end_it - start_it) * (final - initial) + initial
term = env.command_manager.get_term(term_name)
term.cfg.rel_standing_envs = new
def gradual_reward_weight_modification(
env: ManagerBasedRLEnv,
env_ids: Sequence[int],
term_name: str,
initial_weight: float,
final_weight: float,
start_it: int,
end_it: int,
):
"""Curriculum that gradually modifies a reward weight between an initial and final value over a range of steps."""
current_it = env.common_step_counter // 24
if current_it < start_it:
return
if current_it >= end_it:
new_weight = final_weight
else:
new_weight = (current_it - start_it) / (end_it - start_it) * (final_weight - initial_weight) + initial_weight
term_cfg = env.reward_manager.get_term_cfg(term_name)
term_cfg.weight = new_weight
env.reward_manager.set_term_cfg(term_name, term_cfg)
def terrain_levels_vel_gym(env: ManagerBasedRLEnv, env_ids: Sequence[int]) -> float:
"""
使用 max_move_distance 而非 reset 时的瞬间位移, 比较标准基于 commands_xy_accumulation
"""
terrain = env.scene.terrain
command = env.command_manager.get_term("base_velocity")
max_move_dist = command.max_move_distance[env_ids]
cmd_accum = command.commands_xy_accumulation[env_ids]
resampling_time = command.cfg.resampling_time_range[0]
zero_prob = command.cfg.rel_standing_envs
move_up = max_move_dist > terrain.cfg.terrain_generator.size[0] / 2
target_dist = torch.norm(cmd_accum, dim=1) * (resampling_time * (1 - zero_prob))
move_down = (max_move_dist < target_dist * 0.5) * ~move_up
terrain.update_env_origins(env_ids, move_up, move_down)
return torch.mean(terrain.terrain_levels.float()) # type: ignore

View File

@@ -0,0 +1,269 @@
# Copyright (c) 2024-2025 Ziqi Fan
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import torch
from typing import TYPE_CHECKING, Literal
import isaaclab.utils.math as math_utils
from isaaclab.assets import Articulation, RigidObject
from isaaclab.managers import SceneEntityCfg
from .utils import is_env_assigned_to_terrain
if TYPE_CHECKING:
from isaaclab.envs import ManagerBasedEnv
def randomize_rigid_body_inertia(
env: ManagerBasedEnv,
env_ids: torch.Tensor | None,
asset_cfg: SceneEntityCfg,
inertia_distribution_params: tuple[float, float],
operation: Literal["add", "scale", "abs"],
distribution: Literal["uniform", "log_uniform", "gaussian"] = "uniform",
):
"""Randomize the inertia tensors of the bodies by adding, scaling, or setting random values.
This function allows randomizing only the diagonal inertia tensor components (xx, yy, zz) of the bodies.
The function samples random values from the given distribution parameters and adds, scales, or sets the values
into the physics simulation based on the operation.
.. tip::
This function uses CPU tensors to assign the body inertias. It is recommended to use this function
only during the initialization of the environment.
"""
# extract the used quantities (to enable type-hinting)
asset: RigidObject | Articulation = env.scene[asset_cfg.name]
# resolve environment ids
if env_ids is None:
env_ids = torch.arange(env.scene.num_envs, device="cpu")
else:
env_ids = env_ids.cpu()
# resolve body indices
if asset_cfg.body_ids == slice(None):
body_ids = torch.arange(asset.num_bodies, dtype=torch.int, device="cpu")
else:
body_ids = torch.tensor(asset_cfg.body_ids, dtype=torch.int, device="cpu")
# get the current inertia tensors of the bodies (num_assets, num_bodies, 9 for articulations or 9 for rigid objects)
inertias = asset.root_physx_view.get_inertias()
# apply randomization on default values
inertias[env_ids[:, None], body_ids, :] = asset.data.default_inertia[env_ids[:, None], body_ids, :].clone()
# randomize each diagonal element (xx, yy, zz -> indices 0, 4, 8)
for idx in [0, 4, 8]:
# Extract and randomize the specific diagonal element
randomized_inertias = _randomize_prop_by_op(
inertias[:, :, idx],
inertia_distribution_params,
env_ids,
body_ids,
operation,
distribution,
)
# Assign the randomized values back to the inertia tensor
inertias[env_ids[:, None], body_ids, idx] = randomized_inertias
# set the inertia tensors into the physics simulation
asset.root_physx_view.set_inertias(inertias, env_ids)
def randomize_com_positions(
env: ManagerBasedEnv,
env_ids: torch.Tensor | None,
asset_cfg: SceneEntityCfg,
com_distribution_params: tuple[float, float],
operation: Literal["add", "scale", "abs"],
distribution: Literal["uniform", "log_uniform", "gaussian"] = "uniform",
):
"""Randomize the center of mass (COM) positions for the rigid bodies.
This function allows randomizing the COM positions of the bodies in the physics simulation. The positions can be
randomized by adding, scaling, or setting random values sampled from the specified distribution.
.. tip::
This function is intended for initialization or offline adjustments, as it modifies physics properties directly.
Args:
env (ManagerBasedEnv): The simulation environment.
env_ids (torch.Tensor | None): Specific environment indices to apply randomization, or None for all environments.
asset_cfg (SceneEntityCfg): The configuration for the target asset whose COM will be randomized.
com_distribution_params (tuple[float, float]): Parameters of the distribution (e.g., min and max for uniform).
operation (Literal["add", "scale", "abs"]): The operation to apply for randomization.
distribution (Literal["uniform", "log_uniform", "gaussian"]): The distribution to sample random values from.
"""
# Extract the asset (Articulation or RigidObject)
asset: RigidObject | Articulation = env.scene[asset_cfg.name]
# Resolve environment indices
if env_ids is None:
env_ids = torch.arange(env.scene.num_envs, device="cpu")
else:
env_ids = env_ids.cpu()
# Resolve body indices
if asset_cfg.body_ids == slice(None):
body_ids = torch.arange(asset.num_bodies, dtype=torch.int, device="cpu")
else:
body_ids = torch.tensor(asset_cfg.body_ids, dtype=torch.int, device="cpu")
# Get the current COM offsets (num_assets, num_bodies, 3)
com_offsets = asset.root_physx_view.get_coms()
for dim_idx in range(3): # Randomize x, y, z independently
randomized_offset = _randomize_prop_by_op(
com_offsets[:, :, dim_idx],
com_distribution_params,
env_ids,
body_ids,
operation,
distribution,
)
com_offsets[env_ids[:, None], body_ids, dim_idx] = randomized_offset[env_ids[:, None], body_ids]
# Set the randomized COM offsets into the simulation
asset.root_physx_view.set_coms(com_offsets, env_ids)
"""
Internal helper functions.
"""
def _randomize_prop_by_op(
data: torch.Tensor,
distribution_parameters: tuple[float | torch.Tensor, float | torch.Tensor],
dim_0_ids: torch.Tensor | None,
dim_1_ids: torch.Tensor | slice,
operation: Literal["add", "scale", "abs"],
distribution: Literal["uniform", "log_uniform", "gaussian"],
) -> torch.Tensor:
"""Perform data randomization based on the given operation and distribution.
Args:
data: The data tensor to be randomized. Shape is (dim_0, dim_1).
distribution_parameters: The parameters for the distribution to sample values from.
dim_0_ids: The indices of the first dimension to randomize.
dim_1_ids: The indices of the second dimension to randomize.
operation: The operation to perform on the data. Options: 'add', 'scale', 'abs'.
distribution: The distribution to sample the random values from. Options: 'uniform', 'log_uniform'.
Returns:
The data tensor after randomization. Shape is (dim_0, dim_1).
Raises:
NotImplementedError: If the operation or distribution is not supported.
"""
# resolve shape
# -- dim 0
if dim_0_ids is None:
n_dim_0 = data.shape[0]
dim_0_ids = slice(None)
else:
n_dim_0 = len(dim_0_ids)
if not isinstance(dim_1_ids, slice):
dim_0_ids = dim_0_ids[:, None]
# -- dim 1
if isinstance(dim_1_ids, slice):
n_dim_1 = data.shape[1]
else:
n_dim_1 = len(dim_1_ids)
# resolve the distribution
if distribution == "uniform":
dist_fn = math_utils.sample_uniform
elif distribution == "log_uniform":
dist_fn = math_utils.sample_log_uniform
elif distribution == "gaussian":
dist_fn = math_utils.sample_gaussian
else:
raise NotImplementedError(
f"Unknown distribution: '{distribution}' for joint properties randomization."
" Please use 'uniform', 'log_uniform', 'gaussian'."
)
# perform the operation
if operation == "add":
data[dim_0_ids, dim_1_ids] += dist_fn(*distribution_parameters, (n_dim_0, n_dim_1), device=data.device)
elif operation == "scale":
data[dim_0_ids, dim_1_ids] *= dist_fn(*distribution_parameters, (n_dim_0, n_dim_1), device=data.device)
elif operation == "abs":
data[dim_0_ids, dim_1_ids] = dist_fn(*distribution_parameters, (n_dim_0, n_dim_1), device=data.device)
else:
raise NotImplementedError(
f"Unknown operation: '{operation}' for property randomization. Please use 'add', 'scale', or 'abs'."
)
return data
def reset_root_state_uniform(
env: ManagerBasedEnv,
env_ids: torch.Tensor,
pose_range: dict[str, tuple[float, float]],
velocity_range: dict[str, tuple[float, float]],
asset_cfg: SceneEntityCfg = SceneEntityCfg("robot"),
):
"""Reset the asset root state to a random position and velocity uniformly within the given ranges.
This function randomizes the root position and velocity of the asset.
* It samples the root position from the given ranges and adds them to the default root position, before setting
them into the physics simulation.
* It samples the root orientation from the given ranges and sets them into the physics simulation.
* It samples the root velocity from the given ranges and sets them into the physics simulation.
The function takes a dictionary of pose and velocity ranges for each axis and rotation. The keys of the
dictionary are ``x``, ``y``, ``z``, ``roll``, ``pitch``, and ``yaw``. The values are tuples of the form
``(min, max)``. If the dictionary does not contain a key, the position or velocity is set to zero for that axis.
Note: If "pits" terrain exists, environments on pit terrain will be reset to default state without random
perturbations to avoid the robot falling into the pit.
"""
# extract the used quantities (to enable type-hinting)
asset: RigidObject | Articulation = env.scene[asset_cfg.name]
# Separate pit and non-pit environments
# Check which environments are assigned to pit terrain (not random reset)
assigned_to_pits = is_env_assigned_to_terrain(env, "pits")
pit_env_ids = env_ids[assigned_to_pits[env_ids]]
non_pit_env_ids = env_ids[~assigned_to_pits[env_ids]]
# Reset pit environments to default state (no random perturbations)
if len(pit_env_ids) > 0:
root_states = asset.data.default_root_state[pit_env_ids].clone()
positions = root_states[:, 0:3] + env.scene.env_origins[pit_env_ids]
orientations = root_states[:, 3:7]
velocities = torch.zeros_like(root_states[:, 7:13])
asset.write_root_pose_to_sim(torch.cat([positions, orientations], dim=-1), env_ids=pit_env_ids)
asset.write_root_velocity_to_sim(velocities, env_ids=pit_env_ids)
# Reset non-pit environments with random perturbations
if len(non_pit_env_ids) > 0:
root_states = asset.data.default_root_state[non_pit_env_ids].clone()
# poses
range_list = [pose_range.get(key, (0.0, 0.0)) for key in ["x", "y", "z", "roll", "pitch", "yaw"]]
ranges = torch.tensor(range_list, device=asset.device)
rand_samples = math_utils.sample_uniform(
ranges[:, 0], ranges[:, 1], (len(non_pit_env_ids), 6), device=asset.device
)
positions = root_states[:, 0:3] + env.scene.env_origins[non_pit_env_ids] + rand_samples[:, 0:3]
orientations_delta = math_utils.quat_from_euler_xyz(rand_samples[:, 3], rand_samples[:, 4], rand_samples[:, 5])
orientations = math_utils.quat_mul(root_states[:, 3:7], orientations_delta)
# velocities
range_list = [velocity_range.get(key, (0.0, 0.0)) for key in ["x", "y", "z", "roll", "pitch", "yaw"]]
ranges = torch.tensor(range_list, device=asset.device)
rand_samples = math_utils.sample_uniform(
ranges[:, 0], ranges[:, 1], (len(non_pit_env_ids), 6), device=asset.device
)
velocities = root_states[:, 7:13] + rand_samples
# set into the physics simulation
asset.write_root_pose_to_sim(torch.cat([positions, orientations], dim=-1), env_ids=non_pit_env_ids)
asset.write_root_velocity_to_sim(velocities, env_ids=non_pit_env_ids)

View File

@@ -0,0 +1,49 @@
# Copyright (c) 2024-2025 Ziqi Fan
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import torch
from typing import TYPE_CHECKING
from isaaclab.assets import Articulation
from isaaclab.managers import SceneEntityCfg
from isaaclab.sensors import ContactSensor
if TYPE_CHECKING:
from isaaclab.envs import ManagerBasedEnv, ManagerBasedRLEnv
def joint_pos_rel_without_wheel(
env: ManagerBasedEnv,
asset_cfg: SceneEntityCfg = SceneEntityCfg("robot"),
wheel_asset_cfg: SceneEntityCfg = SceneEntityCfg("robot"),
) -> torch.Tensor:
"""The joint positions of the asset w.r.t. the default joint positions.(Without the wheel joints)"""
# extract the used quantities (to enable type-hinting)
asset: Articulation = env.scene[asset_cfg.name]
joint_pos_rel = asset.data.joint_pos[:, asset_cfg.joint_ids] - asset.data.default_joint_pos[:, asset_cfg.joint_ids]
joint_pos_rel[:, wheel_asset_cfg.joint_ids] = 0
return joint_pos_rel
def phase(env: ManagerBasedRLEnv, cycle_time: float) -> torch.Tensor:
if not hasattr(env, "episode_length_buf") or env.episode_length_buf is None:
env.episode_length_buf = torch.zeros(env.num_envs, device=env.device, dtype=torch.long)
phase = env.episode_length_buf[:, None] * env.step_dt / cycle_time
phase_tensor = torch.cat([torch.sin(2 * torch.pi * phase), torch.cos(2 * torch.pi * phase)], dim=-1)
return phase_tensor
def joint_acc(env: ManagerBasedRLEnv, asset_cfg: SceneEntityCfg = SceneEntityCfg("robot")) -> torch.Tensor:
asset: Articulation = env.scene[asset_cfg.name]
return asset.data.joint_acc[:, asset_cfg.joint_ids]
def foot_contact_force_norm(env: ManagerBasedRLEnv, sensor_cfg: SceneEntityCfg) -> torch.Tensor:
contact_sensor: ContactSensor = env.scene.sensors[sensor_cfg.name]
net_contact_forces = contact_sensor.data.net_forces_w_history # [B, T_hist, num_bodies, 3]
contact_force_norm = torch.norm(net_contact_forces[:, :, sensor_cfg.body_ids], dim=-1) # [B, T_hist, num_legs]
max_contact_force_norm, _ = torch.max(contact_force_norm, dim=1) # [B, num_legs]
contact_force_norm = torch.concat([max_contact_force_norm.unsqueeze(1), contact_force_norm], dim=1) # [B, T_hist+1, num_legs]
return contact_force_norm.flatten(start_dim=-2) # [B, (T_hist+1)*num_legs]

View File

@@ -0,0 +1,557 @@
# Copyright (c) 2024-2025 Ziqi Fan
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import torch
from typing import TYPE_CHECKING
import isaaclab.utils.math as math_utils
from isaaclab.assets import Articulation, RigidObject
from isaaclab.envs import mdp
from isaaclab.managers import ManagerTermBase
from isaaclab.managers import RewardTermCfg as RewTerm
from isaaclab.managers import SceneEntityCfg
from isaaclab.sensors import ContactSensor, RayCaster
from isaaclab.utils.math import quat_apply_inverse, yaw_quat
if TYPE_CHECKING:
from isaaclab.envs import ManagerBasedRLEnv
def track_lin_vel_xy_exp(
env: ManagerBasedRLEnv, std: float, command_name: str, asset_cfg: SceneEntityCfg = SceneEntityCfg("robot")
) -> torch.Tensor:
"""Reward tracking of linear velocity commands (xy axes) using exponential kernel."""
# extract the used quantities (to enable type-hinting)
asset: RigidObject = env.scene[asset_cfg.name]
# compute the error
lin_vel_error = torch.sum(
torch.square(env.command_manager.get_command(command_name)[:, :2] - asset.data.root_lin_vel_b[:, :2]),
dim=1,
)
reward = torch.exp(-lin_vel_error / std**2)
reward *= torch.clamp(-env.scene["robot"].data.projected_gravity_b[:, 2], 0, 0.7) / 0.7
return reward
def track_ang_vel_z_exp(
env: ManagerBasedRLEnv, std: float, command_name: str, asset_cfg: SceneEntityCfg = SceneEntityCfg("robot")
) -> torch.Tensor:
"""Reward tracking of angular velocity commands (yaw) using exponential kernel."""
# extract the used quantities (to enable type-hinting)
asset: RigidObject = env.scene[asset_cfg.name]
# compute the error
ang_vel_error = torch.square(env.command_manager.get_command(command_name)[:, 2] - asset.data.root_ang_vel_b[:, 2])
reward = torch.exp(-ang_vel_error / std**2)
reward *= torch.clamp(-env.scene["robot"].data.projected_gravity_b[:, 2], 0, 0.7) / 0.7
return reward
def joint_power(env: ManagerBasedRLEnv, asset_cfg: SceneEntityCfg = SceneEntityCfg("robot")) -> torch.Tensor:
"""Reward joint_power"""
# extract the used quantities (to enable type-hinting)
asset: Articulation = env.scene[asset_cfg.name]
# compute the reward
reward = torch.sum(
torch.abs(asset.data.joint_vel[:, asset_cfg.joint_ids] * asset.data.applied_torque[:, asset_cfg.joint_ids]),
dim=1,
)
return reward
def stand_still(
env: ManagerBasedRLEnv,
command_name: str,
command_threshold: float = 0.06,
asset_cfg: SceneEntityCfg = SceneEntityCfg("robot"),
) -> torch.Tensor:
"""Penalize offsets from the default joint positions when the command is very small."""
# Penalize motion when command is nearly zero.
reward = mdp.joint_deviation_l1(env, asset_cfg)
reward *= torch.norm(env.command_manager.get_command(command_name), dim=1) < command_threshold
reward *= torch.clamp(-env.scene["robot"].data.projected_gravity_b[:, 2], 0, 0.7) / 0.7
return reward
def joint_pos_penalty(
env: ManagerBasedRLEnv,
command_name: str,
asset_cfg: SceneEntityCfg,
stand_still_scale: float,
velocity_threshold: float,
command_threshold: float,
) -> torch.Tensor:
"""Penalize joint position error from default on the articulation."""
# extract the used quantities (to enable type-hinting)
asset: Articulation = env.scene[asset_cfg.name]
cmd = torch.linalg.norm(env.command_manager.get_command(command_name), dim=1)
body_vel = torch.linalg.norm(asset.data.root_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,
)
reward *= torch.clamp(-env.scene["robot"].data.projected_gravity_b[:, 2], 0, 0.7) / 0.7
return reward
def joint_mirror(env: ManagerBasedRLEnv, asset_cfg: SceneEntityCfg, mirror_joints: list[list[str]]) -> torch.Tensor:
# extract the used quantities (to enable type-hinting)
asset: Articulation = env.scene[asset_cfg.name]
if not hasattr(env, "joint_mirror_joints_cache") or env.joint_mirror_joints_cache is None:
# Cache joint positions for all pairs
env.joint_mirror_joints_cache = [
[asset.find_joints(joint_name) for joint_name in joint_pair] for joint_pair in mirror_joints
]
reward = torch.zeros(env.num_envs, device=env.device)
# Iterate over all joint pairs
for joint_pair in env.joint_mirror_joints_cache:
# Calculate the difference for each pair and add to the total reward
diff = torch.sum(
torch.square(asset.data.joint_pos[:, joint_pair[0][0]] - asset.data.joint_pos[:, joint_pair[1][0]]),
dim=-1,
)
reward += diff
reward *= 1 / len(mirror_joints) if len(mirror_joints) > 0 else 0
reward *= torch.clamp(-env.scene["robot"].data.projected_gravity_b[:, 2], 0, 0.7) / 0.7
return reward
def action_mirror(env: ManagerBasedRLEnv, asset_cfg: SceneEntityCfg, mirror_joints: list[list[str]]) -> torch.Tensor:
# extract the used quantities (to enable type-hinting)
asset: Articulation = env.scene[asset_cfg.name]
if not hasattr(env, "action_mirror_joints_cache") or env.action_mirror_joints_cache is None:
# Cache joint positions for all pairs
env.action_mirror_joints_cache = [
[asset.find_joints(joint_name) for joint_name in joint_pair] for joint_pair in mirror_joints
]
reward = torch.zeros(env.num_envs, device=env.device)
# Iterate over all joint pairs
for joint_pair in env.action_mirror_joints_cache:
# Calculate the difference for each pair and add to the total reward
diff = torch.sum(
torch.square(
torch.abs(env.action_manager.action[:, joint_pair[0][0]])
- torch.abs(env.action_manager.action[:, joint_pair[1][0]])
),
dim=-1,
)
reward += diff
reward *= 1 / len(mirror_joints) if len(mirror_joints) > 0 else 0
reward *= torch.clamp(-env.scene["robot"].data.projected_gravity_b[:, 2], 0, 0.7) / 0.7
return reward
def action_sync(env: ManagerBasedRLEnv, asset_cfg: SceneEntityCfg, joint_groups: list[list[str]]) -> torch.Tensor:
# extract the used quantities (to enable type-hinting)
asset: Articulation = env.scene[asset_cfg.name]
# Cache joint indices if not already done
if not hasattr(env, "action_sync_joint_cache") or env.action_sync_joint_cache is None:
env.action_sync_joint_cache = [
[asset.find_joints(joint_name) for joint_name in joint_group] for joint_group in joint_groups
]
reward = torch.zeros(env.num_envs, device=env.device)
# Iterate over each joint group
for joint_group in env.action_sync_joint_cache:
if len(joint_group) < 2:
continue # need at least 2 joints to compare
# Get absolute actions for all joints in this group
actions = torch.stack(
[torch.abs(env.action_manager.action[:, joint[0]]) for joint in joint_group], dim=1
) # shape: (num_envs, num_joints_in_group)
# Calculate mean action for each environment
mean_actions = torch.mean(actions, dim=1, keepdim=True)
# Calculate variance from mean for each joint
variance = torch.mean(torch.square(actions - mean_actions), dim=1)
# Add to reward (we want to minimize this variance)
reward += variance.squeeze()
reward *= 1 / len(joint_groups) if len(joint_groups) > 0 else 0
reward *= torch.clamp(-env.scene["robot"].data.projected_gravity_b[:, 2], 0, 0.7) / 0.7
return reward
def feet_air_time(
env: ManagerBasedRLEnv, command_name: str, sensor_cfg: SceneEntityCfg, threshold: float
) -> torch.Tensor:
"""Reward long steps taken by the feet using L2-kernel.
This function rewards the agent for taking steps that are longer than a threshold. This helps ensure
that the robot lifts its feet off the ground and takes steps. The reward is computed as the sum of
the time for which the feet are in the air.
If the commands are small (i.e. the agent is not supposed to take a step), then the reward is zero.
"""
# extract the used quantities (to enable type-hinting)
contact_sensor: ContactSensor = env.scene.sensors[sensor_cfg.name]
# compute the reward
first_contact = contact_sensor.compute_first_contact(env.step_dt)[:, sensor_cfg.body_ids]
last_air_time = contact_sensor.data.last_air_time[:, sensor_cfg.body_ids]
reward = torch.sum((last_air_time - threshold) * first_contact, dim=1)
# no reward for zero command
reward *= torch.norm(env.command_manager.get_command(command_name), dim=1) > 0.1
reward *= torch.clamp(-env.scene["robot"].data.projected_gravity_b[:, 2], 0, 0.7) / 0.7
return reward
def feet_air_time_positive_biped(env, command_name: str, threshold: float, sensor_cfg: SceneEntityCfg) -> torch.Tensor:
"""Reward long steps taken by the feet for bipeds.
This function rewards the agent for taking steps up to a specified threshold and also keep one foot at
a time in the air.
If the commands are small (i.e. the agent is not supposed to take a step), then the reward is zero.
"""
contact_sensor: ContactSensor = env.scene.sensors[sensor_cfg.name]
# compute the reward
air_time = contact_sensor.data.current_air_time[:, sensor_cfg.body_ids]
contact_time = contact_sensor.data.current_contact_time[:, sensor_cfg.body_ids]
in_contact = contact_time > 0.0
in_mode_time = torch.where(in_contact, contact_time, air_time)
single_stance = torch.sum(in_contact.int(), dim=1) == 1
reward = torch.min(torch.where(single_stance.unsqueeze(-1), in_mode_time, 0.0), dim=1)[0]
reward = torch.clamp(reward, max=threshold)
# no reward for zero command
reward *= torch.norm(env.command_manager.get_command(command_name), dim=1) > 0.1
reward *= torch.clamp(-env.scene["robot"].data.projected_gravity_b[:, 2], 0, 0.7) / 0.7
return reward
def feet_air_time_variance_penalty(env: ManagerBasedRLEnv, sensor_cfg: SceneEntityCfg) -> torch.Tensor:
"""Penalize variance in the amount of time each foot spends in the air/on the ground relative to each other"""
# extract the used quantities (to enable type-hinting)
contact_sensor: ContactSensor = env.scene.sensors[sensor_cfg.name]
# compute the reward
last_air_time = contact_sensor.data.last_air_time[:, sensor_cfg.body_ids]
last_contact_time = contact_sensor.data.last_contact_time[:, sensor_cfg.body_ids]
reward = torch.var(torch.clip(last_air_time, max=0.5), dim=1) + torch.var(
torch.clip(last_contact_time, max=0.5), dim=1
)
reward *= torch.clamp(-env.scene["robot"].data.projected_gravity_b[:, 2], 0, 0.7) / 0.7
return reward
def feet_contact(
env: ManagerBasedRLEnv, command_name: str, expect_contact_num: int, sensor_cfg: SceneEntityCfg
) -> torch.Tensor:
"""Reward feet contact"""
# extract the used quantities (to enable type-hinting)
contact_sensor: ContactSensor = env.scene.sensors[sensor_cfg.name]
# compute the reward
contact = contact_sensor.compute_first_contact(env.step_dt)[:, sensor_cfg.body_ids]
contact_num = torch.sum(contact, dim=1)
reward = (contact_num != expect_contact_num).float()
# no reward for zero command
reward *= torch.linalg.norm(env.command_manager.get_command(command_name), dim=1) > 0.1
reward *= torch.clamp(-env.scene["robot"].data.projected_gravity_b[:, 2], 0, 0.7) / 0.7
return reward
def feet_contact_without_cmd(env: ManagerBasedRLEnv, command_name: str, sensor_cfg: SceneEntityCfg) -> torch.Tensor:
"""Reward feet contact"""
# extract the used quantities (to enable type-hinting)
contact_sensor: ContactSensor = env.scene.sensors[sensor_cfg.name]
# compute the reward
contact = contact_sensor.compute_first_contact(env.step_dt)[:, sensor_cfg.body_ids]
reward = torch.sum(contact, dim=-1).float()
reward *= torch.linalg.norm(env.command_manager.get_command(command_name), dim=1) < 0.1
reward *= torch.clamp(-env.scene["robot"].data.projected_gravity_b[:, 2], 0, 0.7) / 0.7
return reward
def feet_stumble(env: ManagerBasedRLEnv, sensor_cfg: SceneEntityCfg) -> torch.Tensor:
# extract the used quantities (to enable type-hinting)
contact_sensor: ContactSensor = env.scene.sensors[sensor_cfg.name]
forces_z = torch.abs(contact_sensor.data.net_forces_w[:, sensor_cfg.body_ids, 2])
forces_xy = torch.linalg.norm(contact_sensor.data.net_forces_w[:, sensor_cfg.body_ids, :2], dim=2)
# Penalize feet hitting vertical surfaces
reward = torch.any(forces_xy > 4 * forces_z, dim=1).float()
reward *= torch.clamp(-env.scene["robot"].data.projected_gravity_b[:, 2], 0, 0.7) / 0.7
return reward
def feet_distance_y_exp(
env: ManagerBasedRLEnv, stance_width: float, std: float, asset_cfg: SceneEntityCfg = SceneEntityCfg("robot")
) -> torch.Tensor:
asset: RigidObject = env.scene[asset_cfg.name]
cur_footsteps_translated = asset.data.body_link_pos_w[:, asset_cfg.body_ids, :] - asset.data.root_link_pos_w[
:, :
].unsqueeze(1)
n_feet = len(asset_cfg.body_ids)
footsteps_in_body_frame = torch.zeros(env.num_envs, n_feet, 3, device=env.device)
for i in range(n_feet):
footsteps_in_body_frame[:, i, :] = math_utils.quat_apply(
math_utils.quat_conjugate(asset.data.root_link_quat_w), cur_footsteps_translated[:, i, :]
)
side_sign = torch.tensor(
[1.0 if i % 2 == 0 else -1.0 for i in range(n_feet)],
device=env.device,
)
stance_width_tensor = stance_width * torch.ones([env.num_envs, 1], device=env.device)
desired_ys = stance_width_tensor / 2 * side_sign.unsqueeze(0)
stance_diff = torch.square(desired_ys - footsteps_in_body_frame[:, :, 1])
reward = torch.exp(-torch.sum(stance_diff, dim=1) / (std**2))
reward *= torch.clamp(-env.scene["robot"].data.projected_gravity_b[:, 2], 0, 0.7) / 0.7
return reward
def feet_distance_xy_exp(
env: ManagerBasedRLEnv,
stance_width: float,
stance_length: float,
std: float,
asset_cfg: SceneEntityCfg = SceneEntityCfg("robot"),
) -> torch.Tensor:
asset: RigidObject = env.scene[asset_cfg.name]
# Compute the current footstep positions relative to the root
cur_footsteps_translated = asset.data.body_link_pos_w[:, asset_cfg.body_ids, :] - asset.data.root_link_pos_w[
:, :
].unsqueeze(1)
footsteps_in_body_frame = torch.zeros(env.num_envs, 4, 3, device=env.device)
for i in range(4):
footsteps_in_body_frame[:, i, :] = math_utils.quat_apply(
math_utils.quat_conjugate(asset.data.root_link_quat_w), cur_footsteps_translated[:, i, :]
)
# Desired x and y positions for each foot
stance_width_tensor = stance_width * torch.ones([env.num_envs, 1], device=env.device)
stance_length_tensor = stance_length * torch.ones([env.num_envs, 1], device=env.device)
desired_xs = torch.cat(
[stance_length_tensor / 2, stance_length_tensor / 2, -stance_length_tensor / 2, -stance_length_tensor / 2],
dim=1,
)
desired_ys = torch.cat(
[stance_width_tensor / 2, -stance_width_tensor / 2, stance_width_tensor / 2, -stance_width_tensor / 2], dim=1
)
# Compute differences in x and y
stance_diff_x = torch.square(desired_xs - footsteps_in_body_frame[:, :, 0])
stance_diff_y = torch.square(desired_ys - footsteps_in_body_frame[:, :, 1])
# Combine x and y differences and compute the exponential penalty
stance_diff = stance_diff_x + stance_diff_y
reward = torch.exp(-torch.sum(stance_diff, dim=1) / std**2)
reward *= torch.clamp(-env.scene["robot"].data.projected_gravity_b[:, 2], 0, 0.7) / 0.7
return reward
def feet_height(
env: ManagerBasedRLEnv,
command_name: str,
asset_cfg: SceneEntityCfg,
target_height: float,
tanh_mult: float,
) -> torch.Tensor:
"""Reward the swinging feet for clearing a specified height off the ground"""
asset: RigidObject = env.scene[asset_cfg.name]
foot_z_target_error = torch.square(asset.data.body_pos_w[:, asset_cfg.body_ids, 2] - target_height)
foot_velocity_tanh = torch.tanh(
tanh_mult * torch.linalg.norm(asset.data.body_lin_vel_w[:, asset_cfg.body_ids, :2], dim=2)
)
reward = torch.sum(foot_z_target_error * foot_velocity_tanh, dim=1)
# no reward for zero command
reward *= torch.linalg.norm(env.command_manager.get_command(command_name), dim=1) > 0.1
reward *= torch.clamp(-env.scene["robot"].data.projected_gravity_b[:, 2], 0, 0.7) / 0.7
return reward
def feet_height_body(
env: ManagerBasedRLEnv,
command_name: str,
asset_cfg: SceneEntityCfg,
target_height: float,
tanh_mult: float,
) -> torch.Tensor:
"""Reward the swinging feet for clearing a specified height off the ground"""
asset: RigidObject = env.scene[asset_cfg.name]
cur_footpos_translated = asset.data.body_pos_w[:, asset_cfg.body_ids, :] - asset.data.root_pos_w[:, :].unsqueeze(1)
footpos_in_body_frame = torch.zeros(env.num_envs, len(asset_cfg.body_ids), 3, device=env.device)
cur_footvel_translated = asset.data.body_lin_vel_w[:, asset_cfg.body_ids, :] - asset.data.root_lin_vel_w[
:, :
].unsqueeze(1)
footvel_in_body_frame = torch.zeros(env.num_envs, len(asset_cfg.body_ids), 3, device=env.device)
for i in range(len(asset_cfg.body_ids)):
footpos_in_body_frame[:, i, :] = math_utils.quat_apply_inverse(
asset.data.root_quat_w, cur_footpos_translated[:, i, :]
)
footvel_in_body_frame[:, i, :] = math_utils.quat_apply_inverse(
asset.data.root_quat_w, cur_footvel_translated[:, i, :]
)
foot_z_target_error = torch.square(footpos_in_body_frame[:, :, 2] - target_height).view(env.num_envs, -1)
foot_velocity_tanh = torch.tanh(tanh_mult * torch.norm(footvel_in_body_frame[:, :, :2], dim=2))
reward = torch.sum(foot_z_target_error * foot_velocity_tanh, dim=1)
reward *= torch.linalg.norm(env.command_manager.get_command(command_name), dim=1) > 0.1
reward *= torch.clamp(-env.scene["robot"].data.projected_gravity_b[:, 2], 0, 0.7) / 0.7
return reward
def feet_slide(
env: ManagerBasedRLEnv, sensor_cfg: SceneEntityCfg, asset_cfg: SceneEntityCfg = SceneEntityCfg("robot")
) -> torch.Tensor:
"""Penalize feet sliding.
This function penalizes the agent for sliding its feet on the ground. The reward is computed as the
norm of the linear velocity of the feet multiplied by a binary contact sensor. This ensures that the
agent is penalized only when the feet are in contact with the ground.
"""
# Penalize feet sliding
contact_sensor: ContactSensor = env.scene.sensors[sensor_cfg.name]
contacts = contact_sensor.data.net_forces_w_history[:, :, sensor_cfg.body_ids, :].norm(dim=-1).max(dim=1)[0] > 1.0
asset: RigidObject = env.scene[asset_cfg.name]
# feet_vel = asset.data.body_lin_vel_w[:, asset_cfg.body_ids, :2]
# reward = torch.sum(feet_vel.norm(dim=-1) * contacts, dim=1)
cur_footvel_translated = asset.data.body_lin_vel_w[:, asset_cfg.body_ids, :] - asset.data.root_lin_vel_w[
:, :
].unsqueeze(1)
footvel_in_body_frame = torch.zeros(env.num_envs, len(asset_cfg.body_ids), 3, device=env.device)
for i in range(len(asset_cfg.body_ids)):
footvel_in_body_frame[:, i, :] = math_utils.quat_apply_inverse(
asset.data.root_quat_w, cur_footvel_translated[:, i, :]
)
foot_leteral_vel = torch.sqrt(torch.sum(torch.square(footvel_in_body_frame[:, :, :2]), dim=2)).view(
env.num_envs, -1
)
reward = torch.sum(foot_leteral_vel * contacts, dim=1)
reward *= torch.clamp(-env.scene["robot"].data.projected_gravity_b[:, 2], 0, 0.7) / 0.7
return reward
# def smoothness_1(env: ManagerBasedRLEnv) -> torch.Tensor:
# # Penalize changes in actions
# diff = torch.square(env.action_manager.action - env.action_manager.prev_action)
# diff = diff * (env.action_manager.prev_action[:, :] != 0) # ignore first step
# return torch.sum(diff, dim=1)
# def smoothness_2(env: ManagerBasedRLEnv) -> torch.Tensor:
# # Penalize changes in actions
# diff = torch.square(env.action_manager.action - 2 * env.action_manager.prev_action + env.action_manager.prev_prev_action)
# diff = diff * (env.action_manager.prev_action[:, :] != 0) # ignore first step
# diff = diff * (env.action_manager.prev_prev_action[:, :] != 0) # ignore second step
# return torch.sum(diff, dim=1)
def action_smoothness_l2(env: ManagerBasedRLEnv) -> torch.Tensor:
# Penalize changes in actions
diff = torch.square(env.action_manager.action - 2 * env.action_manager.prev_action + env.action_manager.prev_prev_action)
diff = diff * (env.action_manager.prev_action[:, :] != 0) # ignore first step
diff = diff * (env.action_manager.prev_prev_action[:, :] != 0) # ignore second step
return torch.sum(diff, dim=1)
def upward(env: ManagerBasedRLEnv, asset_cfg: SceneEntityCfg = SceneEntityCfg("robot")) -> torch.Tensor:
"""Penalize z-axis base linear velocity using L2 squared kernel."""
# extract the used quantities (to enable type-hinting)
asset: RigidObject = env.scene[asset_cfg.name]
reward = torch.square(1 - asset.data.projected_gravity_b[:, 2])
return reward
def base_height_l2(
env: ManagerBasedRLEnv,
target_height: float,
asset_cfg: SceneEntityCfg = SceneEntityCfg("robot"),
sensor_cfg: SceneEntityCfg | None = None,
) -> torch.Tensor:
"""Penalize asset height from its target using L2 squared kernel.
Note:
For flat terrain, target height is in the world frame. For rough terrain,
sensor readings can adjust the target height to account for the terrain.
"""
# extract the used quantities (to enable type-hinting)
asset: RigidObject = env.scene[asset_cfg.name]
if sensor_cfg is not None:
sensor: RayCaster = env.scene[sensor_cfg.name]
# Adjust the target height using the sensor data
ray_hits = sensor.data.ray_hits_w[..., 2]
if torch.isnan(ray_hits).any() or torch.isinf(ray_hits).any() or torch.max(torch.abs(ray_hits)) > 1e6:
adjusted_target_height = asset.data.root_link_pos_w[:, 2]
else:
adjusted_target_height = target_height + torch.mean(ray_hits, dim=1)
else:
# Use the provided target height directly for flat terrain
adjusted_target_height = target_height
# Compute the L2 squared penalty
reward = torch.square(asset.data.root_pos_w[:, 2] - adjusted_target_height)
reward *= torch.clamp(-env.scene["robot"].data.projected_gravity_b[:, 2], 0, 0.7) / 0.7
return reward
def lin_vel_z_l2(env: ManagerBasedRLEnv, asset_cfg: SceneEntityCfg = SceneEntityCfg("robot")) -> torch.Tensor:
"""Penalize z-axis base linear velocity using L2 squared kernel."""
# extract the used quantities (to enable type-hinting)
asset: RigidObject = env.scene[asset_cfg.name]
reward = torch.square(asset.data.root_lin_vel_b[:, 2])
reward *= torch.clamp(-env.scene["robot"].data.projected_gravity_b[:, 2], 0, 0.7) / 0.7
return reward
def ang_vel_xy_l2(env: ManagerBasedRLEnv, asset_cfg: SceneEntityCfg = SceneEntityCfg("robot")) -> torch.Tensor:
"""Penalize xy-axis base angular velocity using L2 squared kernel."""
# extract the used quantities (to enable type-hinting)
asset: RigidObject = env.scene[asset_cfg.name]
reward = torch.sum(torch.square(asset.data.root_ang_vel_b[:, :2]), dim=1)
reward *= torch.clamp(-env.scene["robot"].data.projected_gravity_b[:, 2], 0, 0.7) / 0.7
return reward
def undesired_contacts(env: ManagerBasedRLEnv, threshold: float, sensor_cfg: SceneEntityCfg) -> torch.Tensor:
"""Penalize undesired contacts as the number of violations that are above a threshold."""
# extract the used quantities (to enable type-hinting)
contact_sensor: ContactSensor = env.scene.sensors[sensor_cfg.name]
# check if contact force is above threshold
net_contact_forces = contact_sensor.data.net_forces_w_history
is_contact = torch.max(torch.norm(net_contact_forces[:, :, sensor_cfg.body_ids], dim=-1), dim=1)[0] > threshold
# sum over contacts for each environment
reward = torch.sum(is_contact, dim=1).float()
reward *= torch.clamp(-env.scene["robot"].data.projected_gravity_b[:, 2], 0, 0.7) / 0.7
return reward
def flat_orientation_l2(env: ManagerBasedRLEnv, asset_cfg: SceneEntityCfg = SceneEntityCfg("robot")) -> torch.Tensor:
"""Penalize non-flat base orientation using L2 squared kernel.
This is computed by penalizing the xy-components of the projected gravity vector.
"""
# extract the used quantities (to enable type-hinting)
asset: RigidObject = env.scene[asset_cfg.name]
reward = torch.sum(torch.square(asset.data.projected_gravity_b[:, :2]), dim=1)
reward *= torch.clamp(-env.scene["robot"].data.projected_gravity_b[:, 2], 0, 0.7) / 0.7
return reward
def hip_pos_penalty(
env: ManagerBasedRLEnv,
command_name: str,
asset_cfg: SceneEntityCfg,
stand_still_scale: float,
command_threshold: float,
) -> torch.Tensor:
"""Penalize joint position error from default on the articulation."""
# extract the used quantities (to enable type-hinting)
asset: Articulation = env.scene[asset_cfg.name]
command = env.command_manager.get_command(command_name)[:, [1, 2]]
cmd_large = torch.any(torch.abs(command) > command_threshold, 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(
cmd_large,
running_reward,
stand_still_scale * running_reward
)
return reward

View File

@@ -0,0 +1,126 @@
# Copyright (c) 2024-2025 Ziqi Fan
# SPDX-License-Identifier: Apache-2.0
"""Utility functions for terrain-aware operations."""
from __future__ import annotations
import torch
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from isaaclab.envs import ManagerBasedEnv
def _get_terrain_column_range(terrain_cfg, terrain_name: str, device) -> tuple[int, int] | None:
"""Helper function to calculate column range for a terrain type.
Args:
terrain_cfg: The terrain generator configuration.
terrain_name: Name of the terrain.
device: Torch device.
Returns:
Tuple of (col_start, col_end) or None if terrain not found.
"""
if terrain_cfg.sub_terrains is None or terrain_name not in terrain_cfg.sub_terrains:
return None
sub_terrain_names = list(terrain_cfg.sub_terrains.keys())
proportions = torch.tensor([sub_cfg.proportion for sub_cfg in terrain_cfg.sub_terrains.values()], device=device)
proportions = proportions / proportions.sum()
cumsum_props = torch.cumsum(proportions, dim=0)
terrain_idx = sub_terrain_names.index(terrain_name)
# Use round() instead of int() to properly allocate columns
col_start = round((0.0 if terrain_idx == 0 else cumsum_props[terrain_idx - 1].item()) * terrain_cfg.num_cols)
col_end = round(cumsum_props[terrain_idx].item() * terrain_cfg.num_cols)
return (col_start, col_end)
def is_env_assigned_to_terrain(env: ManagerBasedEnv, terrain_name: str) -> torch.Tensor:
"""Check which environments are initially assigned to the specified terrain type.
Each environment is assigned to a specific terrain cell at initialization.
This function returns a mask indicating which environments were assigned to the given terrain type.
Args:
env: The environment instance.
terrain_name: Name of the terrain to check (e.g., "pits", "stairs").
Returns:
Boolean tensor of shape (num_envs,) where True means the environment is assigned to this terrain.
"""
# Check if terrain and terrain generator are available
terrain = getattr(env.scene, "terrain", None)
if terrain is None or not hasattr(terrain, "terrain_types"):
return torch.zeros(env.num_envs, dtype=torch.bool, device=env.device)
if terrain.cfg.terrain_type != "generator" or terrain.cfg.terrain_generator is None:
return torch.zeros(env.num_envs, dtype=torch.bool, device=env.device)
terrain_cfg = terrain.cfg.terrain_generator
col_range = _get_terrain_column_range(terrain_cfg, terrain_name, env.device)
if col_range is None:
return torch.zeros(env.num_envs, dtype=torch.bool, device=env.device)
col_start, col_end = col_range
# terrain_types directly stores column indices, so just check if they're in range
return (terrain.terrain_types >= col_start) & (terrain.terrain_types < col_end)
def is_robot_on_terrain(env: ManagerBasedEnv, terrain_name: str, asset_name: str = "robot") -> torch.Tensor:
"""Check which robots are currently standing on the specified terrain type.
This function calculates which terrain grid cell each robot is on based on its world position,
then checks if that cell's terrain type matches the specified terrain.
Args:
env: The environment instance.
terrain_name: Name of the terrain to check (e.g., "pits", "stairs").
asset_name: Name of the robot asset. Defaults to "robot".
Returns:
Boolean tensor of shape (num_envs,) where True means the robot is currently on this terrain.
"""
# Check if terrain and terrain generator are available
terrain = getattr(env.scene, "terrain", None)
if terrain is None or not hasattr(terrain, "terrain_types"):
return torch.zeros(env.num_envs, dtype=torch.bool, device=env.device)
if terrain.cfg.terrain_type != "generator" or terrain.cfg.terrain_generator is None:
return torch.zeros(env.num_envs, dtype=torch.bool, device=env.device)
terrain_cfg = terrain.cfg.terrain_generator
col_range = _get_terrain_column_range(terrain_cfg, terrain_name, env.device)
if col_range is None:
return torch.zeros(env.num_envs, dtype=torch.bool, device=env.device)
col_start, col_end = col_range
# Get robot positions in world frame
asset = env.scene[asset_name]
robot_pos_w = asset.data.root_pos_w[:, :2] # [num_envs, 2] (x, y)
# Get terrain grid information
terrain_origins = terrain.terrain_origins # [num_rows, num_cols, 3]
num_rows, num_cols, _ = terrain_origins.shape
# Use terrain_origins to directly compute which cell each robot is in
# terrain_origins[r, c, :2] is the center of cell (r, c)
# We need to find the closest terrain origin for each robot
# Reshape terrain_origins for distance calculation
terrain_origins_2d = terrain_origins[:, :, :2].reshape(num_rows * num_cols, 2) # [num_rows*num_cols, 2]
# Calculate distances from each robot to all terrain origins
distances = torch.cdist(robot_pos_w, terrain_origins_2d) # [num_envs, num_rows*num_cols]
# Find the closest terrain origin for each robot
closest_flat_idx = torch.argmin(distances, dim=1) # [num_envs]
# Convert flat index to column index
# flat_idx = row * num_cols + col
col_idx = closest_flat_idx % num_cols # [num_envs]
# Check if the robot's current terrain column is in the specified terrain's range
return (col_idx >= col_start) & (col_idx < col_end)

View File

@@ -0,0 +1,86 @@
from isaaclab.utils import configclass
from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg
@configclass
class PPORunnerCfg(RslRlOnPolicyRunnerCfg):
num_steps_per_env = 24
max_iterations = 150000
save_interval = 500
experiment_name = "go2_rough"
policy = RslRlPpoActorCriticCfg(
init_noise_std=1.0,
actor_obs_normalization=False,
critic_obs_normalization=False,
actor_hidden_dims=[512, 256, 128],
critic_hidden_dims=[512, 256, 128],
activation="elu",
)
algorithm = RslRlPpoAlgorithmCfg(
value_loss_coef=1.0,
use_clipped_value_loss=True,
clip_param=0.2,
entropy_coef=0.01,
num_learning_epochs=5,
num_mini_batches=4,
learning_rate=1.0e-3,
schedule="adaptive",
gamma=0.99,
lam=0.95,
desired_kl=0.01,
max_grad_norm=1.0,
)
@configclass
class RslRlMoeCtsActorCriticCfg(RslRlPpoActorCriticCfg):
class_name = "ActorCriticMoECTS"
init_noise_std = 1.0
expert_num = 8 # number of experts in the student model
latent_dim = 32
norm_type = 'l2norm' # normalization type for encoders: l2norm, simnorm
teacher_encoder_hidden_dims = [512, 256, 128]
student_encoder_hidden_dims = [512, 256, 128]
actor_hidden_dims=[512, 256, 128]
critic_hidden_dims=[512, 256, 128]
activation="elu"
actor_obs_normalization = False
critic_obs_normalization = False
@configclass
class RslRlMoeCtsAlgorithmCfg(RslRlPpoAlgorithmCfg):
class_name = "MoECTS"
value_loss_coef = 1.0
load_balance_coef = 0.01 # coefficient for load balance loss
use_clipped_value_loss = True
clip_param = 0.2
entropy_coef = 0.01
num_learning_epochs = 5
num_mini_batches = 4
learning_rate = 1e-3
student_encoder_learning_rate = 1e-3
schedule = "adaptive"
gamma = 0.99
lam = 0.95
betas = (0.9, 0.999)
weight_decay = 0.0
desired_kl = 0.01
max_grad_norm = 1.0
teacher_env_ratio = 0.75 # percentage of envs assigned to teacher
@configclass
class MoECTSRunnerCfg(RslRlOnPolicyRunnerCfg):
experiment_name = "go2_moe_cts"
class_name = "OnPolicyRunnerCTS"
num_steps_per_env = 24
max_iterations = 150000
save_interval = 500
policy = RslRlMoeCtsActorCriticCfg()
algorithm = RslRlMoeCtsAlgorithmCfg()
# concat elu inspired by concat relu from https://arxiv.org/pdf/2303.07507
@configclass
class MoECTSCatELURunnerCfg(MoECTSRunnerCfg):
def __post_init__(self):
super().__post_init__()
self.policy.activation = 'cat_elu'

View File

@@ -0,0 +1,49 @@
# Copyright (c) 2024-2025 Ziqi Fan
# SPDX-License-Identifier: Apache-2.0
# Copyright (c) 2022-2025, The Isaac Lab Project Developers.
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
import omni.ext
# Functions and vars are available to other extension as usual in python: `example.python_ext.some_public_function(x)`
def some_public_function(x: int):
print("[robot_lab] some_public_function was called with x: ", x)
return x**x
# Any class derived from `omni.ext.IExt` in top level module (defined in `python.modules` of `extension.toml`) will be
# instantiated when extension gets enabled and `on_startup(ext_id)` will be called. Later when extension gets disabled
# on_shutdown() is called.
class ExampleExtension(omni.ext.IExt):
# ext_id is current extension id. It can be used with extension manager to query additional information, like where
# this extension is located on filesystem.
def on_startup(self, ext_id):
print("[robot_lab] startup")
self._count = 0
self._window = omni.ui.Window("My Window", width=300, height=300)
with self._window.frame:
with omni.ui.VStack():
label = omni.ui.Label("")
def on_click():
self._count += 1
label.text = f"count: {self._count}"
def on_reset():
self._count = 0
label.text = "empty"
on_reset()
with omni.ui.HStack():
omni.ui.Button("Add", clicked_fn=on_click)
omni.ui.Button("Reset", clicked_fn=on_reset)
def on_shutdown(self):
print("[robot_lab] shutdown")

53
source/robot_lab/setup.py Normal file
View File

@@ -0,0 +1,53 @@
# Copyright (c) 2024-2025 Ziqi Fan
# SPDX-License-Identifier: Apache-2.0
"""Installation script for the 'robot_lab' python package."""
import os
import toml
from setuptools import setup
# Obtain the extension data from the extension.toml file
EXTENSION_PATH = os.path.dirname(os.path.realpath(__file__))
# Read the extension.toml file
EXTENSION_TOML_DATA = toml.load(os.path.join(EXTENSION_PATH, "config", "extension.toml"))
# Minimum dependencies required prior to installation
INSTALL_REQUIRES = [
# base
"psutil",
"colorama",
"xacrodoc",
# amp
"numpy",
"pandas",
"pinocchio",
# rl
"cusrl[all]",
]
# Installation operation
setup(
name="robot_lab",
packages=["robot_lab"],
author=EXTENSION_TOML_DATA["package"]["author"],
maintainer=EXTENSION_TOML_DATA["package"]["maintainer"],
url=EXTENSION_TOML_DATA["package"]["repository"],
version=EXTENSION_TOML_DATA["package"]["version"],
description=EXTENSION_TOML_DATA["package"]["description"],
keywords=EXTENSION_TOML_DATA["package"]["keywords"],
install_requires=INSTALL_REQUIRES,
license="Apache License 2.0",
include_package_data=True,
python_requires=">=3.10",
classifiers=[
"Natural Language :: English",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Isaac Sim :: 4.5.0",
"Isaac Sim :: 5.0.0",
"Isaac Sim :: 5.1.0",
],
zip_safe=False,
)

14
source/rsl_rl/.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
# IDEs
.idea
# builds
*.egg-info
build/*
dist/*
# cache
__pycache__
.pytest_cache
# vs code
.vscode

View File

@@ -0,0 +1,33 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.0
hooks:
- id: ruff-check
- id: ruff-format
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: check-symlinks
- id: destroyed-symlinks
- id: check-yaml
- id: check-toml
- id: check-merge-conflict
- id: check-case-conflict
- id: check-executables-have-shebangs
- id: check-shebang-scripts-are-executable
- id: detect-private-key
- repo: https://github.com/codespell-project/codespell
rev: v2.2.6
hooks:
- id: codespell
additional_dependencies:
- tomli
- repo: https://github.com/Lucas-C/pre-commit-hooks
rev: v1.5.1
hooks:
- id: insert-license
files: \.py$
args:
# - --remove-header # Remove existing license headers. Useful when updating license.
- --license-filepath
- .github/LICENSE_HEADER.txt

View File

@@ -0,0 +1,38 @@
cff-version: 1.2.0
title: "RSL-RL: A Learning Library for Robotics Research"
message: "If you use this work, please cite the following paper."
repository-code: "https://github.com/leggedrobotics/rsl_rl"
license: BSD-3-Clause
version: 3.3.0
type: software
authors:
- family-names: Schwarke
given-names: Clemens
- family-names: Mittal
given-names: Mayank
- family-names: Rudin
given-names: Nikita
- family-names: Hoeller
given-names: David
keywords:
- reinforcement learning
- robotics
- control
- RSL-RL
preferred-citation:
type: article
authors:
- family-names: Schwarke
given-names: Clemens
- family-names: Mittal
given-names: Mayank
- family-names: Rudin
given-names: Nikita
- family-names: Hoeller
given-names: David
- family-names: Hutter
given-names: Marco
title: "RSL-RL: A Learning Library for Robotics Research"
journal: "arXiv preprint"
doi: 10.48550/arXiv.2509.10771
url: "https://arxiv.org/abs/2509.10771"

View File

@@ -0,0 +1,42 @@
# RSL-RL Maintainers and Contributors
This is the official list of developers and contributors.
To see the full list of contributors, see the revision history in the source control.
Names should be added to this file as: individual names or organizations.
Email addresses are tracked elsewhere to avoid spam.
Please keep the lists sorted alphabetically.
## Maintainers
* Robotic Systems Lab, ETH Zurich
* NVIDIA Corporation
---
* Clemens Schwarke
* Mayank Mittal
## Authors
* Clemens Schwarke
* David Hoeller
* Mayank Mittal
* Nikita Rudin
## Contributors
* Bikram Pandit
* Eric Vollenweider
* Fabian Jenelten
* Lorenzo Terenzi
* Marko Bjelonic
* Matthijs van der Boon
* Özhan Özen
* Pascal Roth
* Shaoshu Su
* Zhang Chong
* Ziqi Fan

30
source/rsl_rl/LICENSE Normal file
View File

@@ -0,0 +1,30 @@
Copyright (c) 2026, ETH Zurich
Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES
All rights reserved.
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.
See licenses/dependencies for license information of dependencies of this package.

109
source/rsl_rl/README.md Normal file
View File

@@ -0,0 +1,109 @@
# RSL-RL
A fast and simple implementation of learning algorithms for robotics. For an overview of the library please have a look at https://arxiv.org/pdf/2509.10771.
Environment repositories using the framework:
* **`Isaac Lab`** (built on top of NVIDIA Isaac Sim): https://github.com/isaac-sim/IsaacLab
* **`Legged Gym`** (built on top of NVIDIA Isaac Gym): https://leggedrobotics.github.io/legged_gym/
* **`MuJoCo Playground`** (built on top of MuJoCo MJX and Warp): https://github.com/google-deepmind/mujoco_playground/
* **`mjlab`** (built on top of MuJoCo Warp): https://github.com/mujocolab/mjlab
The library currently supports **PPO** and **Student-Teacher Distillation** with additional features from our research. These include:
* [Random Network Distillation (RND)](https://proceedings.mlr.press/v229/schwarke23a.html) - Encourages exploration by adding
a curiosity driven intrinsic reward.
* [Symmetry-based Augmentation](https://arxiv.org/abs/2403.04359) - Makes the learned behaviors more symmetrical.
We welcome contributions from the community. Please check our contribution guidelines for more
information.
**Maintainer**: Mayank Mittal and Clemens Schwarke <br/>
**Affiliation**: Robotic Systems Lab, ETH Zurich & NVIDIA <br/>
**Contact**: cschwarke@ethz.ch
## Setup
The package can be installed via PyPI with:
```bash
pip install rsl-rl-lib
```
or by cloning this repository and installing it with:
```bash
git clone https://github.com/leggedrobotics/rsl_rl
cd rsl_rl
pip install -e .
```
The package supports the following logging frameworks which can be configured through `logger`:
* Tensorboard: https://www.tensorflow.org/tensorboard/
* Weights & Biases: https://wandb.ai/site
* Neptune: https://docs.neptune.ai/
For a demo configuration of PPO, please check the [example_config.yaml](config/example_config.yaml) file.
## Contribution Guidelines
For documentation, we adopt the [Google Style Guide](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html) for docstrings. Please make sure that your code is well-documented and follows the guidelines.
We use the following tools for maintaining code quality:
- [pre-commit](https://pre-commit.com/): Runs a list of formatters and linters over the codebase.
- [ruff](https://github.com/astral-sh/ruff): An extremely fast Python linter and code formatter, written in Rust.
Please check [here](https://pre-commit.com/#install) for instructions to set these up. To run over the entire repository, please execute the following command in the terminal:
```bash
# for installation (only once)
pre-commit install
# for running
pre-commit run --all-files
```
## Citing
If you use this library for your research, please cite the following work:
```text
@article{schwarke2025rslrl,
title={RSL-RL: A Learning Library for Robotics Research},
author={Schwarke, Clemens and Mittal, Mayank and Rudin, Nikita and Hoeller, David and Hutter, Marco},
journal={arXiv preprint arXiv:2509.10771},
year={2025}
}
```
If you use the library with curiosity-driven exploration (random network distillation), please cite:
```text
@InProceedings{schwarke2023curiosity,
title = {Curiosity-Driven Learning of Joint Locomotion and Manipulation Tasks},
author = {Schwarke, Clemens and Klemm, Victor and Boon, Matthijs van der and Bjelonic, Marko and Hutter, Marco},
booktitle = {Proceedings of The 7th Conference on Robot Learning},
pages = {2594--2610},
year = {2023},
volume = {229},
series = {Proceedings of Machine Learning Research},
publisher = {PMLR},
url = {https://proceedings.mlr.press/v229/schwarke23a.html},
}
```
If you use the library with symmetry augmentation, please cite:
```text
@InProceedings{mittal2024symmetry,
author={Mittal, Mayank and Rudin, Nikita and Klemm, Victor and Allshire, Arthur and Hutter, Marco},
booktitle={2024 IEEE International Conference on Robotics and Automation (ICRA)},
title={Symmetry Considerations for Learning Task Symmetric Robot Policies},
year={2024},
pages={7433-7439},
doi={10.1109/ICRA57147.2024.10611493}
}
```

View File

@@ -0,0 +1,77 @@
runner:
class_name: OnPolicyRunner
# General
num_steps_per_env: 24 # Number of steps per environment per iteration
max_iterations: 1500 # Number of policy updates
seed: 1
# Observations
obs_groups: {"policy": ["policy"], "critic": ["policy", "privileged"]} # Maps observation groups to sets. See `vec_env.py` for more information
# Logging parameters
save_interval: 50 # Check for potential saves every `save_interval` iterations
experiment_name: walking_experiment
run_name: ""
# Logging writer
logger: tensorboard # tensorboard, neptune, wandb
neptune_project: legged_gym
wandb_project: legged_gym
# Policy
policy:
class_name: ActorCritic
activation: elu
actor_obs_normalization: false
critic_obs_normalization: false
actor_hidden_dims: [256, 256, 256]
critic_hidden_dims: [256, 256, 256]
init_noise_std: 1.0
noise_std_type: "scalar" # 'scalar' or 'log'
state_dependent_std: false
# Algorithm
algorithm:
class_name: PPO
# Training
learning_rate: 0.001
num_learning_epochs: 5
num_mini_batches: 4 # mini batch size = num_envs * num_steps / num_mini_batches
schedule: adaptive # adaptive, fixed
# Value function
value_loss_coef: 1.0
clip_param: 0.2
use_clipped_value_loss: true
# Surrogate loss
desired_kl: 0.01
entropy_coef: 0.01
gamma: 0.99
lam: 0.95
max_grad_norm: 1.0
# Miscellaneous
normalize_advantage_per_mini_batch: false
# Random network distillation
rnd_cfg:
weight: 0.0 # Initial weight of the RND reward
weight_schedule: null # This is a dictionary with a required key called "mode". Please check the RND module for more information
reward_normalization: false # Whether to normalize RND reward
# Learning parameters
learning_rate: 0.001 # Learning rate for RND
# Network parameters
num_outputs: 1 # Number of outputs of RND network. Note: if -1, then the network will use dimensions of the observation
predictor_hidden_dims: [-1] # Hidden dimensions of predictor network
target_hidden_dims: [-1] # Hidden dimensions of target network
# Symmetry augmentation
symmetry_cfg:
use_data_augmentation: true # This adds symmetric trajectories to the batch
use_mirror_loss: false # This adds symmetry loss term to the loss function
data_augmentation_func: null # String containing the module and function name to import
# Example: "legged_gym.envs.locomotion.anymal_c.symmetry:get_symmetric_states"
#
# .. code-block:: python
#
# @torch.no_grad()
# def get_symmetric_states(
# obs: Optional[torch.Tensor] = None, actions: Optional[torch.Tensor] = None, cfg: "BaseEnvCfg" = None, obs_type: str = "policy"
# ) -> Tuple[torch.Tensor, torch.Tensor]:
#
mirror_loss_coeff: 0.0 # Coefficient for symmetry loss term. If 0, no symmetry loss is used

View File

@@ -0,0 +1,339 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.

View File

@@ -0,0 +1,30 @@
Copyright (c) 2005-2021, NumPy Developers.
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* 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.
* Neither the name of the NumPy Developers nor the names of any
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
OWNER 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.

View File

@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,19 @@
Copyright (c) 2014 pre-commit dev team: Anthony Sottile, Ken Struys
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,19 @@
Copyright (c) 2014 pre-commit dev team: Anthony Sottile, Ken Struys
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,47 @@
MIT License
Copyright (c) 2021 Robert Craigie
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
===============================================================================
MIT License
Pyright - A static type checker for the Python language
Copyright (c) Microsoft Corporation. All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE

View File

@@ -0,0 +1,430 @@
MIT License
Copyright (c) 2022 Charles Marsh
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
end of terms and conditions
The externally maintained libraries from which parts of the Software is derived
are:
- autoflake, licensed as follows:
"""
Copyright (C) 2012-2018 Steven Myint
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""
- autotyping, licensed as follows:
"""
MIT License
Copyright (c) 2023 Jelle Zijlstra
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""
- Flake8, licensed as follows:
"""
== Flake8 License (MIT) ==
Copyright (C) 2011-2013 Tarek Ziade <tarek@ziade.org>
Copyright (C) 2012-2016 Ian Cordasco <graffatcolmingov@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""
- flake8-eradicate, licensed as follows:
"""
MIT License
Copyright (c) 2018 Nikita Sobolev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""
- flake8-pyi, licensed as follows:
"""
The MIT License (MIT)
Copyright (c) 2016 Łukasz Langa
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""
- flake8-simplify, licensed as follows:
"""
MIT License
Copyright (c) 2020 Martin Thoma
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""
- isort, licensed as follows:
"""
The MIT License (MIT)
Copyright (c) 2013 Timothy Edmund Crosley
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
"""
- pygrep-hooks, licensed as follows:
"""
Copyright (c) 2018 Anthony Sottile
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
"""
- pycodestyle, licensed as follows:
"""
Copyright © 2006-2009 Johann C. Rocholl <johann@rocholl.net>
Copyright © 2009-2014 Florent Xicluna <florent.xicluna@gmail.com>
Copyright © 2014-2020 Ian Lee <IanLee1521@gmail.com>
Licensed under the terms of the Expat License
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation files
(the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of the Software,
and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""
- pydocstyle, licensed as follows:
"""
Copyright (c) 2012 GreenSteam, <http://greensteam.dk/>
Copyright (c) 2014-2020 Amir Rachum, <http://amir.rachum.com/>
Copyright (c) 2020 Sambhav Kothari, <https://github.com/samj1912>
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""
- Pyflakes, licensed as follows:
"""
Copyright 2005-2011 Divmod, Inc.
Copyright 2013-2014 Florent Xicluna
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
"""
- Pyright, licensed as follows:
"""
MIT License
Pyright - A static type checker for the Python language
Copyright (c) Microsoft Corporation. All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
"""
- pyupgrade, licensed as follows:
"""
Copyright (c) 2017 Anthony Sottile
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
"""
- rome/tools, licensed under the MIT license:
"""
MIT License
Copyright (c) Rome Tools, Inc. and its affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""
- RustPython, licensed as follows:
"""
MIT License
Copyright (c) 2020 RustPython Team
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""
- rust-analyzer/text-size, licensed under the MIT license:
"""
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) Meta Platforms, Inc. and affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,73 @@
From PyTorch:
Copyright (c) 2016- Facebook, Inc (Adam Paszke)
Copyright (c) 2014- Facebook, Inc (Soumith Chintala)
Copyright (c) 2011-2014 Idiap Research Institute (Ronan Collobert)
Copyright (c) 2012-2014 Deepmind Technologies (Koray Kavukcuoglu)
Copyright (c) 2011-2012 NEC Laboratories America (Koray Kavukcuoglu)
Copyright (c) 2011-2013 NYU (Clement Farabet)
Copyright (c) 2006-2010 NEC Laboratories America (Ronan Collobert, Leon Bottou, Iain Melvin, Jason Weston)
Copyright (c) 2006 Idiap Research Institute (Samy Bengio)
Copyright (c) 2001-2004 Idiap Research Institute (Ronan Collobert, Samy Bengio, Johnny Mariethoz)
From Caffe2:
Copyright (c) 2016-present, Facebook Inc. All rights reserved.
All contributions by Facebook:
Copyright (c) 2016 Facebook Inc.
All contributions by Google:
Copyright (c) 2015 Google Inc.
All rights reserved.
All contributions by Yangqing Jia:
Copyright (c) 2015 Yangqing Jia
All rights reserved.
All contributions by Kakao Brain:
Copyright 2019-2020 Kakao Brain
All contributions from Caffe:
Copyright(c) 2013, 2014, 2015, the respective contributors
All rights reserved.
All other contributions:
Copyright(c) 2015, 2016 the respective contributors
All rights reserved.
Caffe2 uses a copyright model similar to Caffe: each contributor holds
copyright over their contributions to Caffe2. The project versioning records
all such contribution and copyright details. If a contributor wants to further
mark their specific copyright on a particular contribution, they should
indicate their copyright solely in the commit message of the change when it is
committed.
All rights reserved.
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 names of Facebook, Deepmind Technologies, NYU, NEC Laboratories America
and IDIAP Research Institute 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 OWNER 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.

View File

@@ -0,0 +1,60 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "rsl-rl-lib"
version = "3.3.0"
keywords = ["reinforcement-learning", "robotics"]
maintainers = [
{ name="Clemens Schwarke", email="cschwarke@ethz.ch" },
{ name="Mayank Mittal", email="mittalma@ethz.ch" },
]
authors = [
{ name="Clemens Schwarke", email="cschwarke@ethz.ch" },
{ name="Mayank Mittal", email="mittalma@ethz.ch" },
{ name="Nikita Rudin", email="rudinn@ethz.ch" },
{ name="David Hoeller", email="holler.david78@gmail.com" },
]
description = "Fast and simple RL algorithms implemented in PyTorch"
readme = { file = "README.md", content-type = "text/markdown" }
license = "BSD-3-Clause"
requires-python = ">=3.9"
classifiers = [
"Programming Language :: Python :: 3",
"Operating System :: OS Independent",
]
dependencies = [
"torch>=2.6.0",
"torchvision>=0.5.0",
"tensordict>=0.7.0",
"numpy>=1.16.4",
"GitPython",
"onnx",
"onnxscript>=0.5.4",
]
[project.urls]
Homepage = "https://github.com/leggedrobotics/rsl_rl"
Issues = "https://github.com/leggedrobotics/rsl_rl/issues"
[tool.setuptools.packages.find]
where = ["."]
include = ["rsl_rl*"]
[tool.setuptools.package-data]
"rsl_rl" = ["config/*", "licenses/*"]
[tool.pyright]
include = ["rsl_rl"]
typeCheckingMode = "basic"
pythonVersion = "3.9"
pythonPlatform = "Linux"
enableTypeIgnoreComments = true
# This is required as the CI pre-commit does not download the module (i.e. numpy, torch, prettytable)
reportMissingImports = "none"
# This is required to ignore type checks of modules with stubs missing.
reportMissingModuleSource = "none" # -> most common: prettytable in mdp managers
reportGeneralTypeIssues = "none" # -> usage of literal MISSING in dataclasses
reportOptionalMemberAccess = "none"
reportPrivateUsage = "warning"

View File

@@ -0,0 +1,6 @@
# Copyright (c) 2021-2026, ETH Zurich and NVIDIA CORPORATION
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
"""Main module for the rsl_rl package."""

View File

@@ -0,0 +1,12 @@
# Copyright (c) 2021-2026, ETH Zurich and NVIDIA CORPORATION
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
"""Implementation of different learning algorithms."""
from .distillation import Distillation
from .ppo import PPO
from .moe_cts import MoECTS
__all__ = ["PPO", "Distillation", "MoECTS"]

View File

@@ -0,0 +1,179 @@
# Copyright (c) 2021-2026, ETH Zurich and NVIDIA CORPORATION
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
import torch
import torch.nn as nn
from tensordict import TensorDict
from rsl_rl.modules import StudentTeacher, StudentTeacherRecurrent
from rsl_rl.storage import RolloutStorage
from rsl_rl.utils import resolve_optimizer
class Distillation:
"""Distillation algorithm for training a student model to mimic a teacher model."""
policy: StudentTeacher | StudentTeacherRecurrent
"""The student teacher model."""
def __init__(
self,
policy: StudentTeacher | StudentTeacherRecurrent,
storage: RolloutStorage,
num_learning_epochs: int = 1,
gradient_length: int = 15,
learning_rate: float = 1e-3,
max_grad_norm: float | None = None,
loss_type: str = "mse",
optimizer: str = "adam",
device: str = "cpu",
# Distributed training parameters
multi_gpu_cfg: dict | None = None,
) -> None:
# Device-related parameters
self.device = device
self.is_multi_gpu = multi_gpu_cfg is not None
# Multi-GPU parameters
if multi_gpu_cfg is not None:
self.gpu_global_rank = multi_gpu_cfg["global_rank"]
self.gpu_world_size = multi_gpu_cfg["world_size"]
else:
self.gpu_global_rank = 0
self.gpu_world_size = 1
# Distillation components
self.policy = policy
self.policy.to(self.device)
# Create the optimizer
self.optimizer = resolve_optimizer(optimizer)(self.policy.parameters(), lr=learning_rate)
# Add storage
self.storage = storage
self.transition = RolloutStorage.Transition()
self.last_hidden_states = (None, None)
# Distillation parameters
self.num_learning_epochs = num_learning_epochs
self.gradient_length = gradient_length
self.learning_rate = learning_rate
self.max_grad_norm = max_grad_norm
# Initialize the loss function
loss_fn_dict = {
"mse": nn.functional.mse_loss,
"huber": nn.functional.huber_loss,
}
if loss_type in loss_fn_dict:
self.loss_fn = loss_fn_dict[loss_type]
else:
raise ValueError(f"Unknown loss type: {loss_type}. Supported types are: {list(loss_fn_dict.keys())}")
self.num_updates = 0
def act(self, obs: TensorDict) -> torch.Tensor:
# Compute the actions
self.transition.actions = self.policy.act(obs).detach()
self.transition.privileged_actions = self.policy.evaluate(obs).detach()
# Record the observations
self.transition.observations = obs
return self.transition.actions
def process_env_step(
self, obs: TensorDict, rewards: torch.Tensor, dones: torch.Tensor, extras: dict[str, torch.Tensor]
) -> None:
# Update the normalizers
self.policy.update_normalization(obs)
# Record the rewards and dones
self.transition.rewards = rewards
self.transition.dones = dones
# Record the transition
self.storage.add_transition(self.transition)
self.transition.clear()
self.policy.reset(dones)
def compute_returns(self, obs: TensorDict) -> None:
# Not needed for distillation
pass
def update(self) -> dict[str, float]:
self.num_updates += 1
mean_behavior_loss = 0
loss = 0
cnt = 0
for epoch in range(self.num_learning_epochs):
self.policy.reset(hidden_states=self.last_hidden_states)
self.policy.detach_hidden_states()
for obs, _, privileged_actions, dones in self.storage.generator():
# Inference of the student for gradient computation
actions = self.policy.act_inference(obs)
# Behavior cloning loss
behavior_loss = self.loss_fn(actions, privileged_actions)
# Total loss
loss = loss + behavior_loss
mean_behavior_loss += behavior_loss.item()
cnt += 1
# Gradient step
if cnt % self.gradient_length == 0:
self.optimizer.zero_grad()
loss.backward()
if self.is_multi_gpu:
self.reduce_parameters()
if self.max_grad_norm:
nn.utils.clip_grad_norm_(self.policy.student.parameters(), self.max_grad_norm)
self.optimizer.step()
self.policy.detach_hidden_states()
loss = 0
# Reset dones
self.policy.reset(dones.view(-1))
self.policy.detach_hidden_states(dones.view(-1))
mean_behavior_loss /= cnt
self.storage.clear()
self.last_hidden_states = self.policy.get_hidden_states()
self.policy.detach_hidden_states()
# Construct the loss dictionary
loss_dict = {"behavior": mean_behavior_loss}
return loss_dict
def broadcast_parameters(self) -> None:
"""Broadcast model parameters to all GPUs."""
# Obtain the model parameters on current GPU
model_params = [self.policy.state_dict()]
# Broadcast the model parameters
torch.distributed.broadcast_object_list(model_params, src=0)
# Load the model parameters on all GPUs from source GPU
self.policy.load_state_dict(model_params[0])
def reduce_parameters(self) -> None:
"""Collect gradients from all GPUs and average them.
This function is called after the backward pass to synchronize the gradients across all GPUs.
"""
# Create a tensor to store the gradients
grads = [param.grad.view(-1) for param in self.policy.parameters() if param.grad is not None]
all_grads = torch.cat(grads)
# Average the gradients across all GPUs
torch.distributed.all_reduce(all_grads, op=torch.distributed.ReduceOp.SUM)
all_grads /= self.gpu_world_size
# Update the gradients for all parameters with the reduced gradients
offset = 0
for param in self.policy.parameters():
if param.grad is not None:
numel = param.numel()
# Copy data back from shared buffer
param.grad.data.copy_(all_grads[offset : offset + numel].view_as(param.grad.data))
# Update the offset for the next parameter
offset += numel

View File

@@ -0,0 +1,484 @@
# Copyright (c) 2021-2026, ETH Zurich and NVIDIA CORPORATION
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
import torch
import torch.nn as nn
import torch.optim as optim
from itertools import chain
from tensordict import TensorDict
import itertools
from rsl_rl.modules import ActorCriticMoECTS
from rsl_rl.modules.rnd import RandomNetworkDistillation
from rsl_rl.storage import RolloutStorageCTS
class MoECTS:
"""Concurrent Teacher-Student algorithm (https://arxiv.org/abs/2405.10830) with MoE."""
policy: ActorCriticMoECTS
"""The actor critic module."""
def __init__(
self,
policy: ActorCriticMoECTS,
storage: RolloutStorageCTS,
num_envs: int,
num_learning_epochs: int = 5,
num_mini_batches: int = 4,
clip_param: float = 0.2,
gamma: float = 0.99,
lam: float = 0.95,
betas: tuple = (0.9, 0.999),
weight_decay: float = 0.0,
value_loss_coef: float = 1.0,
entropy_coef: float = 0.01,
load_balance_coef: float = 0.01,
learning_rate: float = 0.001,
student_encoder_learning_rate: float = 0.001,
max_grad_norm: float = 1.0,
use_clipped_value_loss: bool = True,
schedule: str = "adaptive",
desired_kl: float = 0.01,
teacher_env_ratio: float = 0.75,
normalize_advantage_per_mini_batch: bool = False,
device: str = "cpu",
# RND parameters
rnd_cfg: dict | None = None,
# Symmetry parameters
symmetry_cfg: dict | None = None,
# Distributed training parameters
multi_gpu_cfg: dict | None = None,
) -> None:
assert isinstance(policy, ActorCriticMoECTS), "Policy must be an instance of ActorCriticMoECTS."
assert not policy.is_recurrent, "Recurrent policies are not supported yet for MoECTS."
# Device-related parameters
self.device = device
self.is_multi_gpu = multi_gpu_cfg is not None
# Multi-GPU parameters
if multi_gpu_cfg is not None:
self.gpu_global_rank = multi_gpu_cfg["global_rank"]
self.gpu_world_size = multi_gpu_cfg["world_size"]
else:
self.gpu_global_rank = 0
self.gpu_world_size = 1
# RND components
if rnd_cfg:
# Extract parameters used in ppo
rnd_lr = rnd_cfg.pop("learning_rate", 1e-3)
# Create RND module
self.rnd = RandomNetworkDistillation(device=self.device, **rnd_cfg)
# Create RND optimizer
params = self.rnd.predictor.parameters()
self.rnd_optimizer = optim.Adam(params, lr=rnd_lr)
else:
self.rnd = None
self.rnd_optimizer = None
# Symmetry components
if symmetry_cfg is not None:
print("[WARNING] `symmetry_cfg` detected, but MoECTS does not currently support symmetry; the configuration will be ignored.")
self.symmetry = None
# PPO components
self.policy = policy
self.policy.to(self.device)
# Create the optimizer
params1 = [
{"params": self.policy.teacher_encoder.parameters()},
{"params": self.policy.critic.parameters()},
{"params": self.policy.actor.parameters()},
{"params": getattr(self.policy, 'std', getattr(self.policy, 'log_std', []))}
]
self.optimizer = optim.Adam(params1, lr=learning_rate, betas=betas, weight_decay=weight_decay)
self.optimizer_stu_enc = optim.Adam(self.policy.student_moe_encoder.parameters(), lr=student_encoder_learning_rate, betas=betas, weight_decay=weight_decay)
# Add storage
self.storage = storage
self.transition = RolloutStorageCTS.Transition()
# MoECTS & 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.load_balance_coef = load_balance_coef
self.gamma = gamma
self.lam = lam
self.max_grad_norm = max_grad_norm
self.use_clipped_value_loss = use_clipped_value_loss
self.desired_kl = desired_kl
self.schedule = schedule
self.learning_rate = learning_rate
self.normalize_advantage_per_mini_batch = normalize_advantage_per_mini_batch
# Teacher-student environment split
self.teacher_num_envs = max(int(num_envs * teacher_env_ratio), 1)
self.student_num_envs = num_envs - self.teacher_num_envs
student_env_ratio = 1 - teacher_env_ratio
self.teacher_env_idxs = torch.tensor([i for i in range(num_envs) if i % int(1/student_env_ratio) != 0], device=self.device)
self.student_env_idxs = torch.tensor([i for i in range(num_envs) if i % int(1/student_env_ratio) == 0], device=self.device)
assert len(self.teacher_env_idxs) == self.teacher_num_envs, f"{len(self.teacher_env_idxs)=} != {self.teacher_num_envs=}"
assert len(self.student_env_idxs) == self.student_num_envs, f"{len(self.student_env_idxs)=} != {self.student_num_envs=}"
def act(self, obs: TensorDict) -> torch.Tensor:
# Compute the actions and values
def _get_results(obs, is_teacher):
actions = self.policy.act(obs, is_teacher)
return (
actions.detach(),
self.policy.evaluate(obs, is_teacher).detach(),
self.policy.get_actions_log_prob(actions).detach(),
self.policy.action_mean.detach(),
self.policy.action_std.detach(),
)
ti, si = self.teacher_env_idxs, self.student_env_idxs
teacher_results = _get_results(obs[ti], is_teacher=True)
student_results = _get_results(obs[si], is_teacher=False)
results = []
for x1, x2 in zip(teacher_results, student_results):
results.append(torch.cat([x1, x2], dim=0))
self.transition.actions = results[0]
self.transition.values = results[1]
self.transition.actions_log_prob = results[2]
self.transition.action_mean = results[3]
self.transition.action_sigma = results[4]
# Record observations before env.step()
self.transition.observations = torch.cat([obs[ti], obs[si]], dim=0)
# Reconstruct the actions in the original order
reordered_actions = torch.zeros_like(self.transition.actions)
reordered_actions[ti] = self.transition.actions[:self.teacher_num_envs]
reordered_actions[si] = self.transition.actions[self.teacher_num_envs:]
return reordered_actions
def process_env_step(
self, obs: TensorDict, rewards: torch.Tensor, dones: torch.Tensor, extras: dict[str, torch.Tensor]
) -> None:
# Update the normalizers
self.policy.update_normalization(obs)
if self.rnd:
self.rnd.update_normalization(obs)
# Record the rewards and dones
# Note: We clone here because later on we bootstrap the rewards based on timeouts
ti, si = self.teacher_env_idxs, self.student_env_idxs
rewards = rewards.clone()
self.transition.rewards = torch.cat([rewards[ti], rewards[si]], dim=0)
self.transition.dones = torch.cat([dones[ti], dones[si]], dim=0)
# Compute the intrinsic rewards and add to extrinsic rewards
if self.rnd:
# Compute the intrinsic rewards
reordered_obs = torch.cat([obs[ti], obs[si]], dim=0)
self.intrinsic_rewards = self.rnd.get_intrinsic_reward(reordered_obs)
# Add intrinsic rewards to extrinsic rewards
self.transition.rewards += self.intrinsic_rewards
# Bootstrapping on time outs
if "time_outs" in extras:
time_outs = extras["time_outs"].to(self.device)
reordered_time_outs = torch.cat([time_outs[ti], time_outs[si]], dim=0)
self.transition.rewards += self.gamma * torch.squeeze(
self.transition.values * reordered_time_outs.unsqueeze(1).to(self.device), 1
)
# Record the transition
self.storage.add_transition(self.transition)
self.transition.clear()
self.policy.reset(dones)
def compute_returns(self, obs: TensorDict) -> None:
st = self.storage
# Compute value for the last step
ti, si = self.teacher_env_idxs, self.student_env_idxs
last_values = torch.cat([
self.policy.evaluate(obs[ti], is_teacher=True).detach(),
self.policy.evaluate(obs[si], is_teacher=False).detach(),
], dim=0)
# Compute returns and advantages
advantage = 0
for step in reversed(range(st.num_transitions_per_env)):
# If we are at the last step, bootstrap the return value
next_values = last_values if step == st.num_transitions_per_env - 1 else st.values[step + 1]
# 1 if we are not in a terminal state, 0 otherwise
next_is_not_terminal = 1.0 - st.dones[step].float()
# TD error: r_t + gamma * V(s_{t+1}) - V(s_t)
delta = st.rewards[step] + next_is_not_terminal * self.gamma * next_values - st.values[step]
# Advantage: A(s_t, a_t) = delta_t + gamma * lambda * A(s_{t+1}, a_{t+1})
advantage = delta + next_is_not_terminal * self.gamma * self.lam * advantage
# Return: R_t = A(s_t, a_t) + V(s_t)
st.returns[step] = advantage + st.values[step]
# Compute the advantages
st.advantages = st.returns - st.values
# Normalize the advantages if per minibatch normalization is not used
if not self.normalize_advantage_per_mini_batch:
st.advantages = (st.advantages - st.advantages.mean()) / (st.advantages.std() + 1e-8)
def update(self) -> dict[str, float]:
mean_value_loss = 0
mean_surrogate_loss = 0
mean_entropy = 0
mean_latent_loss = 0
mean_load_balance_loss = 0
# RND loss
mean_rnd_loss = 0 if self.rnd else None
# Get mini batch generator
generator = self.storage.mini_batch_generator(self.num_mini_batches, self.num_learning_epochs)
data = list(generator)
# Iterate over batches
teacher_samples = self.teacher_num_envs * self.storage.num_transitions_per_env // self.num_mini_batches
student_samples = self.student_num_envs * self.storage.num_transitions_per_env // self.num_mini_batches
for (
obs_batch,
actions_batch,
target_values_batch,
advantages_batch,
returns_batch,
old_actions_log_prob_batch,
old_mu_batch,
old_sigma_batch,
hidden_states_batch,
masks_batch,
) in data:
original_batch_size = obs_batch.batch_size[0]
# Check if we should normalize advantages per mini batch
if self.normalize_advantage_per_mini_batch:
with torch.no_grad():
advantages_batch = (advantages_batch - advantages_batch.mean()) / (advantages_batch.std() + 1e-8)
def _get_results(start, end, is_teacher):
self.policy.act(obs_batch[start:end], is_teacher)
actions_log_prob = self.policy.get_actions_log_prob(actions_batch[start:end])
value = self.policy.evaluate(obs_batch[start:end], is_teacher)
mu = self.policy.action_mean
sigma = self.policy.action_std
entropy = self.policy.entropy
return actions_log_prob, value, mu, sigma, entropy
teacher_results = _get_results(0, teacher_samples, is_teacher=True)
student_results = _get_results(teacher_samples, teacher_samples + student_samples, is_teacher=False)
results = []
for x1, x2 in zip(teacher_results, student_results):
results.append(torch.cat([x1, x2], dim=0))
actions_log_prob_batch, value_batch, mu_batch, sigma_batch, entropy_batch = results
# Compute KL divergence and adapt the learning rate
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.0e-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)
# Reduce the KL divergence across all GPUs
if self.is_multi_gpu:
torch.distributed.all_reduce(kl_mean, op=torch.distributed.ReduceOp.SUM)
kl_mean /= self.gpu_world_size
# Update the learning rate only on the main process
# TODO: Is this needed? If KL-divergence is the "same" across all GPUs,
# then the learning rate should be the same across all GPUs.
if self.gpu_global_rank == 0:
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)
# Update the learning rate for all GPUs
if self.is_multi_gpu:
lr_tensor = torch.tensor(self.learning_rate, device=self.device)
torch.distributed.broadcast(lr_tensor, src=0)
self.learning_rate = lr_tensor.item()
# Update the learning rate for all parameter groups
for param_group in self.optimizer.param_groups:
param_group["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_losses = torch.max(surrogate, surrogate_clipped)
teacher_surrogate_loss = surrogate_losses[:teacher_samples].mean()
student_surrogate_loss = surrogate_losses[teacher_samples:].mean()
surrogate_loss = teacher_surrogate_loss + student_surrogate_loss
# 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()
# RND loss
# TODO: Move this processing to inside RND module.
if self.rnd:
# Extract the rnd_state
# TODO: Check if we still need torch no grad. It is just an affine transformation.
with torch.no_grad():
rnd_state_batch = self.rnd.get_rnd_state(obs_batch[:original_batch_size])
rnd_state_batch = self.rnd.state_normalizer(rnd_state_batch)
# Predict the embedding and the target
predicted_embedding = self.rnd.predictor(rnd_state_batch)
target_embedding = self.rnd.target(rnd_state_batch).detach()
# Compute the loss as the mean squared error
mseloss = torch.nn.MSELoss()
rnd_loss = mseloss(predicted_embedding, target_embedding)
# Compute the gradients for PPO
self.optimizer.zero_grad()
loss.backward()
# Compute the gradients for RND
if self.rnd:
self.rnd_optimizer.zero_grad()
rnd_loss.backward()
# Collect gradients from all GPUs
if self.is_multi_gpu:
self.reduce_parameters()
# Apply the gradients for PPO
params_to_clip = itertools.chain.from_iterable(g['params'] for g in self.optimizer.param_groups)
nn.utils.clip_grad_norm_(params_to_clip, self.max_grad_norm)
self.optimizer.step()
# Apply the gradients for RND
if self.rnd_optimizer:
self.rnd_optimizer.step()
# Store the losses
mean_value_loss += value_loss.item()
mean_surrogate_loss += surrogate_loss.item()
mean_entropy += entropy_batch.mean().item()
# RND loss
if mean_rnd_loss is not None:
mean_rnd_loss += rnd_loss.item()
for (
obs_batch,
actions_batch,
target_values_batch,
advantages_batch,
returns_batch,
old_actions_log_prob_batch,
old_mu_batch,
old_sigma_batch,
hidden_states_batch,
masks_batch,
) in data:
# Student encoder loss
obs_a_batch = self.policy.get_actor_obs(obs_batch)
obs_a_batch = self.policy.actor_obs_normalizer(obs_a_batch)
student_latent, gating_weights = self.policy.student_moe_encoder(obs_a_batch[teacher_samples:])
with torch.no_grad():
obs_c_batch = self.policy.get_critic_obs(obs_batch)
obs_c_batch = self.policy.critic_obs_normalizer(obs_c_batch)
teacher_latent = self.policy.teacher_encoder(obs_c_batch[teacher_samples:])
latent_loss = (teacher_latent - student_latent).pow(2).mean()
# Load balance loss
mean_usage = torch.mean(gating_weights, dim=0)
target_usage = torch.full_like(mean_usage, 1.0 / gating_weights.shape[1])
load_balance_loss = torch.mean((mean_usage - target_usage).pow(2))
# load_balance_loss = torch.sum(mean_usage.pow(2)) * gating_weights.shape[1] # Switch Transformer style
student_loss = latent_loss + self.load_balance_coef * load_balance_loss
self.optimizer_stu_enc.zero_grad()
student_loss.backward()
nn.utils.clip_grad_norm_(self.policy.student_moe_encoder.parameters(), self.max_grad_norm)
self.optimizer_stu_enc.step()
mean_latent_loss += latent_loss.item()
mean_load_balance_loss += load_balance_loss.item()
# Divide the losses by the number of updates
num_updates = self.num_learning_epochs * self.num_mini_batches
mean_value_loss /= num_updates
mean_surrogate_loss /= num_updates
mean_entropy /= num_updates
mean_latent_loss /= num_updates
mean_load_balance_loss /= num_updates
if mean_rnd_loss is not None:
mean_rnd_loss /= num_updates
# Clear the storage
self.storage.clear()
# Construct the loss dictionary
loss_dict = {
"value": mean_value_loss,
"surrogate": mean_surrogate_loss,
"entropy": mean_entropy,
"mean_latent_loss": mean_latent_loss,
"mean_load_balance_loss": mean_load_balance_loss
}
if self.rnd:
loss_dict["rnd"] = mean_rnd_loss
return loss_dict
def broadcast_parameters(self) -> None:
"""Broadcast model parameters to all GPUs."""
# Obtain the model parameters on current GPU
model_params = [self.policy.state_dict()]
if self.rnd:
model_params.append(self.rnd.predictor.state_dict())
# Broadcast the model parameters
torch.distributed.broadcast_object_list(model_params, src=0)
# Load the model parameters on all GPUs from source GPU
self.policy.load_state_dict(model_params[0])
if self.rnd:
self.rnd.predictor.load_state_dict(model_params[1])
def reduce_parameters(self) -> None:
"""Collect gradients from all GPUs and average them.
This function is called after the backward pass to synchronize the gradients across all GPUs.
"""
# Create a tensor to store the gradients
grads = [param.grad.view(-1) for param in self.policy.parameters() if param.grad is not None]
if self.rnd:
grads += [param.grad.view(-1) for param in self.rnd.parameters() if param.grad is not None]
all_grads = torch.cat(grads)
# Average the gradients across all GPUs
torch.distributed.all_reduce(all_grads, op=torch.distributed.ReduceOp.SUM)
all_grads /= self.gpu_world_size
# Get all parameters
all_params = self.policy.parameters()
if self.rnd:
all_params = chain(all_params, self.rnd.parameters())
# Update the gradients for all parameters with the reduced gradients
offset = 0
for param in all_params:
if param.grad is not None:
numel = param.numel()
# Copy data back from shared buffer
param.grad.data.copy_(all_grads[offset : offset + numel].view_as(param.grad.data))
# Update the offset for the next parameter
offset += numel

View File

@@ -0,0 +1,458 @@
# Copyright (c) 2021-2026, ETH Zurich and NVIDIA CORPORATION
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
import torch
import torch.nn as nn
import torch.optim as optim
from itertools import chain
from tensordict import TensorDict
from rsl_rl.modules import ActorCritic, ActorCriticCNN, ActorCriticRecurrent
from rsl_rl.modules.rnd import RandomNetworkDistillation
from rsl_rl.storage import RolloutStorage
from rsl_rl.utils import resolve_callable
class PPO:
"""Proximal Policy Optimization algorithm (https://arxiv.org/abs/1707.06347)."""
policy: ActorCritic | ActorCriticRecurrent | ActorCriticCNN
"""The actor critic module."""
def __init__(
self,
policy: ActorCritic | ActorCriticRecurrent | ActorCriticCNN,
storage: RolloutStorage,
num_learning_epochs: int = 5,
num_mini_batches: int = 4,
clip_param: float = 0.2,
gamma: float = 0.99,
lam: float = 0.95,
value_loss_coef: float = 1.0,
entropy_coef: float = 0.01,
learning_rate: float = 0.001,
max_grad_norm: float = 1.0,
use_clipped_value_loss: bool = True,
schedule: str = "adaptive",
desired_kl: float = 0.01,
normalize_advantage_per_mini_batch: bool = False,
device: str = "cpu",
# RND parameters
rnd_cfg: dict | None = None,
# Symmetry parameters
symmetry_cfg: dict | None = None,
# Distributed training parameters
multi_gpu_cfg: dict | None = None,
) -> None:
# Device-related parameters
self.device = device
self.is_multi_gpu = multi_gpu_cfg is not None
# Multi-GPU parameters
if multi_gpu_cfg is not None:
self.gpu_global_rank = multi_gpu_cfg["global_rank"]
self.gpu_world_size = multi_gpu_cfg["world_size"]
else:
self.gpu_global_rank = 0
self.gpu_world_size = 1
# RND components
if rnd_cfg:
# Extract parameters used in ppo
rnd_lr = rnd_cfg.pop("learning_rate", 1e-3)
# Create RND module
self.rnd = RandomNetworkDistillation(device=self.device, **rnd_cfg)
# Create RND optimizer
params = self.rnd.predictor.parameters()
self.rnd_optimizer = optim.Adam(params, lr=rnd_lr)
else:
self.rnd = None
self.rnd_optimizer = None
# Symmetry components
if symmetry_cfg is not None:
# Check if symmetry is enabled
use_symmetry = symmetry_cfg["use_data_augmentation"] or symmetry_cfg["use_mirror_loss"]
# Print that we are not using symmetry
if not use_symmetry:
print("Symmetry not used for learning. We will use it for logging instead.")
# Resolve the data augmentation function (supports string names or direct callables)
symmetry_cfg["data_augmentation_func"] = resolve_callable(symmetry_cfg["data_augmentation_func"])
# Check valid configuration
if not callable(symmetry_cfg["data_augmentation_func"]):
raise ValueError(
f"Symmetry configuration exists but the function is not callable: "
f"{symmetry_cfg['data_augmentation_func']}"
)
# Check if the policy is compatible with symmetry
if isinstance(policy, ActorCriticRecurrent):
raise ValueError("Symmetry augmentation is not supported for recurrent policies.")
# Store symmetry configuration
self.symmetry = symmetry_cfg
else:
self.symmetry = None
# PPO components
self.policy = policy
self.policy.to(self.device)
# Create the optimizer
self.optimizer = optim.Adam(self.policy.parameters(), lr=learning_rate)
# Add storage
self.storage = storage
self.transition = RolloutStorage.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
self.desired_kl = desired_kl
self.schedule = schedule
self.learning_rate = learning_rate
self.normalize_advantage_per_mini_batch = normalize_advantage_per_mini_batch
def act(self, obs: TensorDict) -> torch.Tensor:
if self.policy.is_recurrent:
self.transition.hidden_states = self.policy.get_hidden_states()
# Compute the actions and values
self.transition.actions = self.policy.act(obs).detach()
self.transition.values = self.policy.evaluate(obs).detach()
self.transition.actions_log_prob = self.policy.get_actions_log_prob(self.transition.actions).detach()
self.transition.action_mean = self.policy.action_mean.detach()
self.transition.action_sigma = self.policy.action_std.detach()
# Record observations before env.step()
self.transition.observations = obs
return self.transition.actions
def process_env_step(
self, obs: TensorDict, rewards: torch.Tensor, dones: torch.Tensor, extras: dict[str, torch.Tensor]
) -> None:
# Update the normalizers
self.policy.update_normalization(obs)
if self.rnd:
self.rnd.update_normalization(obs)
# Record the rewards and dones
# Note: We clone here because later on we bootstrap the rewards based on timeouts
self.transition.rewards = rewards.clone()
self.transition.dones = dones
# Compute the intrinsic rewards and add to extrinsic rewards
if self.rnd:
# Compute the intrinsic rewards
self.intrinsic_rewards = self.rnd.get_intrinsic_reward(obs)
# Add intrinsic rewards to extrinsic rewards
self.transition.rewards += self.intrinsic_rewards
# Bootstrapping on time outs
if "time_outs" in extras:
self.transition.rewards += self.gamma * torch.squeeze(
self.transition.values * extras["time_outs"].unsqueeze(1).to(self.device), 1
)
# Record the transition
self.storage.add_transition(self.transition)
self.transition.clear()
self.policy.reset(dones)
def compute_returns(self, obs: TensorDict) -> None:
st = self.storage
# Compute value for the last step
last_values = self.policy.evaluate(obs).detach()
# Compute returns and advantages
advantage = 0
for step in reversed(range(st.num_transitions_per_env)):
# If we are at the last step, bootstrap the return value
next_values = last_values if step == st.num_transitions_per_env - 1 else st.values[step + 1]
# 1 if we are not in a terminal state, 0 otherwise
next_is_not_terminal = 1.0 - st.dones[step].float()
# TD error: r_t + gamma * V(s_{t+1}) - V(s_t)
delta = st.rewards[step] + next_is_not_terminal * self.gamma * next_values - st.values[step]
# Advantage: A(s_t, a_t) = delta_t + gamma * lambda * A(s_{t+1}, a_{t+1})
advantage = delta + next_is_not_terminal * self.gamma * self.lam * advantage
# Return: R_t = A(s_t, a_t) + V(s_t)
st.returns[step] = advantage + st.values[step]
# Compute the advantages
st.advantages = st.returns - st.values
# Normalize the advantages if per minibatch normalization is not used
if not self.normalize_advantage_per_mini_batch:
st.advantages = (st.advantages - st.advantages.mean()) / (st.advantages.std() + 1e-8)
def update(self) -> dict[str, float]:
mean_value_loss = 0
mean_surrogate_loss = 0
mean_entropy = 0
# RND loss
mean_rnd_loss = 0 if self.rnd else None
# Symmetry loss
mean_symmetry_loss = 0 if self.symmetry else None
# Get mini batch generator
if self.policy.is_recurrent:
generator = self.storage.recurrent_mini_batch_generator(self.num_mini_batches, self.num_learning_epochs)
else:
generator = self.storage.mini_batch_generator(self.num_mini_batches, self.num_learning_epochs)
# Iterate over batches
for (
obs_batch,
actions_batch,
target_values_batch,
advantages_batch,
returns_batch,
old_actions_log_prob_batch,
old_mu_batch,
old_sigma_batch,
hidden_states_batch,
masks_batch,
) in generator:
num_aug = 1 # Number of augmentations per sample. Starts at 1 for no augmentation.
original_batch_size = obs_batch.batch_size[0]
# Check if we should normalize advantages per mini batch
if self.normalize_advantage_per_mini_batch:
with torch.no_grad():
advantages_batch = (advantages_batch - advantages_batch.mean()) / (advantages_batch.std() + 1e-8)
# Perform symmetric augmentation
if self.symmetry and self.symmetry["use_data_augmentation"]:
# Augmentation using symmetry
data_augmentation_func = self.symmetry["data_augmentation_func"]
# Returned shape: [batch_size * num_aug, ...]
obs_batch, actions_batch = data_augmentation_func(
obs=obs_batch,
actions=actions_batch,
env=self.symmetry["_env"],
)
# Compute number of augmentations per sample
num_aug = int(obs_batch.batch_size[0] / original_batch_size)
# Repeat the rest of the batch
old_actions_log_prob_batch = old_actions_log_prob_batch.repeat(num_aug, 1)
target_values_batch = target_values_batch.repeat(num_aug, 1)
advantages_batch = advantages_batch.repeat(num_aug, 1)
returns_batch = returns_batch.repeat(num_aug, 1)
# Recompute actions log prob and entropy for current batch of transitions
# Note: We need to do this because we updated the policy with the new parameters
self.policy.act(obs_batch, masks=masks_batch, hidden_state=hidden_states_batch[0])
actions_log_prob_batch = self.policy.get_actions_log_prob(actions_batch)
value_batch = self.policy.evaluate(obs_batch, masks=masks_batch, hidden_state=hidden_states_batch[1])
# Note: We only keep the entropy of the first augmentation (the original one)
mu_batch = self.policy.action_mean[:original_batch_size]
sigma_batch = self.policy.action_std[:original_batch_size]
entropy_batch = self.policy.entropy[:original_batch_size]
# Compute KL divergence and adapt the learning rate
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.0e-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)
# Reduce the KL divergence across all GPUs
if self.is_multi_gpu:
torch.distributed.all_reduce(kl_mean, op=torch.distributed.ReduceOp.SUM)
kl_mean /= self.gpu_world_size
# Update the learning rate only on the main process
# TODO: Is this needed? If KL-divergence is the "same" across all GPUs,
# then the learning rate should be the same across all GPUs.
if self.gpu_global_rank == 0:
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)
# Update the learning rate for all GPUs
if self.is_multi_gpu:
lr_tensor = torch.tensor(self.learning_rate, device=self.device)
torch.distributed.broadcast(lr_tensor, src=0)
self.learning_rate = lr_tensor.item()
# Update the learning rate for all parameter groups
for param_group in self.optimizer.param_groups:
param_group["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()
# Symmetry loss
if self.symmetry:
# Obtain the symmetric actions
# Note: If we did augmentation before then we don't need to augment again
if not self.symmetry["use_data_augmentation"]:
data_augmentation_func = self.symmetry["data_augmentation_func"]
obs_batch, _ = data_augmentation_func(obs=obs_batch, actions=None, env=self.symmetry["_env"])
# Compute number of augmentations per sample
num_aug = int(obs_batch.shape[0] / original_batch_size)
# Actions predicted by the actor for symmetrically-augmented observations
mean_actions_batch = self.policy.act_inference(obs_batch.detach().clone())
# Compute the symmetrically augmented actions
# Note: We are assuming the first augmentation is the original one. We do not use the action_batch from
# earlier since that action was sampled from the distribution. However, the symmetry loss is computed
# using the mean of the distribution.
action_mean_orig = mean_actions_batch[:original_batch_size]
_, actions_mean_symm_batch = data_augmentation_func(
obs=None, actions=action_mean_orig, env=self.symmetry["_env"]
)
# Compute the loss
mse_loss = torch.nn.MSELoss()
symmetry_loss = mse_loss(
mean_actions_batch[original_batch_size:], actions_mean_symm_batch.detach()[original_batch_size:]
)
# Add the loss to the total loss
if self.symmetry["use_mirror_loss"]:
loss += self.symmetry["mirror_loss_coeff"] * symmetry_loss
else:
symmetry_loss = symmetry_loss.detach()
# RND loss
# TODO: Move this processing to inside RND module.
if self.rnd:
# Extract the rnd_state
# TODO: Check if we still need torch no grad. It is just an affine transformation.
with torch.no_grad():
rnd_state_batch = self.rnd.get_rnd_state(obs_batch[:original_batch_size])
rnd_state_batch = self.rnd.state_normalizer(rnd_state_batch)
# Predict the embedding and the target
predicted_embedding = self.rnd.predictor(rnd_state_batch)
target_embedding = self.rnd.target(rnd_state_batch).detach()
# Compute the loss as the mean squared error
mseloss = torch.nn.MSELoss()
rnd_loss = mseloss(predicted_embedding, target_embedding)
# Compute the gradients for PPO
self.optimizer.zero_grad()
loss.backward()
# Compute the gradients for RND
if self.rnd:
self.rnd_optimizer.zero_grad()
rnd_loss.backward()
# Collect gradients from all GPUs
if self.is_multi_gpu:
self.reduce_parameters()
# Apply the gradients for PPO
nn.utils.clip_grad_norm_(self.policy.parameters(), self.max_grad_norm)
self.optimizer.step()
# Apply the gradients for RND
if self.rnd_optimizer:
self.rnd_optimizer.step()
# Store the losses
mean_value_loss += value_loss.item()
mean_surrogate_loss += surrogate_loss.item()
mean_entropy += entropy_batch.mean().item()
# RND loss
if mean_rnd_loss is not None:
mean_rnd_loss += rnd_loss.item()
# Symmetry loss
if mean_symmetry_loss is not None:
mean_symmetry_loss += symmetry_loss.item()
# Divide the losses by the number of updates
num_updates = self.num_learning_epochs * self.num_mini_batches
mean_value_loss /= num_updates
mean_surrogate_loss /= num_updates
mean_entropy /= num_updates
if mean_rnd_loss is not None:
mean_rnd_loss /= num_updates
if mean_symmetry_loss is not None:
mean_symmetry_loss /= num_updates
# Clear the storage
self.storage.clear()
# Construct the loss dictionary
loss_dict = {
"value": mean_value_loss,
"surrogate": mean_surrogate_loss,
"entropy": mean_entropy,
}
if self.rnd:
loss_dict["rnd"] = mean_rnd_loss
if self.symmetry:
loss_dict["symmetry"] = mean_symmetry_loss
return loss_dict
def broadcast_parameters(self) -> None:
"""Broadcast model parameters to all GPUs."""
# Obtain the model parameters on current GPU
model_params = [self.policy.state_dict()]
if self.rnd:
model_params.append(self.rnd.predictor.state_dict())
# Broadcast the model parameters
torch.distributed.broadcast_object_list(model_params, src=0)
# Load the model parameters on all GPUs from source GPU
self.policy.load_state_dict(model_params[0])
if self.rnd:
self.rnd.predictor.load_state_dict(model_params[1])
def reduce_parameters(self) -> None:
"""Collect gradients from all GPUs and average them.
This function is called after the backward pass to synchronize the gradients across all GPUs.
"""
# Create a tensor to store the gradients
grads = [param.grad.view(-1) for param in self.policy.parameters() if param.grad is not None]
if self.rnd:
grads += [param.grad.view(-1) for param in self.rnd.parameters() if param.grad is not None]
all_grads = torch.cat(grads)
# Average the gradients across all GPUs
torch.distributed.all_reduce(all_grads, op=torch.distributed.ReduceOp.SUM)
all_grads /= self.gpu_world_size
# Get all parameters
all_params = self.policy.parameters()
if self.rnd:
all_params = chain(all_params, self.rnd.parameters())
# Update the gradients for all parameters with the reduced gradients
offset = 0
for param in all_params:
if param.grad is not None:
numel = param.numel()
# Copy data back from shared buffer
param.grad.data.copy_(all_grads[offset : offset + numel].view_as(param.grad.data))
# Update the offset for the next parameter
offset += numel

10
source/rsl_rl/rsl_rl/env/__init__.py vendored Normal file
View File

@@ -0,0 +1,10 @@
# Copyright (c) 2021-2026, ETH Zurich and NVIDIA CORPORATION
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
"""Submodule defining the environment definitions."""
from .vec_env import VecEnv
__all__ = ["VecEnv"]

91
source/rsl_rl/rsl_rl/env/vec_env.py vendored Normal file
View File

@@ -0,0 +1,91 @@
# Copyright (c) 2021-2026, ETH Zurich and NVIDIA CORPORATION
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
import torch
from abc import ABC, abstractmethod
from tensordict import TensorDict
class VecEnv(ABC):
"""Abstract class for a vectorized environment.
The vectorized environment is a collection of environments that are synchronized. This means that the same type of
action is applied to all environments and the same type of observation is returned from all environments.
"""
num_envs: int
"""Number of environments."""
num_actions: int
"""Number of actions."""
max_episode_length: int | torch.Tensor
"""Maximum episode length.
The maximum episode length can be a scalar or a tensor. If it is a scalar, it is the same for all environments.
If it is a tensor, it is the maximum episode length for each environment. This is useful for dynamic episode
lengths.
"""
episode_length_buf: torch.Tensor
"""Buffer for current episode lengths."""
device: torch.device | str
"""Device to use."""
cfg: dict | object
"""Configuration object."""
@abstractmethod
def get_observations(self) -> TensorDict:
"""Return the current observations.
Returns:
The observations from the environment.
"""
raise NotImplementedError
@abstractmethod
def step(self, actions: torch.Tensor) -> tuple[TensorDict, torch.Tensor, torch.Tensor, dict]:
"""Apply input action to the environment.
Args:
actions: Input actions to apply. Shape: (num_envs, num_actions)
Returns:
observations: Observations from the environment.
rewards: Rewards from the environment. Shape: (num_envs,)
dones: Done flags from the environment. Shape: (num_envs,)
extras: Extra information from the environment.
Observations:
The observations TensorDict usually contains multiple observation groups. The `obs_groups`
dictionary of the runner configuration specifies which observation groups are used for which
purpose, i.e., it maps the available observation groups to observation sets. The observation sets
(keys of the `obs_groups` dictionary) currently used by rsl_rl are:
- "policy": Specified observation groups are used as input to the actor/student network.
- "critic": Specified observation groups are used as input to the critic network.
- "teacher": Specified observation groups are used as input to the teacher network.
- "rnd_state": Specified observation groups are used as input to the RND network.
Incomplete or incorrect configurations are handled in the `resolve_obs_groups()` function in
`rsl_rl/utils/utils.py`.
Extras:
The extras dictionary includes metrics such as the episode reward, episode length, etc. The following
dictionary keys are used by rsl_rl:
- "time_outs" (torch.Tensor): Timeouts for the environments. These correspond to terminations that
happen due to time limits and not due to the environment reaching a terminal state. This is useful
for environments that have a fixed episode length.
- "log" (dict[str, float | torch.Tensor]): Additional information for logging and debugging purposes.
The key should be a string and start with "/" for namespacing. The value can be a scalar or a
tensor. If it is a tensor, the mean of the tensor is used for logging.
"""
raise NotImplementedError

View File

@@ -0,0 +1,27 @@
# Copyright (c) 2021-2026, ETH Zurich and NVIDIA CORPORATION
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
"""Definitions for neural-network components for RL-agents."""
from .actor_critic import ActorCritic
from .actor_critic_cnn import ActorCriticCNN
from .actor_critic_recurrent import ActorCriticRecurrent
from .actor_critic_moe_cts import ActorCriticMoECTS
from .rnd import RandomNetworkDistillation, resolve_rnd_config
from .student_teacher import StudentTeacher
from .student_teacher_recurrent import StudentTeacherRecurrent
from .symmetry import resolve_symmetry_config
__all__ = [
"ActorCritic",
"ActorCriticCNN",
"ActorCriticRecurrent",
"ActorCriticMoECTS",
"RandomNetworkDistillation",
"StudentTeacher",
"StudentTeacherRecurrent",
"resolve_rnd_config",
"resolve_symmetry_config",
]

View File

@@ -0,0 +1,198 @@
# Copyright (c) 2021-2026, ETH Zurich and NVIDIA CORPORATION
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
import torch
import torch.nn as nn
from tensordict import TensorDict
from torch.distributions import Normal
from typing import Any, NoReturn
from rsl_rl.networks import MLP, EmpiricalNormalization
class ActorCritic(nn.Module):
is_recurrent: bool = False
def __init__(
self,
obs: TensorDict,
obs_groups: dict[str, list[str]],
num_actions: int,
actor_obs_normalization: bool = False,
critic_obs_normalization: bool = False,
actor_hidden_dims: tuple[int] | list[int] = [256, 256, 256],
critic_hidden_dims: tuple[int] | list[int] = [256, 256, 256],
activation: str = "elu",
init_noise_std: float = 1.0,
noise_std_type: str = "scalar",
state_dependent_std: bool = False,
**kwargs: dict[str, Any],
) -> None:
if kwargs:
print(
"ActorCritic.__init__ got unexpected arguments, which will be ignored: " + str([key for key in kwargs])
)
super().__init__()
# Get the observation dimensions
self.obs_groups = obs_groups
num_actor_obs = 0
for obs_group in obs_groups["policy"]:
assert len(obs[obs_group].shape) == 2, "The ActorCritic module only supports 1D observations."
num_actor_obs += obs[obs_group].shape[-1]
num_critic_obs = 0
for obs_group in obs_groups["critic"]:
assert len(obs[obs_group].shape) == 2, "The ActorCritic module only supports 1D observations."
num_critic_obs += obs[obs_group].shape[-1]
# Actor
self.state_dependent_std = state_dependent_std
if self.state_dependent_std:
self.actor = MLP(num_actor_obs, [2, num_actions], actor_hidden_dims, activation)
else:
self.actor = MLP(num_actor_obs, num_actions, actor_hidden_dims, activation)
print(f"Actor MLP: {self.actor}")
# Actor observation normalization
self.actor_obs_normalization = actor_obs_normalization
if actor_obs_normalization:
self.actor_obs_normalizer = EmpiricalNormalization(num_actor_obs)
else:
self.actor_obs_normalizer = torch.nn.Identity()
# Critic
self.critic = MLP(num_critic_obs, 1, critic_hidden_dims, activation)
print(f"Critic MLP: {self.critic}")
# Critic observation normalization
self.critic_obs_normalization = critic_obs_normalization
if critic_obs_normalization:
self.critic_obs_normalizer = EmpiricalNormalization(num_critic_obs)
else:
self.critic_obs_normalizer = torch.nn.Identity()
# Action noise
self.noise_std_type = noise_std_type
if self.state_dependent_std:
torch.nn.init.zeros_(self.actor[-2].weight[num_actions:])
if self.noise_std_type == "scalar":
torch.nn.init.constant_(self.actor[-2].bias[num_actions:], init_noise_std)
elif self.noise_std_type == "log":
torch.nn.init.constant_(
self.actor[-2].bias[num_actions:], torch.log(torch.tensor(init_noise_std + 1e-7))
)
else:
raise ValueError(f"Unknown standard deviation type: {self.noise_std_type}. Should be 'scalar' or 'log'")
else:
if self.noise_std_type == "scalar":
self.std = nn.Parameter(init_noise_std * torch.ones(num_actions))
elif self.noise_std_type == "log":
self.log_std = nn.Parameter(torch.log(init_noise_std * torch.ones(num_actions)))
else:
raise ValueError(f"Unknown standard deviation type: {self.noise_std_type}. Should be 'scalar' or 'log'")
# Action distribution
# Note: Populated in update_distribution
self.distribution = None
# Disable args validation for speedup
Normal.set_default_validate_args(False)
def reset(self, dones: torch.Tensor | None = None) -> None:
pass
def forward(self) -> NoReturn:
raise NotImplementedError
@property
def action_mean(self) -> torch.Tensor:
return self.distribution.mean
@property
def action_std(self) -> torch.Tensor:
return self.distribution.stddev
@property
def entropy(self) -> torch.Tensor:
return self.distribution.entropy().sum(dim=-1)
def _update_distribution(self, obs: torch.Tensor) -> None:
if self.state_dependent_std:
# Compute mean and standard deviation
mean_and_std = self.actor(obs)
if self.noise_std_type == "scalar":
mean, std = torch.unbind(mean_and_std, dim=-2)
elif self.noise_std_type == "log":
mean, log_std = torch.unbind(mean_and_std, dim=-2)
std = torch.exp(log_std)
else:
raise ValueError(f"Unknown standard deviation type: {self.noise_std_type}. Should be 'scalar' or 'log'")
else:
# Compute mean
mean = self.actor(obs)
# Compute standard deviation
if self.noise_std_type == "scalar":
std = self.std.expand_as(mean)
elif self.noise_std_type == "log":
std = torch.exp(self.log_std).expand_as(mean)
else:
raise ValueError(f"Unknown standard deviation type: {self.noise_std_type}. Should be 'scalar' or 'log'")
# Create distribution
self.distribution = Normal(mean, std)
def act(self, obs: TensorDict, **kwargs: dict[str, Any]) -> torch.Tensor:
obs = self.get_actor_obs(obs)
obs = self.actor_obs_normalizer(obs)
self._update_distribution(obs)
return self.distribution.sample()
def act_inference(self, obs: TensorDict) -> torch.Tensor:
obs = self.get_actor_obs(obs)
obs = self.actor_obs_normalizer(obs)
if self.state_dependent_std:
return self.actor(obs)[..., 0, :]
else:
return self.actor(obs)
def evaluate(self, obs: TensorDict, **kwargs: dict[str, Any]) -> torch.Tensor:
obs = self.get_critic_obs(obs)
obs = self.critic_obs_normalizer(obs)
return self.critic(obs)
def get_actor_obs(self, obs: TensorDict) -> torch.Tensor:
obs_list = [obs[obs_group] for obs_group in self.obs_groups["policy"]]
return torch.cat(obs_list, dim=-1)
def get_critic_obs(self, obs: TensorDict) -> torch.Tensor:
obs_list = [obs[obs_group] for obs_group in self.obs_groups["critic"]]
return torch.cat(obs_list, dim=-1)
def get_actions_log_prob(self, actions: torch.Tensor) -> torch.Tensor:
return self.distribution.log_prob(actions).sum(dim=-1)
def update_normalization(self, obs: TensorDict) -> None:
if self.actor_obs_normalization:
actor_obs = self.get_actor_obs(obs)
self.actor_obs_normalizer.update(actor_obs)
if self.critic_obs_normalization:
critic_obs = self.get_critic_obs(obs)
self.critic_obs_normalizer.update(critic_obs)
def load_state_dict(self, state_dict: dict, strict: bool = True) -> bool:
"""Load the parameters of the actor-critic model.
Args:
state_dict: State dictionary of the model.
strict: Whether to strictly enforce that the keys in `state_dict` match the keys returned by this module's
:meth:`state_dict` function.
Returns:
Whether this training resumes a previous training. This flag is used by the :func:`load` function of
:class:`OnPolicyRunner` to determine how to load further parameters (relevant for, e.g., distillation).
"""
super().load_state_dict(state_dict, strict=strict)
return True

View File

@@ -0,0 +1,262 @@
# Copyright (c) 2021-2026, ETH Zurich and NVIDIA CORPORATION
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
import torch
import torch.nn as nn
from tensordict import TensorDict
from torch.distributions import Normal
from typing import Any
from rsl_rl.networks import CNN, MLP, EmpiricalNormalization
from .actor_critic import ActorCritic
class ActorCriticCNN(ActorCritic):
def __init__(
self,
obs: TensorDict,
obs_groups: dict[str, list[str]],
num_actions: int,
actor_obs_normalization: bool = False,
critic_obs_normalization: bool = False,
actor_hidden_dims: tuple[int] | list[int] = [256, 256, 256],
critic_hidden_dims: tuple[int] | list[int] = [256, 256, 256],
actor_cnn_cfg: dict[str, dict] | dict | None = None,
critic_cnn_cfg: dict[str, dict] | dict | None = None,
activation: str = "elu",
init_noise_std: float = 1.0,
noise_std_type: str = "scalar",
state_dependent_std: bool = False,
**kwargs: dict[str, Any],
) -> None:
if kwargs:
print(
"ActorCriticCNN.__init__ got unexpected arguments, which will be ignored: "
+ str([key for key in kwargs])
)
super(ActorCritic, self).__init__()
# Get the observation dimensions
self.obs_groups = obs_groups
num_actor_obs_1d = 0
self.actor_obs_groups_1d = []
actor_in_dims_2d = []
actor_in_channels_2d = []
self.actor_obs_groups_2d = []
for obs_group in obs_groups["policy"]:
if len(obs[obs_group].shape) == 4: # B, C, H, W
self.actor_obs_groups_2d.append(obs_group)
actor_in_dims_2d.append(obs[obs_group].shape[2:4])
actor_in_channels_2d.append(obs[obs_group].shape[1])
elif len(obs[obs_group].shape) == 2: # B, C
self.actor_obs_groups_1d.append(obs_group)
num_actor_obs_1d += obs[obs_group].shape[-1]
else:
raise ValueError(f"Invalid observation shape for {obs_group}: {obs[obs_group].shape}")
num_critic_obs_1d = 0
self.critic_obs_groups_1d = []
critic_in_dims_2d = []
critic_in_channels_2d = []
self.critic_obs_groups_2d = []
for obs_group in obs_groups["critic"]:
if len(obs[obs_group].shape) == 4: # B, C, H, W
self.critic_obs_groups_2d.append(obs_group)
critic_in_dims_2d.append(obs[obs_group].shape[2:4])
critic_in_channels_2d.append(obs[obs_group].shape[1])
elif len(obs[obs_group].shape) == 2: # B, C
self.critic_obs_groups_1d.append(obs_group)
num_critic_obs_1d += obs[obs_group].shape[-1]
else:
raise ValueError(f"Invalid observation shape for {obs_group}: {obs[obs_group].shape}")
# Assert that there are 2D observations
assert self.actor_obs_groups_2d or self.critic_obs_groups_2d, (
"No 2D observations are provided. If this is intentional, use the ActorCritic module instead."
)
# Actor CNN
if self.actor_obs_groups_2d:
# Resolve the actor CNN configuration
assert actor_cnn_cfg is not None, "An actor CNN configuration is required for 2D actor observations."
# If a single configuration dictionary is provided, create a dictionary for each 2D observation group
if not all(isinstance(v, dict) for v in actor_cnn_cfg.values()):
actor_cnn_cfg = {group: actor_cnn_cfg for group in self.actor_obs_groups_2d}
# Check that the number of configs matches the number of observation groups
assert len(actor_cnn_cfg) == len(self.actor_obs_groups_2d), (
"The number of CNN configurations must match the number of 2D actor observations."
)
# Create CNNs for each 2D actor observation
self.actor_cnns = nn.ModuleDict()
encoding_dim = 0
for idx, obs_group in enumerate(self.actor_obs_groups_2d):
self.actor_cnns[obs_group] = CNN(
input_dim=actor_in_dims_2d[idx],
input_channels=actor_in_channels_2d[idx],
**actor_cnn_cfg[obs_group],
)
print(f"Actor CNN for {obs_group}: {self.actor_cnns[obs_group]}")
# Get the output dimension of the CNN
if self.actor_cnns[obs_group].output_channels is None:
encoding_dim += int(self.actor_cnns[obs_group].output_dim)
else:
raise ValueError("The output of the actor CNN must be flattened before passing it to the MLP.")
else:
self.actor_cnns = None
encoding_dim = 0
# Actor MLP
self.state_dependent_std = state_dependent_std
if self.state_dependent_std:
self.actor = MLP(num_actor_obs_1d + encoding_dim, [2, num_actions], actor_hidden_dims, activation)
else:
self.actor = MLP(num_actor_obs_1d + encoding_dim, num_actions, actor_hidden_dims, activation)
print(f"Actor MLP: {self.actor}")
# Actor observation normalization (only for 1D actor observations)
self.actor_obs_normalization = actor_obs_normalization
if actor_obs_normalization:
self.actor_obs_normalizer = EmpiricalNormalization(num_actor_obs_1d)
else:
self.actor_obs_normalizer = torch.nn.Identity()
# Critic CNN
if self.critic_obs_groups_2d:
# Resolve the critic CNN configuration
assert critic_cnn_cfg is not None, "A critic CNN configuration is required for 2D critic observations."
# If a single configuration dictionary is provided, create a dictionary for each 2D observation group
if not all(isinstance(v, dict) for v in critic_cnn_cfg.values()):
critic_cnn_cfg = {group: critic_cnn_cfg for group in self.critic_obs_groups_2d}
# Check that the number of configs matches the number of observation groups
assert len(critic_cnn_cfg) == len(self.critic_obs_groups_2d), (
"The number of CNN configurations must match the number of 2D critic observations."
)
# Create CNNs for each 2D critic observation
self.critic_cnns = nn.ModuleDict()
encoding_dim = 0
for idx, obs_group in enumerate(self.critic_obs_groups_2d):
self.critic_cnns[obs_group] = CNN(
input_dim=critic_in_dims_2d[idx],
input_channels=critic_in_channels_2d[idx],
**critic_cnn_cfg[obs_group],
)
print(f"Critic CNN for {obs_group}: {self.critic_cnns[obs_group]}")
# Get the output dimension of the CNN
if self.critic_cnns[obs_group].output_channels is None:
encoding_dim += int(self.critic_cnns[obs_group].output_dim)
else:
raise ValueError("The output of the critic CNN must be flattened before passing it to the MLP.")
else:
self.critic_cnns = None
encoding_dim = 0
# Critic MLP
self.critic = MLP(num_critic_obs_1d + encoding_dim, 1, critic_hidden_dims, activation)
print(f"Critic MLP: {self.critic}")
# Critic observation normalization (only for 1D critic observations)
self.critic_obs_normalization = critic_obs_normalization
if critic_obs_normalization:
self.critic_obs_normalizer = EmpiricalNormalization(num_critic_obs_1d)
else:
self.critic_obs_normalizer = torch.nn.Identity()
# Action noise
self.noise_std_type = noise_std_type
if self.state_dependent_std:
torch.nn.init.zeros_(self.actor[-2].weight[num_actions:])
if self.noise_std_type == "scalar":
torch.nn.init.constant_(self.actor[-2].bias[num_actions:], init_noise_std)
elif self.noise_std_type == "log":
torch.nn.init.constant_(
self.actor[-2].bias[num_actions:], torch.log(torch.tensor(init_noise_std + 1e-7))
)
else:
raise ValueError(f"Unknown standard deviation type: {self.noise_std_type}. Should be 'scalar' or 'log'")
else:
if self.noise_std_type == "scalar":
self.std = nn.Parameter(init_noise_std * torch.ones(num_actions))
elif self.noise_std_type == "log":
self.log_std = nn.Parameter(torch.log(init_noise_std * torch.ones(num_actions)))
else:
raise ValueError(f"Unknown standard deviation type: {self.noise_std_type}. Should be 'scalar' or 'log'")
# Action distribution
# Note: Populated in update_distribution
self.distribution = None
# Disable args validation for speedup
Normal.set_default_validate_args(False)
def _update_distribution(self, mlp_obs: torch.Tensor, cnn_obs: dict[str, torch.Tensor]) -> None:
if self.actor_cnns is not None:
# Encode the 2D actor observations
cnn_enc_list = [self.actor_cnns[obs_group](cnn_obs[obs_group]) for obs_group in self.actor_obs_groups_2d]
cnn_enc = torch.cat(cnn_enc_list, dim=-1)
# Concatenate to the MLP observations
mlp_obs = torch.cat([mlp_obs, cnn_enc], dim=-1)
super()._update_distribution(mlp_obs)
def act(self, obs: TensorDict, **kwargs: dict[str, Any]) -> torch.Tensor:
mlp_obs, cnn_obs = self.get_actor_obs(obs)
mlp_obs = self.actor_obs_normalizer(mlp_obs)
self._update_distribution(mlp_obs, cnn_obs)
return self.distribution.sample() # type: ignore
def act_inference(self, obs: TensorDict) -> torch.Tensor:
mlp_obs, cnn_obs = self.get_actor_obs(obs)
mlp_obs = self.actor_obs_normalizer(mlp_obs)
if self.actor_cnns is not None:
# Encode the 2D actor observations
cnn_enc_list = [self.actor_cnns[obs_group](cnn_obs[obs_group]) for obs_group in self.actor_obs_groups_2d]
cnn_enc = torch.cat(cnn_enc_list, dim=-1)
# Concatenate to the MLP observations
mlp_obs = torch.cat([mlp_obs, cnn_enc], dim=-1)
if self.state_dependent_std:
return self.actor(mlp_obs)[..., 0, :]
else:
return self.actor(mlp_obs)
def evaluate(self, obs: TensorDict, **kwargs: dict[str, Any]) -> torch.Tensor:
mlp_obs, cnn_obs = self.get_critic_obs(obs)
mlp_obs = self.critic_obs_normalizer(mlp_obs)
if self.critic_cnns is not None:
# Encode the 2D critic observations
cnn_enc_list = [self.critic_cnns[obs_group](cnn_obs[obs_group]) for obs_group in self.critic_obs_groups_2d]
cnn_enc = torch.cat(cnn_enc_list, dim=-1)
# Concatenate to the MLP observations
mlp_obs = torch.cat([mlp_obs, cnn_enc], dim=-1)
return self.critic(mlp_obs)
def get_actor_obs(self, obs: TensorDict) -> tuple[torch.Tensor, dict[str, torch.Tensor]]:
obs_list_1d = [obs[obs_group] for obs_group in self.actor_obs_groups_1d]
obs_dict_2d = {}
for obs_group in self.actor_obs_groups_2d:
obs_dict_2d[obs_group] = obs[obs_group]
return torch.cat(obs_list_1d, dim=-1), obs_dict_2d
def get_critic_obs(self, obs: TensorDict) -> tuple[torch.Tensor, dict[str, torch.Tensor]]:
obs_list_1d = [obs[obs_group] for obs_group in self.critic_obs_groups_1d]
obs_dict_2d = {}
for obs_group in self.critic_obs_groups_2d:
obs_dict_2d[obs_group] = obs[obs_group]
return torch.cat(obs_list_1d, dim=-1), obs_dict_2d
def update_normalization(self, obs: TensorDict) -> None:
if self.actor_obs_normalization:
actor_obs, _ = self.get_actor_obs(obs)
self.actor_obs_normalizer.update(actor_obs)
if self.critic_obs_normalization:
critic_obs, _ = self.get_critic_obs(obs)
self.critic_obs_normalizer.update(critic_obs)

View File

@@ -0,0 +1,281 @@
# Copyright (c) 2021-2026, ETH Zurich and NVIDIA CORPORATION
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
import torch
import torch.nn as nn
from tensordict import TensorDict
from torch.distributions import Normal
from typing import Any, NoReturn
from rsl_rl.networks.moe import MLP
from rsl_rl.networks import EmpiricalNormalization, L2Norm, SimNorm, MoE
class StudentMoEEncoder(nn.Module):
def __init__(
self,
expert_num,
input_dim,
hidden_dims,
output_dim,
activation='elu',
norm_type='l2norm',
):
super().__init__()
self.norm_layer = L2Norm() if norm_type == 'l2norm' else SimNorm()
self.moe = MoE(
expert_num=expert_num,
input_dim=input_dim,
hidden_dims=hidden_dims,
output_dim=output_dim,
activation=activation,
)
def forward(self, obs):
latent, weights = self.moe(obs)
latent = self.norm_layer(latent)
return latent, weights
class ActorCriticMoECTS(nn.Module):
is_recurrent: bool = False
def __init__(
self,
obs: TensorDict,
obs_groups: dict[str, list[str]],
num_actions: int,
actor_obs_normalization: bool = False,
critic_obs_normalization: bool = False,
actor_hidden_dims: tuple[int] | list[int] = [256, 256, 256],
critic_hidden_dims: tuple[int] | list[int] = [256, 256, 256],
teacher_encoder_hidden_dims: tuple[int] | list[int] = [512, 256],
student_encoder_hidden_dims: tuple[int] | list[int] = [512, 256, 128],
expert_num: int = 8,
activation: str = "elu",
init_noise_std: float = 1.0,
noise_std_type: str = "scalar",
state_dependent_std: bool = False,
latent_dim: int = 32,
norm_type: str = 'l2norm',
**kwargs: dict[str, Any],
) -> None:
if kwargs:
print(
"ActorCriticMoECTS.__init__ got unexpected arguments, which will be ignored: " + str([key for key in kwargs])
)
assert norm_type in ['l2norm', 'simnorm'], f"Normalization type {norm_type} not supported!"
assert "policy" in obs.keys() and "critic" in obs.keys() and "single_obs" in obs.keys(), \
"obs must contain 'policy', 'critic' and 'single_obs' keys for ActorCriticMoECTS."
super().__init__()
self.num_actions = num_actions
# Get the observation dimensions
self.obs_groups = obs_groups
num_actor_obs = 0
for obs_group in obs_groups["policy"]:
assert len(obs[obs_group].shape) == 2, "The ActorCriticMoECTS module only supports 1D observations."
num_actor_obs += obs[obs_group].shape[-1]
num_critic_obs = 0
for obs_group in obs_groups["critic"]:
assert len(obs[obs_group].shape) == 2, "The ActorCriticMoECTS module only supports 1D observations."
num_critic_obs += obs[obs_group].shape[-1]
# MLP input dimensions (teacher, student, actor, critic)
self.num_actor_obs = num_actor_obs
self.num_single_obs = obs['single_obs'].shape[-1]
mlp_input_dim_t = num_critic_obs
mlp_input_dim_s = num_actor_obs
mlp_input_dim_a = latent_dim + self.num_single_obs
mlp_input_dim_c = latent_dim + num_critic_obs
# Teacher encoder
self.teacher_encoder = nn.Sequential(
MLP(mlp_input_dim_t, latent_dim, teacher_encoder_hidden_dims, activation=activation),
L2Norm() if norm_type == 'l2norm' else SimNorm()
)
print(f"Teacher Encoder: {self.teacher_encoder}")
# Student MoE encoder
self.student_moe_encoder = StudentMoEEncoder(
expert_num=expert_num,
input_dim=mlp_input_dim_s,
hidden_dims=student_encoder_hidden_dims,
output_dim=latent_dim,
activation=activation,
norm_type=norm_type,
)
print(f"Student MoE Encoder: {self.student_moe_encoder}")
# Actor
self.state_dependent_std = state_dependent_std
if self.state_dependent_std:
self.actor = MLP(mlp_input_dim_a, [2, num_actions], actor_hidden_dims, activation)
else:
self.actor = MLP(mlp_input_dim_a, num_actions, actor_hidden_dims, activation)
print(f"Actor MLP: {self.actor}")
# Actor observation normalization
self.actor_obs_normalization = actor_obs_normalization
if actor_obs_normalization:
self.actor_obs_normalizer = EmpiricalNormalization(self.num_actor_obs)
self.single_obs_normalizer = EmpiricalNormalization(self.num_single_obs)
else:
self.actor_obs_normalizer = torch.nn.Identity()
self.single_obs_normalizer = torch.nn.Identity()
# Critic
self.critic = MLP(mlp_input_dim_c, 1, critic_hidden_dims, activation)
print(f"Critic MLP: {self.critic}")
# Critic observation normalization
self.critic_obs_normalization = critic_obs_normalization
if critic_obs_normalization:
self.critic_obs_normalizer = EmpiricalNormalization(num_critic_obs)
else:
self.critic_obs_normalizer = torch.nn.Identity()
# Action noise
self.noise_std_type = noise_std_type
if self.state_dependent_std:
torch.nn.init.zeros_(self.actor[-2].weight[num_actions:])
if self.noise_std_type == "scalar":
torch.nn.init.constant_(self.actor[-2].bias[num_actions:], init_noise_std)
elif self.noise_std_type == "log":
torch.nn.init.constant_(
self.actor[-2].bias[num_actions:], torch.log(torch.tensor(init_noise_std + 1e-7))
)
else:
raise ValueError(f"Unknown standard deviation type: {self.noise_std_type}. Should be 'scalar' or 'log'")
else:
if self.noise_std_type == "scalar":
self.std = nn.Parameter(init_noise_std * torch.ones(num_actions))
elif self.noise_std_type == "log":
self.log_std = nn.Parameter(torch.log(init_noise_std * torch.ones(num_actions)))
else:
raise ValueError(f"Unknown standard deviation type: {self.noise_std_type}. Should be 'scalar' or 'log'")
# Action distribution
# Note: Populated in update_distribution
self.distribution = None
# Disable args validation for speedup
Normal.set_default_validate_args(False)
def reset(self, dones: torch.Tensor | None = None) -> None:
pass
def forward(self) -> NoReturn:
raise NotImplementedError
@property
def action_mean(self) -> torch.Tensor:
return self.distribution.mean
@property
def action_std(self) -> torch.Tensor:
return self.distribution.stddev
@property
def entropy(self) -> torch.Tensor:
return self.distribution.entropy().sum(dim=-1)
def _update_distribution(self, latent_and_obs: torch.Tensor) -> None:
if self.state_dependent_std:
# Compute mean and standard deviation
mean_and_std = self.actor(latent_and_obs)
if self.noise_std_type == "scalar":
mean, std = torch.unbind(mean_and_std, dim=-2)
elif self.noise_std_type == "log":
mean, log_std = torch.unbind(mean_and_std, dim=-2)
std = torch.exp(log_std)
else:
raise ValueError(f"Unknown standard deviation type: {self.noise_std_type}. Should be 'scalar' or 'log'")
else:
# Compute mean
mean = self.actor(latent_and_obs)
# Compute standard deviation
if self.noise_std_type == "scalar":
std = self.std.expand_as(mean)
elif self.noise_std_type == "log":
std = torch.exp(self.log_std).expand_as(mean)
else:
raise ValueError(f"Unknown standard deviation type: {self.noise_std_type}. Should be 'scalar' or 'log'")
# Create distribution
self.distribution = Normal(mean, std)
def act(self, obs: TensorDict, is_teacher: bool, **kwargs: dict[str, Any]) -> torch.Tensor:
single_obs = self.single_obs_normalizer(obs['single_obs'])
if is_teacher:
obs_c = self.get_critic_obs(obs)
obs_c = self.critic_obs_normalizer(obs_c)
latent = self.teacher_encoder(obs_c)
else:
with torch.no_grad():
obs_a = self.get_actor_obs(obs)
obs_a = self.actor_obs_normalizer(obs_a)
latent, _ = self.student_moe_encoder(obs_a)
latent_and_obs = torch.cat([latent, single_obs], dim=-1)
self._update_distribution(latent_and_obs)
return self.distribution.sample()
def act_inference(self, obs: TensorDict) -> torch.Tensor:
single_obs = self.single_obs_normalizer(obs['single_obs'])
obs_a = self.get_actor_obs(obs)
obs_a = self.actor_obs_normalizer(obs_a)
latent, _ = self.student_moe_encoder(obs_a)
latent_and_obs = torch.cat([latent, single_obs], dim=-1)
if self.state_dependent_std:
return self.actor(latent_and_obs)[..., 0, :]
else:
return self.actor(latent_and_obs)
def evaluate(self, obs: TensorDict, is_teacher: bool, **kwargs: dict[str, Any]) -> torch.Tensor:
obs_c = self.get_critic_obs(obs)
obs_c = self.critic_obs_normalizer(obs_c)
if is_teacher:
latent = self.teacher_encoder(obs_c)
else:
obs_a = self.get_actor_obs(obs)
obs_a = self.actor_obs_normalizer(obs_a)
latent, _ = self.student_moe_encoder(obs_a)
latent_and_obs = torch.cat([latent.detach(), obs_c], dim=-1)
return self.critic(latent_and_obs)
def get_actor_obs(self, obs: TensorDict) -> torch.Tensor:
obs_list = [obs[obs_group] for obs_group in self.obs_groups["policy"]]
return torch.cat(obs_list, dim=-1)
def get_critic_obs(self, obs: TensorDict) -> torch.Tensor:
obs_list = [obs[obs_group] for obs_group in self.obs_groups["critic"]]
return torch.cat(obs_list, dim=-1)
def get_actions_log_prob(self, actions: torch.Tensor) -> torch.Tensor:
return self.distribution.log_prob(actions).sum(dim=-1)
def update_normalization(self, obs: TensorDict) -> None:
if self.actor_obs_normalization:
actor_obs = self.get_actor_obs(obs)
self.actor_obs_normalizer.update(actor_obs)
self.single_obs_normalizer.update(obs['single_obs'])
if self.critic_obs_normalization:
critic_obs = self.get_critic_obs(obs)
self.critic_obs_normalizer.update(critic_obs)
def load_state_dict(self, state_dict: dict, strict: bool = True) -> bool:
"""Load the parameters of the actor-critic model.
Args:
state_dict: State dictionary of the model.
strict: Whether to strictly enforce that the keys in `state_dict` match the keys returned by this module's
:meth:`state_dict` function.
Returns:
Whether this training resumes a previous training. This flag is used by the :func:`load` function of
:class:`OnPolicyRunner` to determine how to load further parameters (relevant for, e.g., distillation).
"""
super().load_state_dict(state_dict, strict=strict)
return True

View File

@@ -0,0 +1,223 @@
# Copyright (c) 2021-2026, ETH Zurich and NVIDIA CORPORATION
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
import torch
import torch.nn as nn
import warnings
from tensordict import TensorDict
from torch.distributions import Normal
from typing import Any, NoReturn
from rsl_rl.networks import MLP, EmpiricalNormalization, HiddenState, Memory
class ActorCriticRecurrent(nn.Module):
is_recurrent: bool = True
def __init__(
self,
obs: TensorDict,
obs_groups: dict[str, list[str]],
num_actions: int,
actor_obs_normalization: bool = False,
critic_obs_normalization: bool = False,
actor_hidden_dims: tuple[int] | list[int] = [256, 256, 256],
critic_hidden_dims: tuple[int] | list[int] = [256, 256, 256],
activation: str = "elu",
init_noise_std: float = 1.0,
noise_std_type: str = "scalar",
state_dependent_std: bool = False,
rnn_type: str = "lstm",
rnn_hidden_dim: int = 256,
rnn_num_layers: int = 1,
**kwargs: dict[str, Any],
) -> None:
if "rnn_hidden_size" in kwargs:
warnings.warn(
"The argument `rnn_hidden_size` is deprecated and will be removed in a future version. "
"Please use `rnn_hidden_dim` instead.",
DeprecationWarning,
)
if rnn_hidden_dim == 256: # Only override if the new argument is at its default
rnn_hidden_dim = kwargs.pop("rnn_hidden_size")
if kwargs:
print(
"ActorCriticRecurrent.__init__ got unexpected arguments, which will be ignored: " + str(kwargs.keys()),
)
super().__init__()
# Get the observation dimensions
self.obs_groups = obs_groups
num_actor_obs = 0
for obs_group in obs_groups["policy"]:
assert len(obs[obs_group].shape) == 2, "The ActorCriticRecurrent module only supports 1D observations."
num_actor_obs += obs[obs_group].shape[-1]
num_critic_obs = 0
for obs_group in obs_groups["critic"]:
assert len(obs[obs_group].shape) == 2, "The ActorCriticRecurrent module only supports 1D observations."
num_critic_obs += obs[obs_group].shape[-1]
# Actor
self.state_dependent_std = state_dependent_std
self.memory_a = Memory(num_actor_obs, rnn_hidden_dim, rnn_num_layers, rnn_type)
if self.state_dependent_std:
self.actor = MLP(rnn_hidden_dim, [2, num_actions], actor_hidden_dims, activation)
else:
self.actor = MLP(rnn_hidden_dim, num_actions, actor_hidden_dims, activation)
print(f"Actor RNN: {self.memory_a}")
print(f"Actor MLP: {self.actor}")
# Actor observation normalization
self.actor_obs_normalization = actor_obs_normalization
if actor_obs_normalization:
self.actor_obs_normalizer = EmpiricalNormalization(num_actor_obs)
else:
self.actor_obs_normalizer = torch.nn.Identity()
# Critic
self.memory_c = Memory(num_critic_obs, rnn_hidden_dim, rnn_num_layers, rnn_type)
self.critic = MLP(rnn_hidden_dim, 1, critic_hidden_dims, activation)
print(f"Critic RNN: {self.memory_c}")
print(f"Critic MLP: {self.critic}")
# Critic observation normalization
self.critic_obs_normalization = critic_obs_normalization
if critic_obs_normalization:
self.critic_obs_normalizer = EmpiricalNormalization(num_critic_obs)
else:
self.critic_obs_normalizer = torch.nn.Identity()
# Action noise
self.noise_std_type = noise_std_type
if self.state_dependent_std:
torch.nn.init.zeros_(self.actor[-2].weight[num_actions:])
if self.noise_std_type == "scalar":
torch.nn.init.constant_(self.actor[-2].bias[num_actions:], init_noise_std)
elif self.noise_std_type == "log":
torch.nn.init.constant_(
self.actor[-2].bias[num_actions:], torch.log(torch.tensor(init_noise_std + 1e-7))
)
else:
raise ValueError(f"Unknown standard deviation type: {self.noise_std_type}. Should be 'scalar' or 'log'")
else:
if self.noise_std_type == "scalar":
self.std = nn.Parameter(init_noise_std * torch.ones(num_actions))
elif self.noise_std_type == "log":
self.log_std = nn.Parameter(torch.log(init_noise_std * torch.ones(num_actions)))
else:
raise ValueError(f"Unknown standard deviation type: {self.noise_std_type}. Should be 'scalar' or 'log'")
# Action distribution
# Note: Populated in update_distribution
self.distribution = None
# Disable args validation for speedup
Normal.set_default_validate_args(False)
@property
def action_mean(self) -> torch.Tensor:
return self.distribution.mean
@property
def action_std(self) -> torch.Tensor:
return self.distribution.stddev
@property
def entropy(self) -> torch.Tensor:
return self.distribution.entropy().sum(dim=-1)
def reset(self, dones: torch.Tensor | None = None) -> None:
self.memory_a.reset(dones)
self.memory_c.reset(dones)
def forward(self) -> NoReturn:
raise NotImplementedError
def _update_distribution(self, obs: torch.Tensor) -> None:
if self.state_dependent_std:
# Compute mean and standard deviation
mean_and_std = self.actor(obs)
if self.noise_std_type == "scalar":
mean, std = torch.unbind(mean_and_std, dim=-2)
elif self.noise_std_type == "log":
mean, log_std = torch.unbind(mean_and_std, dim=-2)
std = torch.exp(log_std)
else:
raise ValueError(f"Unknown standard deviation type: {self.noise_std_type}. Should be 'scalar' or 'log'")
else:
# Compute mean
mean = self.actor(obs)
# Compute standard deviation
if self.noise_std_type == "scalar":
std = self.std.expand_as(mean)
elif self.noise_std_type == "log":
std = torch.exp(self.log_std).expand_as(mean)
else:
raise ValueError(f"Unknown standard deviation type: {self.noise_std_type}. Should be 'scalar' or 'log'")
# Create distribution
self.distribution = Normal(mean, std)
def act(self, obs: TensorDict, masks: torch.Tensor | None = None, hidden_state: HiddenState = None) -> torch.Tensor:
obs = self.get_actor_obs(obs)
obs = self.actor_obs_normalizer(obs)
out_mem = self.memory_a(obs, masks, hidden_state).squeeze(0)
self._update_distribution(out_mem)
return self.distribution.sample()
def act_inference(self, obs: TensorDict) -> torch.Tensor:
obs = self.get_actor_obs(obs)
obs = self.actor_obs_normalizer(obs)
out_mem = self.memory_a(obs).squeeze(0)
if self.state_dependent_std:
return self.actor(out_mem)[..., 0, :]
else:
return self.actor(out_mem)
def evaluate(
self, obs: TensorDict, masks: torch.Tensor | None = None, hidden_state: HiddenState = None
) -> torch.Tensor:
obs = self.get_critic_obs(obs)
obs = self.critic_obs_normalizer(obs)
out_mem = self.memory_c(obs, masks, hidden_state).squeeze(0)
return self.critic(out_mem)
def get_actor_obs(self, obs: TensorDict) -> torch.Tensor:
obs_list = [obs[obs_group] for obs_group in self.obs_groups["policy"]]
return torch.cat(obs_list, dim=-1)
def get_critic_obs(self, obs: TensorDict) -> torch.Tensor:
obs_list = [obs[obs_group] for obs_group in self.obs_groups["critic"]]
return torch.cat(obs_list, dim=-1)
def get_actions_log_prob(self, actions: torch.Tensor) -> torch.Tensor:
return self.distribution.log_prob(actions).sum(dim=-1)
def get_hidden_states(self) -> tuple[HiddenState, HiddenState]:
return self.memory_a.hidden_state, self.memory_c.hidden_state
def update_normalization(self, obs: TensorDict) -> None:
if self.actor_obs_normalization:
actor_obs = self.get_actor_obs(obs)
self.actor_obs_normalizer.update(actor_obs)
if self.critic_obs_normalization:
critic_obs = self.get_critic_obs(obs)
self.critic_obs_normalizer.update(critic_obs)
def load_state_dict(self, state_dict: dict, strict: bool = True) -> bool:
"""Load the parameters of the actor-critic model.
Args:
state_dict: State dictionary of the model.
strict: Whether to strictly enforce that the keys in `state_dict` match the keys returned by this module's
:meth:`state_dict` function.
Returns:
Whether this training resumes a previous training. This flag is used by the :func:`load` function of
:class:`OnPolicyRunner` to determine how to load further parameters (relevant for, e.g., distillation).
"""
super().load_state_dict(state_dict, strict=strict)
return True

View File

@@ -0,0 +1,210 @@
# Copyright (c) 2021-2026, ETH Zurich and NVIDIA CORPORATION
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
import torch
import torch.nn as nn
from tensordict import TensorDict
from typing import Any, NoReturn
from rsl_rl.env import VecEnv
from rsl_rl.networks import MLP, EmpiricalDiscountedVariationNormalization, EmpiricalNormalization
class RandomNetworkDistillation(nn.Module):
"""Implementation of Random Network Distillation (RND) [1].
References:
.. [1] Burda, Yuri, et al. "Exploration by Random Network Distillation." arXiv preprint arXiv:1810.12894 (2018).
"""
def __init__(
self,
num_states: int,
obs_groups: dict,
num_outputs: int,
predictor_hidden_dims: tuple[int] | list[int],
target_hidden_dims: tuple[int] | list[int],
activation: str = "elu",
weight: float = 0.0,
state_normalization: bool = False,
reward_normalization: bool = False,
device: str = "cpu",
weight_schedule: dict | None = None,
) -> None:
"""Initialize the RND module.
- If :attr:`state_normalization` is True, then the input state is normalized using an Empirical Normalization
layer.
- If :attr:`reward_normalization` is True, then the intrinsic reward is normalized using an Empirical Discounted
Variation Normalization layer.
- If the hidden dimensions are -1 in the predictor and target networks configuration, then the number of states
is used as the hidden dimension.
Args:
num_states: Number of states/inputs to the predictor and target networks.
obs_groups: Dictionary of observation groups.
num_outputs: Number of outputs (embedding size) of the predictor and target networks.
predictor_hidden_dims: List of hidden dimensions of the predictor network.
target_hidden_dims: List of hidden dimensions of the target network.
activation: Activation function.
weight: Scaling factor of the intrinsic reward.
state_normalization: Whether to normalize the input state.
reward_normalization: Whether to normalize the intrinsic reward.
device: Device to use.
weight_schedule: Type of schedule to use for the RND weight parameter.
It is a dictionary with the following keys:
- "mode": Type of schedule to use for the RND weight parameter.
- "constant": Constant weight schedule.
- "step": Step weight schedule.
- "linear": Linear weight schedule.
For the "step" weight schedule, the following parameters are required:
- "final_step": Step at which the weight parameter is set to the final value.
- "final_value": Final value of the weight parameter.
For the "linear" weight schedule, the following parameters are required:
- "initial_step": Step at which the weight parameter is set to the initial value.
- "final_step": Step at which the weight parameter is set to the final value.
- "final_value": Final value of the weight parameter.
"""
# Initialize parent class
super().__init__()
# Store parameters
self.num_states = num_states
self.obs_groups = obs_groups
self.num_outputs = num_outputs
self.initial_weight = weight
self.device = device
self.state_normalization = state_normalization
self.reward_normalization = reward_normalization
# Normalization of input gates
if state_normalization:
self.state_normalizer = EmpiricalNormalization(shape=[self.num_states], until=1.0e8).to(self.device)
else:
self.state_normalizer = torch.nn.Identity()
# Normalization of intrinsic reward
if reward_normalization:
self.reward_normalizer = EmpiricalDiscountedVariationNormalization(shape=[], until=1.0e8).to(self.device)
else:
self.reward_normalizer = torch.nn.Identity()
# Counter for the number of updates
self.update_counter = 0
# Resolve weight schedule
if weight_schedule is not None:
self.weight_scheduler_params = weight_schedule
self.weight_scheduler = getattr(self, f"_{weight_schedule['mode']}_weight_schedule")
else:
self.weight_scheduler = None
# Create network architecture
self.predictor = MLP(num_states, num_outputs, predictor_hidden_dims, activation).to(self.device)
self.target = MLP(num_states, num_outputs, target_hidden_dims, activation).to(self.device)
# Make target network not trainable
self.target.eval()
def get_intrinsic_reward(self, obs: TensorDict) -> torch.Tensor:
# Note: The counter is updated number of env steps per learning iteration
self.update_counter += 1
# Extract the rnd state from the observation
rnd_state = self.get_rnd_state(obs)
rnd_state = self.state_normalizer(rnd_state)
# Obtain the embedding of the rnd state from the target and predictor networks
target_embedding = self.target(rnd_state).detach()
predictor_embedding = self.predictor(rnd_state).detach()
# Compute the intrinsic reward as the distance between the embeddings
intrinsic_reward = torch.linalg.norm(target_embedding - predictor_embedding, dim=1)
# Normalize intrinsic reward
intrinsic_reward = self.reward_normalizer(intrinsic_reward)
# Check the weight schedule
if self.weight_scheduler is not None:
self.weight = self.weight_scheduler(step=self.update_counter, **self.weight_scheduler_params)
else:
self.weight = self.initial_weight
# Scale intrinsic reward
intrinsic_reward *= self.weight
return intrinsic_reward
def forward(self, *args: Any, **kwargs: dict[str, Any]) -> NoReturn:
raise RuntimeError("Forward method is not implemented. Use get_intrinsic_reward instead.")
def train(self, mode: bool = True) -> RandomNetworkDistillation:
# Set module into training mode
self.predictor.train(mode)
if self.state_normalization:
self.state_normalizer.train(mode)
if self.reward_normalization:
self.reward_normalizer.train(mode)
return self
def eval(self) -> RandomNetworkDistillation:
return self.train(False)
def get_rnd_state(self, obs: TensorDict) -> torch.Tensor:
obs_list = [obs[obs_group] for obs_group in self.obs_groups["rnd_state"]]
return torch.cat(obs_list, dim=-1)
def update_normalization(self, obs: TensorDict) -> None:
# Normalize the state
if self.state_normalization:
rnd_state = self.get_rnd_state(obs)
self.state_normalizer.update(rnd_state)
def _constant_weight_schedule(self, step: int, **kwargs: dict[str, Any]) -> float:
return self.initial_weight
def _step_weight_schedule(self, step: int, final_step: int, final_value: float, **kwargs: dict[str, Any]) -> float:
return self.initial_weight if step < final_step else final_value
def _linear_weight_schedule(
self, step: int, initial_step: int, final_step: int, final_value: float, **kwargs: dict[str, Any]
) -> float:
if step < initial_step:
return self.initial_weight
elif step > final_step:
return final_value
else:
return self.initial_weight + (final_value - self.initial_weight) * (step - initial_step) / (
final_step - initial_step
)
def resolve_rnd_config(alg_cfg: dict, obs: TensorDict, obs_groups: dict[str, list[str]], env: VecEnv) -> dict:
"""Resolve the RND configuration.
Args:
alg_cfg: Algorithm configuration dictionary.
obs: Observation dictionary.
obs_groups: Observation groups dictionary.
env: Environment object.
Returns:
The resolved algorithm configuration dictionary.
"""
# Resolve dimension of rnd gated state
if "rnd_cfg" in alg_cfg and alg_cfg["rnd_cfg"] is not None:
# Get dimension of rnd gated state
num_rnd_state = 0
for obs_group in obs_groups["rnd_state"]:
assert len(obs[obs_group].shape) == 2, "The RND module only supports 1D observations."
num_rnd_state += obs[obs_group].shape[-1]
# Add rnd gated state to config
alg_cfg["rnd_cfg"]["num_states"] = num_rnd_state
alg_cfg["rnd_cfg"]["obs_groups"] = obs_groups
# Scale down the rnd weight with timestep
alg_cfg["rnd_cfg"]["weight"] *= env.unwrapped.step_dt
else:
alg_cfg["rnd_cfg"] = None
return alg_cfg

View File

@@ -0,0 +1,204 @@
# Copyright (c) 2021-2026, ETH Zurich and NVIDIA CORPORATION
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
import torch
import torch.nn as nn
from tensordict import TensorDict
from torch.distributions import Normal
from typing import Any, NoReturn
from rsl_rl.networks import MLP, EmpiricalNormalization, HiddenState
class StudentTeacher(nn.Module):
is_recurrent: bool = False
def __init__(
self,
obs: TensorDict,
obs_groups: dict[str, list[str]],
num_actions: int,
student_obs_normalization: bool = False,
teacher_obs_normalization: bool = False,
student_hidden_dims: tuple[int] | list[int] = [256, 256, 256],
teacher_hidden_dims: tuple[int] | list[int] = [256, 256, 256],
activation: str = "elu",
init_noise_std: float = 0.1,
noise_std_type: str = "scalar",
**kwargs: dict[str, Any],
) -> None:
if kwargs:
print(
"StudentTeacher.__init__ got unexpected arguments, which will be ignored: "
+ str([key for key in kwargs])
)
super().__init__()
self.loaded_teacher = False # Indicates if teacher has been loaded
# Get the observation dimensions
self.obs_groups = obs_groups
num_student_obs = 0
for obs_group in obs_groups["policy"]:
assert len(obs[obs_group].shape) == 2, "The StudentTeacher module only supports 1D observations."
num_student_obs += obs[obs_group].shape[-1]
num_teacher_obs = 0
for obs_group in obs_groups["teacher"]:
assert len(obs[obs_group].shape) == 2, "The StudentTeacher module only supports 1D observations."
num_teacher_obs += obs[obs_group].shape[-1]
# Student
self.student = MLP(num_student_obs, num_actions, student_hidden_dims, activation)
print(f"Student MLP: {self.student}")
# Student observation normalization
self.student_obs_normalization = student_obs_normalization
if student_obs_normalization:
self.student_obs_normalizer = EmpiricalNormalization(num_student_obs)
else:
self.student_obs_normalizer = torch.nn.Identity()
# Teacher
self.teacher = MLP(num_teacher_obs, num_actions, teacher_hidden_dims, activation)
print(f"Teacher MLP: {self.teacher}")
# Teacher observation normalization
self.teacher_obs_normalization = teacher_obs_normalization
if teacher_obs_normalization:
self.teacher_obs_normalizer = EmpiricalNormalization(num_teacher_obs)
else:
self.teacher_obs_normalizer = torch.nn.Identity()
# Action noise
self.noise_std_type = noise_std_type
if self.noise_std_type == "scalar":
self.std = nn.Parameter(init_noise_std * torch.ones(num_actions))
elif self.noise_std_type == "log":
self.log_std = nn.Parameter(torch.log(init_noise_std * torch.ones(num_actions)))
else:
raise ValueError(f"Unknown standard deviation type: {self.noise_std_type}. Should be 'scalar' or 'log'")
# Action distribution
# Note: Populated in update_distribution
self.distribution = None
# Disable args validation for speedup
Normal.set_default_validate_args(False)
def reset(
self, dones: torch.Tensor | None = None, hidden_states: tuple[HiddenState, HiddenState] = (None, None)
) -> None:
pass
def forward(self) -> NoReturn:
raise NotImplementedError
@property
def action_mean(self) -> torch.Tensor:
return self.distribution.mean
@property
def action_std(self) -> torch.Tensor:
return self.distribution.stddev
@property
def entropy(self) -> torch.Tensor:
return self.distribution.entropy().sum(dim=-1)
def _update_distribution(self, obs: TensorDict) -> None:
# Compute mean
mean = self.student(obs)
# Compute standard deviation
if self.noise_std_type == "scalar":
std = self.std.expand_as(mean)
elif self.noise_std_type == "log":
std = torch.exp(self.log_std).expand_as(mean)
else:
raise ValueError(f"Unknown standard deviation type: {self.noise_std_type}. Should be 'scalar' or 'log'")
# Create distribution
self.distribution = Normal(mean, std)
def act(self, obs: TensorDict) -> torch.Tensor:
obs = self.get_student_obs(obs)
obs = self.student_obs_normalizer(obs)
self._update_distribution(obs)
return self.distribution.sample()
def act_inference(self, obs: TensorDict) -> torch.Tensor:
obs = self.get_student_obs(obs)
obs = self.student_obs_normalizer(obs)
return self.student(obs)
def evaluate(self, obs: TensorDict) -> torch.Tensor:
obs = self.get_teacher_obs(obs)
obs = self.teacher_obs_normalizer(obs)
with torch.no_grad():
return self.teacher(obs)
def get_student_obs(self, obs: TensorDict) -> torch.Tensor:
obs_list = [obs[obs_group] for obs_group in self.obs_groups["policy"]]
return torch.cat(obs_list, dim=-1)
def get_teacher_obs(self, obs: TensorDict) -> torch.Tensor:
obs_list = [obs[obs_group] for obs_group in self.obs_groups["teacher"]]
return torch.cat(obs_list, dim=-1)
def get_hidden_states(self) -> tuple[HiddenState, HiddenState]:
return None, None
def detach_hidden_states(self, dones: torch.Tensor | None = None) -> None:
pass
def train(self, mode: bool = True) -> None:
super().train(mode)
# Make sure teacher is in eval mode
self.teacher.eval()
self.teacher_obs_normalizer.eval()
def update_normalization(self, obs: TensorDict) -> None:
if self.student_obs_normalization:
student_obs = self.get_student_obs(obs)
self.student_obs_normalizer.update(student_obs)
def load_state_dict(self, state_dict: dict, strict: bool = True) -> bool:
"""Load the parameters of the student and teacher networks.
Args:
state_dict: State dictionary of the model.
strict: Whether to strictly enforce that the keys in `state_dict` match the keys returned by this module's
:meth:`state_dict` function.
Returns:
Whether this training resumes a previous training. This flag is used by the :func:`load` function of
:class:`OnPolicyRunner` to determine how to load further parameters.
"""
# Check if state_dict contains teacher and student or just teacher parameters
if any("actor" in key for key in state_dict): # Load parameters from rl training
# Rename keys to match teacher and remove critic parameters
teacher_state_dict = {}
teacher_obs_normalizer_state_dict = {}
for key, value in state_dict.items():
if "actor." in key:
teacher_state_dict[key.replace("actor.", "")] = value
if "actor_obs_normalizer." in key:
teacher_obs_normalizer_state_dict[key.replace("actor_obs_normalizer.", "")] = value
self.teacher.load_state_dict(teacher_state_dict, strict=strict)
self.teacher_obs_normalizer.load_state_dict(teacher_obs_normalizer_state_dict, strict=strict)
# Set flag for successfully loading the parameters
self.loaded_teacher = True
self.teacher.eval()
self.teacher_obs_normalizer.eval()
return False # Training does not resume
elif any("student" in key for key in state_dict): # Load parameters from distillation training
super().load_state_dict(state_dict, strict=strict)
# Set flag for successfully loading the parameters
self.loaded_teacher = True
self.teacher.eval()
self.teacher_obs_normalizer.eval()
return True # Training resumes
else:
raise ValueError("state_dict does not contain student or teacher parameters")

View File

@@ -0,0 +1,244 @@
# Copyright (c) 2021-2026, ETH Zurich and NVIDIA CORPORATION
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
import torch
import torch.nn as nn
import warnings
from tensordict import TensorDict
from torch.distributions import Normal
from typing import Any, NoReturn
from rsl_rl.networks import MLP, EmpiricalNormalization, HiddenState, Memory
class StudentTeacherRecurrent(nn.Module):
is_recurrent: bool = True
def __init__(
self,
obs: TensorDict,
obs_groups: dict[str, list[str]],
num_actions: int,
student_obs_normalization: bool = False,
teacher_obs_normalization: bool = False,
student_hidden_dims: tuple[int] | list[int] = [256, 256, 256],
teacher_hidden_dims: tuple[int] | list[int] = [256, 256, 256],
activation: str = "elu",
init_noise_std: float = 0.1,
noise_std_type: str = "scalar",
rnn_type: str = "lstm",
rnn_hidden_dim: int = 256,
rnn_num_layers: int = 1,
teacher_recurrent: bool = False,
**kwargs: dict[str, Any],
) -> None:
if "rnn_hidden_size" in kwargs:
warnings.warn(
"The argument `rnn_hidden_size` is deprecated and will be removed in a future version. "
"Please use `rnn_hidden_dim` instead.",
DeprecationWarning,
)
if rnn_hidden_dim == 256: # Only override if the new argument is at its default
rnn_hidden_dim = kwargs.pop("rnn_hidden_size")
if kwargs:
print(
"StudentTeacherRecurrent.__init__ got unexpected arguments, which will be ignored: "
+ str(kwargs.keys()),
)
super().__init__()
self.loaded_teacher = False # Indicates if teacher has been loaded
self.teacher_recurrent = teacher_recurrent # Indicates if teacher is recurrent too
# Get the observation dimensions
self.obs_groups = obs_groups
num_student_obs = 0
for obs_group in obs_groups["policy"]:
assert len(obs[obs_group].shape) == 2, "The StudentTeacher module only supports 1D observations."
num_student_obs += obs[obs_group].shape[-1]
num_teacher_obs = 0
for obs_group in obs_groups["teacher"]:
assert len(obs[obs_group].shape) == 2, "The StudentTeacher module only supports 1D observations."
num_teacher_obs += obs[obs_group].shape[-1]
# Student
self.memory_s = Memory(num_student_obs, rnn_hidden_dim, rnn_num_layers, rnn_type)
self.student = MLP(rnn_hidden_dim, num_actions, student_hidden_dims, activation)
print(f"Student RNN: {self.memory_s}")
print(f"Student MLP: {self.student}")
# Student observation normalization
self.student_obs_normalization = student_obs_normalization
if student_obs_normalization:
self.student_obs_normalizer = EmpiricalNormalization(num_student_obs)
else:
self.student_obs_normalizer = torch.nn.Identity()
# Teacher
if self.teacher_recurrent:
self.memory_t = Memory(num_teacher_obs, rnn_hidden_dim, rnn_num_layers, rnn_type)
teacher_input_dim = rnn_hidden_dim if self.teacher_recurrent else num_teacher_obs
self.teacher = MLP(teacher_input_dim, num_actions, teacher_hidden_dims, activation)
if self.teacher_recurrent:
print(f"Teacher RNN: {self.memory_t}")
print(f"Teacher MLP: {self.teacher}")
# Teacher observation normalization
self.teacher_obs_normalization = teacher_obs_normalization
if teacher_obs_normalization:
self.teacher_obs_normalizer = EmpiricalNormalization(num_teacher_obs)
else:
self.teacher_obs_normalizer = torch.nn.Identity()
# Action noise
self.noise_std_type = noise_std_type
if self.noise_std_type == "scalar":
self.std = nn.Parameter(init_noise_std * torch.ones(num_actions))
elif self.noise_std_type == "log":
self.log_std = nn.Parameter(torch.log(init_noise_std * torch.ones(num_actions)))
else:
raise ValueError(f"Unknown standard deviation type: {self.noise_std_type}. Should be 'scalar' or 'log'")
# Action distribution
# Note: Populated in update_distribution
self.distribution = None
# Disable args validation for speedup
Normal.set_default_validate_args(False)
def reset(
self, dones: torch.Tensor | None = None, hidden_states: tuple[HiddenState, HiddenState] = (None, None)
) -> None:
self.memory_s.reset(dones, hidden_states[0])
if self.teacher_recurrent:
self.memory_t.reset(dones, hidden_states[1])
def forward(self) -> NoReturn:
raise NotImplementedError
@property
def action_mean(self) -> torch.Tensor:
return self.distribution.mean
@property
def action_std(self) -> torch.Tensor:
return self.distribution.stddev
@property
def entropy(self) -> torch.Tensor:
return self.distribution.entropy().sum(dim=-1)
def _update_distribution(self, obs: TensorDict) -> None:
# Compute mean
mean = self.student(obs)
# Compute standard deviation
if self.noise_std_type == "scalar":
std = self.std.expand_as(mean)
elif self.noise_std_type == "log":
std = torch.exp(self.log_std).expand_as(mean)
else:
raise ValueError(f"Unknown standard deviation type: {self.noise_std_type}. Should be 'scalar' or 'log'")
# Create distribution
self.distribution = Normal(mean, std)
def act(self, obs: TensorDict) -> torch.Tensor:
obs = self.get_student_obs(obs)
obs = self.student_obs_normalizer(obs)
out_mem = self.memory_s(obs).squeeze(0)
self._update_distribution(out_mem)
return self.distribution.sample()
def act_inference(self, obs: TensorDict) -> torch.Tensor:
obs = self.get_student_obs(obs)
obs = self.student_obs_normalizer(obs)
out_mem = self.memory_s(obs).squeeze(0)
return self.student(out_mem)
def evaluate(self, obs: TensorDict) -> torch.Tensor:
obs = self.get_teacher_obs(obs)
obs = self.teacher_obs_normalizer(obs)
with torch.no_grad():
if self.teacher_recurrent:
self.memory_t.eval()
obs = self.memory_t(obs).squeeze(0)
return self.teacher(obs)
def get_student_obs(self, obs: TensorDict) -> torch.Tensor:
obs_list = [obs[obs_group] for obs_group in self.obs_groups["policy"]]
return torch.cat(obs_list, dim=-1)
def get_teacher_obs(self, obs: TensorDict) -> torch.Tensor:
obs_list = [obs[obs_group] for obs_group in self.obs_groups["teacher"]]
return torch.cat(obs_list, dim=-1)
def get_hidden_states(self) -> tuple[HiddenState, HiddenState]:
if self.teacher_recurrent:
return self.memory_s.hidden_state, self.memory_t.hidden_state
else:
return self.memory_s.hidden_state, None
def detach_hidden_states(self, dones: torch.Tensor | None = None) -> None:
self.memory_s.detach_hidden_state(dones)
if self.teacher_recurrent:
self.memory_t.detach_hidden_state(dones)
def train(self, mode: bool = True) -> None:
super().train(mode)
# Make sure teacher is in eval mode
self.teacher.eval()
self.teacher_obs_normalizer.eval()
def update_normalization(self, obs: TensorDict) -> None:
if self.student_obs_normalization:
student_obs = self.get_student_obs(obs)
self.student_obs_normalizer.update(student_obs)
def load_state_dict(self, state_dict: dict, strict: bool = True) -> bool:
"""Load the parameters of the student and teacher networks.
Args:
state_dict: State dictionary of the model.
strict: Whether to strictly enforce that the keys in `state_dict` match the keys returned by this module's
:meth:`state_dict` function.
Returns:
Whether this training resumes a previous training. This flag is used by the :func:`load` function of
:class:`OnPolicyRunner` to determine how to load further parameters.
"""
# Check if state_dict contains teacher and student or just teacher parameters
if any("actor" in key for key in state_dict): # Load parameters from rl training
# Rename keys to match teacher and remove critic parameters
teacher_state_dict = {}
teacher_obs_normalizer_state_dict = {}
for key, value in state_dict.items():
if "actor." in key:
teacher_state_dict[key.replace("actor.", "")] = value
if "actor_obs_normalizer." in key:
teacher_obs_normalizer_state_dict[key.replace("actor_obs_normalizer.", "")] = value
self.teacher.load_state_dict(teacher_state_dict, strict=strict)
self.teacher_obs_normalizer.load_state_dict(teacher_obs_normalizer_state_dict, strict=strict)
# Also load recurrent memory if teacher is recurrent
if self.teacher_recurrent:
memory_t_state_dict = {}
for key, value in state_dict.items():
if "memory_a." in key:
memory_t_state_dict[key.replace("memory_a.", "")] = value
self.memory_t.load_state_dict(memory_t_state_dict, strict=strict)
# Set flag for successfully loading the parameters
self.loaded_teacher = True
self.teacher.eval()
self.teacher_obs_normalizer.eval()
return False # Training does not resume
elif any("student" in key for key in state_dict): # Load parameters from distillation training
super().load_state_dict(state_dict, strict=strict)
# Set flag for successfully loading the parameters
self.loaded_teacher = True
self.teacher.eval()
self.teacher_obs_normalizer.eval()
return True # Training resumes
else:
raise ValueError("state_dict does not contain student or teacher parameters")

View File

@@ -0,0 +1,27 @@
# Copyright (c) 2021-2026, ETH Zurich and NVIDIA CORPORATION
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
from rsl_rl.env import VecEnv
def resolve_symmetry_config(alg_cfg: dict, env: VecEnv) -> dict:
"""Resolve the symmetry configuration.
Args:
alg_cfg: Algorithm configuration dictionary.
env: Environment object.
Returns:
The resolved algorithm configuration dictionary.
"""
# If using symmetry then pass the environment config object
# Note: This is used by the symmetry function for handling different observation terms
if "symmetry_cfg" in alg_cfg and alg_cfg["symmetry_cfg"] is not None:
alg_cfg["symmetry_cfg"]["_env"] = env
else:
alg_cfg["symmetry_cfg"] = None
return alg_cfg

View File

@@ -0,0 +1,23 @@
# Copyright (c) 2021-2026, ETH Zurich and NVIDIA CORPORATION
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
"""Definitions for components of modules."""
from .cnn import CNN
from .memory import HiddenState, Memory
from .mlp import MLP
from .normalization import EmpiricalDiscountedVariationNormalization, EmpiricalNormalization
from .moe import L2Norm, SimNorm, MoE
__all__ = [
"CNN",
"MLP",
"EmpiricalDiscountedVariationNormalization",
"EmpiricalNormalization",
"HiddenState",
"Memory",
"L2Norm",
"SimNorm",
"MoE",
]

View File

@@ -0,0 +1,192 @@
# Copyright (c) 2021-2026, ETH Zurich and NVIDIA CORPORATION
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
import math
import torch
from torch import nn as nn
from rsl_rl.utils import get_param, resolve_nn_activation
class CNN(nn.Sequential):
"""Convolutional Neural Network (CNN).
The CNN network is a sequence of convolutional layers, optional normalization layers, optional activation functions,
and optional pooling. The final output can be flattened.
"""
def __init__(
self,
input_dim: tuple[int, int],
input_channels: int,
output_channels: tuple[int] | list[int],
kernel_size: int | tuple[int] | list[int],
stride: int | tuple[int] | list[int] = 1,
dilation: int | tuple[int] | list[int] = 1,
padding: str = "none",
norm: str | tuple[str] | list[str] = "none",
activation: str = "elu",
max_pool: bool | tuple[bool] | list[bool] = False,
global_pool: str = "none",
flatten: bool = True,
) -> None:
"""Initialize the CNN.
Args:
input_dim: Height and width of the input.
input_channels: Number of input channels.
output_channels: List of output channels for each convolutional layer.
kernel_size: List of kernel sizes for each convolutional layer or a single kernel size for all layers.
stride: List of strides for each convolutional layer or a single stride for all layers.
dilation: List of dilations for each convolutional layer or a single dilation for all layers.
padding: Padding type to use. Either 'none', 'zeros', 'reflect', 'replicate', or 'circular'.
norm: List of normalization types for each convolutional layer or a single type for all layers. Either
'none', 'batch', or 'layer'.
activation: Activation function to use.
max_pool: List of booleans indicating whether to apply max pooling after each convolutional layer or a
single boolean for all layers.
global_pool: Global pooling type to apply at the end. Either 'none', 'max', or 'avg'.
flatten: Whether to flatten the output tensor.
"""
super().__init__()
# Resolve activation function
activation_function = resolve_nn_activation(activation)
# Create layers sequentially
layers = []
last_channels = input_channels
last_dim = input_dim
for idx in range(len(output_channels)):
# Get parameters for the current layer
k = get_param(kernel_size, idx)
s = get_param(stride, idx)
d = get_param(dilation, idx)
p = (
_compute_padding(last_dim, k, s, d)
if padding in ["zeros", "reflect", "replicate", "circular"]
else (0, 0)
)
# Append convolutional layer
layers.append(
nn.Conv2d(
in_channels=last_channels,
out_channels=output_channels[idx],
kernel_size=k,
stride=s,
padding=p,
dilation=d,
padding_mode=padding if padding in ["zeros", "reflect", "replicate", "circular"] else "zeros",
)
)
# Append normalization layer if specified
n = get_param(norm, idx)
if n == "none":
pass
elif n == "batch":
layers.append(nn.BatchNorm2d(output_channels[idx]))
elif n == "layer":
norm_input_dim = _compute_output_dim(last_dim, k, s, d, p)
layers.append(nn.LayerNorm([output_channels[idx], norm_input_dim[0], norm_input_dim[1]]))
else:
raise ValueError(
f"Unsupported normalization type: {n}. Supported types are 'none', 'batch', and 'layer'."
)
# Append activation function
layers.append(activation_function)
# Apply max pooling if specified
if get_param(max_pool, idx):
layers.append(nn.MaxPool2d(kernel_size=3, stride=2, padding=1))
# Update last channels and dimensions
last_channels = output_channels[idx]
last_dim = _compute_output_dim(last_dim, k, s, d, p, is_max_pool=get_param(max_pool, idx))
# Apply global pooling if specified
if global_pool == "none":
pass
elif global_pool == "max":
layers.append(nn.AdaptiveMaxPool2d((1, 1)))
last_dim = (1, 1)
elif global_pool == "avg":
layers.append(nn.AdaptiveAvgPool2d((1, 1)))
last_dim = (1, 1)
else:
raise ValueError(
f"Unsupported global pooling type: {global_pool}. Supported types are 'none', 'max', and 'avg'."
)
# Apply flattening if specified
if flatten:
layers.append(nn.Flatten(start_dim=1))
# Store final output dimension
self._output_channels = last_channels if not flatten else None
self._output_dim = last_dim if not flatten else last_channels * last_dim[0] * last_dim[1]
# Register the layers
for idx, layer in enumerate(layers):
self.add_module(f"{idx}", layer)
@property
def output_channels(self) -> int | None:
"""Get the number of output channels or None if output is flattened."""
return self._output_channels
@property
def output_dim(self) -> tuple[int, int] | int:
"""Get the output height and width or total output dimension if output is flattened."""
return self._output_dim
def init_weights(self) -> None:
"""Initialize the weights of the CNN with Kaiming initialization."""
for idx, module in enumerate(self):
if isinstance(module, nn.Conv2d):
torch.nn.init.kaiming_normal_(module.weight)
torch.nn.init.zeros_(module.bias)
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""Forward pass of the CNN."""
for layer in self:
x = layer(x)
return x
def _compute_padding(input_hw: tuple[int, int], kernel: int, stride: int, dilation: int) -> tuple[int, int]:
"""Compute the optimal padding for the current layer.
Reference: https://pytorch.org/docs/stable/generated/torch.nn.Conv2d.html
"""
h = math.ceil((stride * math.floor(input_hw[0] / stride) - input_hw[0] - stride + dilation * (kernel - 1) + 1) / 2)
w = math.ceil((stride * math.floor(input_hw[1] / stride) - input_hw[1] - stride + dilation * (kernel - 1) + 1) / 2)
return (h, w)
def _compute_output_dim(
input_hw: tuple[int, int],
kernel: int,
stride: int,
dilation: int,
padding: tuple[int, int],
is_max_pool: bool = False,
) -> tuple[int, int]:
"""Compute the output height and width of the current layer.
Reference: https://pytorch.org/docs/stable/generated/torch.nn.Conv2d.html
"""
h = math.floor((input_hw[0] + 2 * padding[0] - dilation * (kernel - 1) - 1) / stride + 1)
w = math.floor((input_hw[1] + 2 * padding[1] - dilation * (kernel - 1) - 1) / stride + 1)
if is_max_pool:
h = math.ceil(h / 2)
w = math.ceil(w / 2)
return (h, w)

View File

@@ -0,0 +1,81 @@
# Copyright (c) 2021-2026, ETH Zurich and NVIDIA CORPORATION
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
import torch
import torch.nn as nn
from typing import Union
from rsl_rl.utils import unpad_trajectories
HiddenState = Union[torch.Tensor, tuple[torch.Tensor, torch.Tensor], None] # Using Union due to Python <3.10
"""Type alias for the hidden state of RNNs (GRU/LSTM).
For GRUs, this is a single tensor while for LSTMs, this is a tuple of two tensors (hidden state and cell state).
"""
class Memory(nn.Module):
"""Memory network for recurrent architectures.
This network is used to store the hidden state of the policy. It currently supports GRU and LSTM.
"""
def __init__(self, input_size: int, hidden_dim: int = 256, num_layers: int = 1, type: str = "lstm") -> None:
super().__init__()
rnn_cls = nn.GRU if type.lower() == "gru" else nn.LSTM
self.rnn = rnn_cls(input_size=input_size, hidden_size=hidden_dim, num_layers=num_layers)
self.hidden_state = None
def forward(
self,
input: torch.Tensor,
masks: torch.Tensor | None = None,
hidden_state: HiddenState = None,
) -> torch.Tensor:
batch_mode = masks is not None
if batch_mode:
# Batch mode needs saved hidden states
if hidden_state is None:
raise ValueError("Hidden states not passed to memory module during policy update")
out, _ = self.rnn(input, hidden_state)
out = unpad_trajectories(out, masks)
else:
# Inference/distillation mode uses hidden state of last step
out, self.hidden_state = self.rnn(input.unsqueeze(0), self.hidden_state)
return out
def reset(self, dones: torch.Tensor | None = None, hidden_state: HiddenState = None) -> None:
if dones is None: # Reset hidden state
if hidden_state is None:
self.hidden_state = None
else:
self.hidden_state = hidden_state
elif self.hidden_state is not None: # Reset hidden state of done environments
if hidden_state is None:
if isinstance(self.hidden_state, tuple): # Tuple in case of LSTM
for hidden_state in self.hidden_state:
hidden_state[..., dones == 1, :] = 0.0
else:
self.hidden_state[..., dones == 1, :] = 0.0
else:
NotImplementedError(
"Resetting the hidden state of done environments with a custom hidden state is not implemented"
)
def detach_hidden_state(self, dones: torch.Tensor | None = None) -> None:
if self.hidden_state is not None:
if dones is None: # Detach hidden state
if isinstance(self.hidden_state, tuple): # Tuple in case of LSTM
self.hidden_state = tuple(hidden_state.detach() for hidden_state in self.hidden_state)
else:
self.hidden_state = self.hidden_state.detach()
else: # Detach hidden state of done environments
if isinstance(self.hidden_state, tuple): # Tuple in case of LSTM
for hidden_state in self.hidden_state:
hidden_state[..., dones == 1, :] = hidden_state[..., dones == 1, :].detach()
else:
self.hidden_state[..., dones == 1, :] = self.hidden_state[..., dones == 1, :].detach()

View File

@@ -0,0 +1,94 @@
# Copyright (c) 2021-2026, ETH Zurich and NVIDIA CORPORATION
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
import torch
import torch.nn as nn
from functools import reduce
from rsl_rl.utils import get_param, resolve_nn_activation
class MLP(nn.Sequential):
"""Multi-layer perceptron.
The MLP network is a sequence of linear layers and activation functions. The last layer is a linear layer that
outputs the desired dimension unless the last activation function is specified.
It provides additional conveniences:
- If the hidden dimensions have a value of ``-1``, the dimension is inferred from the input dimension.
- If the output dimension is a tuple, the output is reshaped to the desired shape.
"""
def __init__(
self,
input_dim: int,
output_dim: int | tuple[int] | list[int],
hidden_dims: tuple[int] | list[int],
activation: str = "elu",
last_activation: str | None = None,
) -> None:
"""Initialize the MLP.
Args:
input_dim: Dimension of the input.
output_dim: Dimension of the output.
hidden_dims: Dimensions of the hidden layers. A value of ``-1`` indicates that the dimension should be
inferred from the input dimension.
activation: Activation function.
last_activation: Activation function of the last layer. None results in a linear last layer.
"""
super().__init__()
# Resolve activation functions
activation_mod = resolve_nn_activation(activation)
last_activation_mod = resolve_nn_activation(last_activation) if last_activation is not None else None
# Resolve number of hidden dims if they are -1
hidden_dims_processed = [input_dim if dim == -1 else dim for dim in hidden_dims]
# Create layers sequentially
layers = []
layers.append(nn.Linear(input_dim, hidden_dims_processed[0]))
layers.append(activation_mod)
for layer_index in range(len(hidden_dims_processed) - 1):
layers.append(nn.Linear(hidden_dims_processed[layer_index], hidden_dims_processed[layer_index + 1]))
layers.append(activation_mod)
# Add last layer
if isinstance(output_dim, int):
layers.append(nn.Linear(hidden_dims_processed[-1], output_dim))
else:
# Compute the total output dimension
total_out_dim = reduce(lambda x, y: x * y, output_dim)
# Add a layer to reshape the output to the desired shape
layers.append(nn.Linear(hidden_dims_processed[-1], total_out_dim))
layers.append(nn.Unflatten(dim=-1, unflattened_size=output_dim))
# Add last activation function if specified
if last_activation_mod is not None:
layers.append(last_activation_mod)
# Register the layers
for idx, layer in enumerate(layers):
self.add_module(f"{idx}", layer)
def init_weights(self, scales: float | tuple[float]) -> None:
"""Initialize the weights of the MLP.
Args:
scales: Scale factor for the weights.
"""
for idx, module in enumerate(self):
if isinstance(module, nn.Linear):
nn.init.orthogonal_(module.weight, gain=get_param(scales, idx))
nn.init.zeros_(module.bias)
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""Forward pass of the MLP."""
for layer in self:
x = layer(x)
return x

View File

@@ -0,0 +1,170 @@
import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np
class CatELU(nn.Module):
"""
CatELU activation (feature-doubling version).
Applies ELU to the input and its negation,
doubling the feature dimension.
Output: [..., 2 * D] given input [..., D]
NOTE:
This is a structural activation and NOT element-wise.
Assumes the last dimension is the feature dimension.
"""
def __init__(self, inplace: bool = False):
super().__init__()
self.elu = nn.ELU(inplace=inplace)
def forward(self, x: torch.Tensor) -> torch.Tensor:
assert x.dim() >= 2, \
f"CatELU expects feature dimension in the last axis, got shape {x.shape}"
y1 = self.elu(x)
y2 = self.elu(-x)
return torch.cat((y1, y2), dim=-1)
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()
elif act_name == "cat_elu":
return CatELU()
else:
print("invalid activation function!")
return None
class L2Norm(nn.Module):
def __init__(self):
super().__init__()
def forward(self, x):
return F.normalize(x, p=2.0, dim=-1)
class SimNorm(nn.Module):
"""
Simplicial normalization.
Adapted from https://arxiv.org/abs/2204.00616.
"""
def __init__(self):
super().__init__()
self.dim = 8 # for latent dim 512
def forward(self, x):
shp = x.shape
x = x.view(*shp[:-1], -1, self.dim)
x = F.softmax(x, dim=-1)
return x.view(*shp)
def __repr__(self):
return f"SimNorm(dim={self.dim})"
# MLP implementation for MoE
class MLP(nn.Module):
def __init__(self, input_dim, output_dim, hidden_dims, activation='elu', last_activation: str | None = None):
super().__init__()
dims = [input_dim] + hidden_dims
act_func = get_activation(activation)
layers = []
last_dim = dims[0]
for h_dim in dims[1:]:
layers.append(nn.Linear(last_dim, h_dim))
layers.append(act_func)
if activation == 'cat_elu':
last_dim = h_dim * 2
else:
last_dim = h_dim
if isinstance(output_dim, int):
layers.append(nn.Linear(last_dim, output_dim))
elif isinstance(output_dim, tuple) or isinstance(output_dim, list):
layers.append(nn.Linear(last_dim, np.prod(output_dim)))
layers.append(nn.Unflatten(dim=-1, unflattened_size=output_dim))
else:
raise ValueError("output_dim must be int, tuple or list")
if last_activation is not None:
last_act_func = get_activation(last_activation)
layers.append(last_act_func)
self.network = nn.Sequential(*layers)
def forward(self, x):
return self.network(x)
class Experts(nn.Module):
def __init__(self,
expert_num,
input_dim,
backbone_hidden_dims,
expert_hidden_dim,
output_dim,
activation='elu',
):
super().__init__()
self.expert_num = expert_num
self.output_dim = output_dim
self.backbone = MLP(input_dim, expert_num * expert_hidden_dim, backbone_hidden_dims, activation, last_activation=activation)
self.experts = nn.Conv1d(
in_channels=expert_num*expert_hidden_dim if activation != 'cat_elu' else expert_num*expert_hidden_dim*2,
out_channels=expert_num*output_dim,
kernel_size=1,
groups=expert_num,
)
def forward(self, x):
shared_features = self.backbone(x).unsqueeze(-1) # (B, expert_num * expert_hidden_dim, 1)
expert_outs = self.experts(shared_features).squeeze(-1) # (B, expert_num * output_dim)
expert_outs = expert_outs.reshape(-1, self.expert_num, self.output_dim)
return expert_outs
class MoE(nn.Module):
def __init__(self,
expert_num,
input_dim,
hidden_dims,
output_dim,
activation='elu',
):
super().__init__()
# Expert networks
self.experts = Experts(
expert_num=expert_num,
input_dim=input_dim,
backbone_hidden_dims=hidden_dims[:-1],
expert_hidden_dim=hidden_dims[-1],
output_dim=output_dim,
activation=activation,
)
# Gating network
self.gating_network = nn.Sequential(
MLP(input_dim, expert_num, hidden_dims, activation),
nn.Softmax(dim=-1)
)
def forward(self, x):
weights = self.gating_network(x) # (B, expert_num)
expert_outs = self.experts(x) # (B, expert_num, output_dim)
output = torch.sum(weights.unsqueeze(-1) * expert_outs, dim=1) # (B, output_dim)
return output, weights

View File

@@ -0,0 +1,120 @@
# Copyright (c) 2021-2026, ETH Zurich and NVIDIA CORPORATION
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2020 Preferred Networks, Inc.
from __future__ import annotations
import torch
from torch import nn
class EmpiricalNormalization(nn.Module):
"""Normalize mean and variance of values based on empirical values."""
def __init__(self, shape: int | tuple[int] | list[int], eps: float = 1e-2, until: int | None = None) -> None:
"""Initialize EmpiricalNormalization module.
.. note:: The normalization parameters are computed over the whole batch, not for each environment separately.
Args:
shape: Shape of input values except batch axis.
eps: Small value for stability.
until: If this arg is specified, the module learns input values until the sum of batch sizes exceeds it.
"""
super().__init__()
self.eps = eps
self.until = until
self.register_buffer("_mean", torch.zeros(shape).unsqueeze(0))
self.register_buffer("_var", torch.ones(shape).unsqueeze(0))
self.register_buffer("_std", torch.ones(shape).unsqueeze(0))
self.register_buffer("count", torch.tensor(0, dtype=torch.long))
@property
def mean(self) -> torch.Tensor:
return self._mean.squeeze(0).clone()
@property
def std(self) -> torch.Tensor:
return self._std.squeeze(0).clone()
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""Normalize mean and variance of values based on empirical values."""
return (x - self._mean) / (self._std + self.eps)
@torch.jit.unused
def update(self, x: torch.Tensor) -> None:
"""Learn input values without computing the output values of them."""
if not self.training:
return
if self.until is not None and self.count >= self.until:
return
count_x = x.shape[0]
self.count += count_x
rate = count_x / self.count
var_x = torch.var(x, dim=0, unbiased=False, keepdim=True)
mean_x = torch.mean(x, dim=0, keepdim=True)
delta_mean = mean_x - self._mean
self._mean += rate * delta_mean
self._var += rate * (var_x - self._var + delta_mean * (mean_x - self._mean))
self._std = torch.sqrt(self._var)
@torch.jit.unused
def inverse(self, y: torch.Tensor) -> torch.Tensor:
"""De-normalize values based on empirical values."""
return y * (self._std + self.eps) + self._mean
class EmpiricalDiscountedVariationNormalization(nn.Module):
"""Reward normalization from Pathak's large scale study on PPO.
Reward normalization. Since the reward function is non-stationary, it is useful to normalize the scale of the
rewards so that the value function can learn quickly. We did this by dividing the rewards by a running estimate of
the standard deviation of the sum of discounted rewards.
"""
def __init__(
self, shape: int | tuple[int] | list[int], eps: float = 1e-2, gamma: float = 0.99, until: int | None = None
) -> None:
super().__init__()
self.emp_norm = EmpiricalNormalization(shape, eps, until)
self.disc_avg = _DiscountedAverage(gamma)
def forward(self, rew: torch.Tensor) -> torch.Tensor:
if self.training:
# Update discounted rewards
avg = self.disc_avg.update(rew)
# Update moments from discounted rewards
self.emp_norm.update(avg)
# Normalize rewards with the empirical std
if self.emp_norm._std > 0:
return rew / self.emp_norm._std
else:
return rew
class _DiscountedAverage:
r"""Discounted average of rewards.
The discounted average is defined as:
.. math::
\bar{R}_t = \gamma \bar{R}_{t-1} + r_t
"""
def __init__(self, gamma: float) -> None:
self.avg = None
self.gamma = gamma
def update(self, rew: torch.Tensor) -> torch.Tensor:
if self.avg is None:
self.avg = rew
else:
self.avg = self.avg * self.gamma + rew
return self.avg

View File

@@ -0,0 +1,12 @@
# Copyright (c) 2021-2026, ETH Zurich and NVIDIA CORPORATION
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
"""Implementation of runners for environment-agent interaction."""
from .on_policy_runner import OnPolicyRunner # noqa: I001
from .distillation_runner import DistillationRunner
from .on_policy_runner_cts import OnPolicyRunnerCTS
__all__ = ["DistillationRunner", "OnPolicyRunner", "OnPolicyRunnerCTS"]

View File

@@ -0,0 +1,57 @@
# Copyright (c) 2021-2026, ETH Zurich and NVIDIA CORPORATION
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
from tensordict import TensorDict
from rsl_rl.algorithms import Distillation
from rsl_rl.modules import StudentTeacher, StudentTeacherRecurrent
from rsl_rl.runners import OnPolicyRunner
from rsl_rl.storage import RolloutStorage
from rsl_rl.utils import resolve_callable
class DistillationRunner(OnPolicyRunner):
"""Distillation runner for training and evaluation of teacher-student methods."""
def learn(self, num_learning_iterations: int, init_at_random_ep_len: bool = False) -> None:
# Check if teacher is loaded
if not self.alg.policy.loaded_teacher:
raise ValueError("Teacher model parameters not loaded. Please load a teacher model to distill.")
super().learn(num_learning_iterations, init_at_random_ep_len)
def _get_default_obs_sets(self) -> list[str]:
"""Get the the default observation sets required for the algorithm.
.. note::
See :func:`resolve_obs_groups` for more details on the handling of observation sets.
"""
return ["teacher"]
def _construct_algorithm(self, obs: TensorDict) -> Distillation:
"""Construct the distillation algorithm."""
# Initialize the policy
student_teacher_class = resolve_callable(self.policy_cfg.pop("class_name"))
student_teacher: StudentTeacher | StudentTeacherRecurrent = student_teacher_class(
obs, self.cfg["obs_groups"], self.env.num_actions, **self.policy_cfg
).to(self.device)
# Initialize the storage
storage = RolloutStorage(
"distillation", self.env.num_envs, self.cfg["num_steps_per_env"], obs, [self.env.num_actions], self.device
)
# Initialize the algorithm
alg_class = resolve_callable(self.alg_cfg.pop("class_name"))
alg: Distillation = alg_class(
student_teacher, storage, device=self.device, **self.alg_cfg, multi_gpu_cfg=self.multi_gpu_cfg
)
# Set RND configuration to None as it does not apply to distillation
self.cfg["algorithm"]["rnd_cfg"] = None
return alg

View File

@@ -0,0 +1,286 @@
# Copyright (c) 2021-2026, ETH Zurich and NVIDIA CORPORATION
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
import os
import time
import torch
import warnings
from tensordict import TensorDict
from rsl_rl.algorithms import PPO
from rsl_rl.env import VecEnv
from rsl_rl.modules import (
ActorCritic,
ActorCriticCNN,
ActorCriticRecurrent,
resolve_rnd_config,
resolve_symmetry_config,
)
from rsl_rl.storage import RolloutStorage
from rsl_rl.utils import resolve_callable, resolve_obs_groups
from rsl_rl.utils.logger import Logger
class OnPolicyRunner:
"""On-policy runner for training and evaluation of actor-critic methods."""
def __init__(self, env: VecEnv, train_cfg: dict, log_dir: str | None = None, device: str = "cpu") -> None:
self.cfg = train_cfg
self.policy_cfg = train_cfg["policy"]
self.alg_cfg = train_cfg["algorithm"]
self.device = device
self.env = env
# Setup multi-GPU training if enabled
self._configure_multi_gpu()
# Query observations from environment for algorithm construction
obs = self.env.get_observations()
self.cfg["obs_groups"] = resolve_obs_groups(obs, self.cfg["obs_groups"], self._get_default_obs_sets())
# Create the algorithm
self.alg = self._construct_algorithm(obs)
# Create the logger
self.logger = Logger(
log_dir=log_dir,
cfg=self.cfg,
env_cfg=self.env.cfg,
num_envs=self.env.num_envs,
is_distributed=self.is_distributed,
gpu_world_size=self.gpu_world_size,
gpu_global_rank=self.gpu_global_rank,
device=self.device,
)
self.current_learning_iteration = 0
def learn(self, num_learning_iterations: int, init_at_random_ep_len: bool = False) -> None:
# Randomize initial episode lengths (for exploration)
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)
)
# Start learning
obs = self.env.get_observations().to(self.device)
self.train_mode() # switch to train mode (for dropout for example)
# Ensure all parameters are in-synced
if self.is_distributed:
print(f"Synchronizing parameters for rank {self.gpu_global_rank}...")
self.alg.broadcast_parameters()
# Start training
start_it = self.current_learning_iteration
total_it = start_it + num_learning_iterations
for it in range(start_it, total_it):
start = time.time()
# Rollout
with torch.inference_mode():
for _ in range(self.cfg["num_steps_per_env"]):
# Sample actions
actions = self.alg.act(obs)
# Step the environment
obs, rewards, dones, extras = self.env.step(actions.to(self.env.device))
# Move to device
obs, rewards, dones = (obs.to(self.device), rewards.to(self.device), dones.to(self.device))
# Process the step
self.alg.process_env_step(obs, rewards, dones, extras)
# Extract intrinsic rewards (only for logging)
intrinsic_rewards = self.alg.intrinsic_rewards if self.alg_cfg["rnd_cfg"] else None
# Book keeping
self.logger.process_env_step(rewards, dones, extras, intrinsic_rewards)
stop = time.time()
collect_time = stop - start
start = stop
# Compute returns
self.alg.compute_returns(obs)
# Update policy
loss_dict = self.alg.update()
stop = time.time()
learn_time = stop - start
self.current_learning_iteration = it
# Log information
self.logger.log(
it=it,
start_it=start_it,
total_it=total_it,
collect_time=collect_time,
learn_time=learn_time,
loss_dict=loss_dict,
learning_rate=self.alg.learning_rate,
action_std=self.alg.policy.action_std,
rnd_weight=self.alg.rnd.weight if self.alg_cfg["rnd_cfg"] else None,
)
# Save model
if it % self.cfg["save_interval"] == 0:
self.save(os.path.join(self.logger.log_dir, f"model_{it}.pt")) # type: ignore
# Save the final model after training
if self.logger.log_dir is not None and not self.logger.disable_logs:
self.save(os.path.join(self.logger.log_dir, f"model_{self.current_learning_iteration}.pt"))
def save(self, path: str, infos: dict | None = None) -> None:
# Save model
saved_dict = {
"model_state_dict": self.alg.policy.state_dict(),
"optimizer_state_dict": self.alg.optimizer.state_dict(),
"iter": self.current_learning_iteration,
"infos": infos,
}
# Save RND model if used
if self.alg_cfg["rnd_cfg"]:
saved_dict["rnd_state_dict"] = self.alg.rnd.state_dict()
if self.alg.rnd_optimizer:
saved_dict["rnd_optimizer_state_dict"] = self.alg.rnd_optimizer.state_dict()
torch.save(saved_dict, path)
# Upload model to external logging services
self.logger.save_model(path, self.current_learning_iteration)
def load(self, path: str, load_optimizer: bool = True, map_location: str | None = None) -> dict:
loaded_dict = torch.load(path, weights_only=False, map_location=map_location)
# Load model
resumed_training = self.alg.policy.load_state_dict(loaded_dict["model_state_dict"])
# Load RND model if used
if self.alg_cfg["rnd_cfg"]:
self.alg.rnd.load_state_dict(loaded_dict["rnd_state_dict"])
# Load optimizer if used
if load_optimizer and resumed_training:
# Algorithm optimizer
self.alg.optimizer.load_state_dict(loaded_dict["optimizer_state_dict"])
# RND optimizer if used
if self.alg_cfg["rnd_cfg"]:
self.alg.rnd_optimizer.load_state_dict(loaded_dict["rnd_optimizer_state_dict"])
# Load current learning iteration
if resumed_training:
self.current_learning_iteration = loaded_dict["iter"]
return loaded_dict["infos"]
def get_inference_policy(self, device: str | None = None) -> callable:
self.eval_mode() # Switch to evaluation mode (e.g. for dropout)
if device is not None:
self.alg.policy.to(device)
return self.alg.policy.act_inference
def train_mode(self) -> None:
# PPO
self.alg.policy.train()
# RND
if self.alg_cfg["rnd_cfg"]:
self.alg.rnd.train()
def eval_mode(self) -> None:
# PPO
self.alg.policy.eval()
# RND
if self.alg_cfg["rnd_cfg"]:
self.alg.rnd.eval()
def add_git_repo_to_log(self, repo_file_path: str) -> None:
self.logger.git_status_repos.append(repo_file_path)
def _get_default_obs_sets(self) -> list[str]:
"""Get the the default observation sets required for the algorithm.
.. note::
See :func:`resolve_obs_groups` for more details on the handling of observation sets.
"""
default_sets = ["critic"]
if "rnd_cfg" in self.alg_cfg and self.alg_cfg["rnd_cfg"] is not None:
default_sets.append("rnd_state")
return default_sets
def _configure_multi_gpu(self) -> None:
"""Configure multi-gpu training."""
# Check if distributed training is enabled
self.gpu_world_size = int(os.getenv("WORLD_SIZE", "1"))
self.is_distributed = self.gpu_world_size > 1
# If not distributed training, set local and global rank to 0 and return
if not self.is_distributed:
self.gpu_local_rank = 0
self.gpu_global_rank = 0
self.multi_gpu_cfg = None
return
# Get rank and world size
self.gpu_local_rank = int(os.getenv("LOCAL_RANK", "0"))
self.gpu_global_rank = int(os.getenv("RANK", "0"))
# Make a configuration dictionary
self.multi_gpu_cfg = {
"global_rank": self.gpu_global_rank, # Rank of the main process
"local_rank": self.gpu_local_rank, # Rank of the current process
"world_size": self.gpu_world_size, # Total number of processes
}
# Check if user has device specified for local rank
if self.device != f"cuda:{self.gpu_local_rank}":
raise ValueError(
f"Device '{self.device}' does not match expected device for local rank '{self.gpu_local_rank}'."
)
# Validate multi-GPU configuration
if self.gpu_local_rank >= self.gpu_world_size:
raise ValueError(
f"Local rank '{self.gpu_local_rank}' is greater than or equal to world size '{self.gpu_world_size}'."
)
if self.gpu_global_rank >= self.gpu_world_size:
raise ValueError(
f"Global rank '{self.gpu_global_rank}' is greater than or equal to world size '{self.gpu_world_size}'."
)
# Initialize torch distributed
torch.distributed.init_process_group(backend="nccl", rank=self.gpu_global_rank, world_size=self.gpu_world_size)
# Set device to the local rank
torch.cuda.set_device(self.gpu_local_rank)
def _construct_algorithm(self, obs: TensorDict) -> PPO:
"""Construct the actor-critic algorithm."""
# Resolve RND config if used
self.alg_cfg = resolve_rnd_config(self.alg_cfg, obs, self.cfg["obs_groups"], self.env)
# Resolve symmetry config if used
self.alg_cfg = resolve_symmetry_config(self.alg_cfg, self.env)
# Resolve deprecated normalization config
if self.cfg.get("empirical_normalization") is not None:
warnings.warn(
"The `empirical_normalization` parameter is deprecated. Please set `actor_obs_normalization` and "
"`critic_obs_normalization` as part of the `policy` configuration instead.",
DeprecationWarning,
)
if self.policy_cfg.get("actor_obs_normalization") is None:
self.policy_cfg["actor_obs_normalization"] = self.cfg["empirical_normalization"]
if self.policy_cfg.get("critic_obs_normalization") is None:
self.policy_cfg["critic_obs_normalization"] = self.cfg["empirical_normalization"]
# Initialize the policy
actor_critic_class = resolve_callable(self.policy_cfg.pop("class_name"))
actor_critic: ActorCritic | ActorCriticRecurrent | ActorCriticCNN = actor_critic_class(
obs, self.cfg["obs_groups"], self.env.num_actions, **self.policy_cfg
).to(self.device)
# Initialize the storage
storage = RolloutStorage(
"rl", self.env.num_envs, self.cfg["num_steps_per_env"], obs, [self.env.num_actions], self.device
)
# Initialize the algorithm
alg_class = resolve_callable(self.alg_cfg.pop("class_name"))
alg: PPO = alg_class(
actor_critic, storage, device=self.device, **self.alg_cfg, multi_gpu_cfg=self.multi_gpu_cfg
)
return alg

View File

@@ -0,0 +1,290 @@
# Copyright (c) 2021-2026, ETH Zurich and NVIDIA CORPORATION
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
import os
import time
import torch
import warnings
from tensordict import TensorDict
from rsl_rl.algorithms import MoECTS
from rsl_rl.env import VecEnv
from rsl_rl.modules import (
ActorCriticMoECTS,
resolve_rnd_config,
resolve_symmetry_config,
)
from rsl_rl.storage import RolloutStorageCTS
from rsl_rl.utils import resolve_callable, resolve_obs_groups
from rsl_rl.utils.logger_cts import LoggerCTS
class OnPolicyRunnerCTS:
"""On-policy runner for training and evaluation of actor-critic methods."""
def __init__(self, env: VecEnv, train_cfg: dict, log_dir: str | None = None, device: str = "cpu") -> None:
self.cfg = train_cfg
self.policy_cfg = train_cfg["policy"]
self.alg_cfg = train_cfg["algorithm"]
self.device = device
self.env = env
# Setup multi-GPU training if enabled
self._configure_multi_gpu()
# Query observations from environment for algorithm construction
obs = self.env.get_observations()
self.cfg["obs_groups"] = resolve_obs_groups(obs, self.cfg["obs_groups"], self._get_default_obs_sets())
# Create the algorithm
self.alg = self._construct_algorithm(obs)
# Create the logger
self.logger = LoggerCTS(
log_dir=log_dir,
cfg=self.cfg,
env_cfg=self.env.cfg,
num_envs=self.env.num_envs,
is_distributed=self.is_distributed,
gpu_world_size=self.gpu_world_size,
gpu_global_rank=self.gpu_global_rank,
teacher_env_idxs=self.alg.teacher_env_idxs,
device=self.device,
)
self.current_learning_iteration = 0
def learn(self, num_learning_iterations: int, init_at_random_ep_len: bool = False) -> None:
# Randomize initial episode lengths (for exploration)
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)
)
# Start learning
obs = self.env.get_observations().to(self.device)
self.train_mode() # switch to train mode (for dropout for example)
# Ensure all parameters are in-synced
if self.is_distributed:
print(f"Synchronizing parameters for rank {self.gpu_global_rank}...")
self.alg.broadcast_parameters()
# Start training
start_it = self.current_learning_iteration
total_it = start_it + num_learning_iterations
for it in range(start_it, total_it):
start = time.time()
# Rollout
with torch.inference_mode():
for _ in range(self.cfg["num_steps_per_env"]):
# Sample actions
actions = self.alg.act(obs)
# Step the environment
obs, rewards, dones, extras = self.env.step(actions.to(self.env.device))
# Move to device
obs, rewards, dones = (obs.to(self.device), rewards.to(self.device), dones.to(self.device))
# Process the step
self.alg.process_env_step(obs, rewards, dones, extras)
# Extract intrinsic rewards (only for logging)
intrinsic_rewards = self.alg.intrinsic_rewards if self.alg_cfg["rnd_cfg"] else None
# Book keeping
self.logger.process_env_step(rewards, dones, extras, intrinsic_rewards)
stop = time.time()
collect_time = stop - start
start = stop
# Compute returns
self.alg.compute_returns(obs)
# Update policy
loss_dict = self.alg.update()
stop = time.time()
learn_time = stop - start
self.current_learning_iteration = it
# Log information
self.logger.log(
it=it,
start_it=start_it,
total_it=total_it,
collect_time=collect_time,
learn_time=learn_time,
loss_dict=loss_dict,
learning_rate=self.alg.learning_rate,
action_std=self.alg.policy.action_std,
rnd_weight=self.alg.rnd.weight if self.alg_cfg["rnd_cfg"] else None,
)
# Save model
if it % self.cfg["save_interval"] == 0:
self.save(os.path.join(self.logger.log_dir, f"model_{it}.pt")) # type: ignore
# Save the final model after training
if self.logger.log_dir is not None and not self.logger.disable_logs:
self.save(os.path.join(self.logger.log_dir, f"model_{self.current_learning_iteration}.pt"))
def save(self, path: str, infos: dict | None = None) -> None:
# Save model
saved_dict = {
"model_state_dict": self.alg.policy.state_dict(),
"optimizer_state_dict": self.alg.optimizer.state_dict(),
"optimizer_stu_enc_state_dict": self.alg.optimizer_stu_enc.state_dict(),
"iter": self.current_learning_iteration,
"infos": infos,
}
# Save RND model if used
if self.alg_cfg["rnd_cfg"]:
saved_dict["rnd_state_dict"] = self.alg.rnd.state_dict()
if self.alg.rnd_optimizer:
saved_dict["rnd_optimizer_state_dict"] = self.alg.rnd_optimizer.state_dict()
torch.save(saved_dict, path)
# Upload model to external logging services
self.logger.save_model(path, self.current_learning_iteration)
def load(self, path: str, load_optimizer: bool = True, map_location: str | None = None) -> dict:
loaded_dict = torch.load(path, weights_only=False, map_location=map_location)
# Load model
resumed_training = self.alg.policy.load_state_dict(loaded_dict["model_state_dict"])
# Load RND model if used
if self.alg_cfg["rnd_cfg"]:
self.alg.rnd.load_state_dict(loaded_dict["rnd_state_dict"])
# Load optimizer if used
if load_optimizer and resumed_training:
# Algorithm optimizer
self.alg.optimizer.load_state_dict(loaded_dict["optimizer_state_dict"])
# Student encoder optimizer
self.alg.optimizer_stu_enc.load_state_dict(loaded_dict["optimizer_stu_enc_state_dict"])
# RND optimizer if used
if self.alg_cfg["rnd_cfg"]:
self.alg.rnd_optimizer.load_state_dict(loaded_dict["rnd_optimizer_state_dict"])
# Load current learning iteration
if resumed_training:
self.current_learning_iteration = loaded_dict["iter"]
return loaded_dict["infos"]
def get_inference_policy(self, device: str | None = None) -> callable:
self.eval_mode() # Switch to evaluation mode (e.g. for dropout)
if device is not None:
self.alg.policy.to(device)
return self.alg.policy.act_inference
def train_mode(self) -> None:
# PPO
self.alg.policy.train()
# RND
if self.alg_cfg["rnd_cfg"]:
self.alg.rnd.train()
def eval_mode(self) -> None:
# PPO
self.alg.policy.eval()
# RND
if self.alg_cfg["rnd_cfg"]:
self.alg.rnd.eval()
def add_git_repo_to_log(self, repo_file_path: str) -> None:
self.logger.git_status_repos.append(repo_file_path)
def _get_default_obs_sets(self) -> list[str]:
"""Get the the default observation sets required for the algorithm.
.. note::
See :func:`resolve_obs_groups` for more details on the handling of observation sets.
"""
default_sets = ["critic"]
if "rnd_cfg" in self.alg_cfg and self.alg_cfg["rnd_cfg"] is not None:
default_sets.append("rnd_state")
return default_sets
def _configure_multi_gpu(self) -> None:
"""Configure multi-gpu training."""
# Check if distributed training is enabled
self.gpu_world_size = int(os.getenv("WORLD_SIZE", "1"))
self.is_distributed = self.gpu_world_size > 1
# If not distributed training, set local and global rank to 0 and return
if not self.is_distributed:
self.gpu_local_rank = 0
self.gpu_global_rank = 0
self.multi_gpu_cfg = None
return
# Get rank and world size
self.gpu_local_rank = int(os.getenv("LOCAL_RANK", "0"))
self.gpu_global_rank = int(os.getenv("RANK", "0"))
# Make a configuration dictionary
self.multi_gpu_cfg = {
"global_rank": self.gpu_global_rank, # Rank of the main process
"local_rank": self.gpu_local_rank, # Rank of the current process
"world_size": self.gpu_world_size, # Total number of processes
}
# Check if user has device specified for local rank
if self.device != f"cuda:{self.gpu_local_rank}":
raise ValueError(
f"Device '{self.device}' does not match expected device for local rank '{self.gpu_local_rank}'."
)
# Validate multi-GPU configuration
if self.gpu_local_rank >= self.gpu_world_size:
raise ValueError(
f"Local rank '{self.gpu_local_rank}' is greater than or equal to world size '{self.gpu_world_size}'."
)
if self.gpu_global_rank >= self.gpu_world_size:
raise ValueError(
f"Global rank '{self.gpu_global_rank}' is greater than or equal to world size '{self.gpu_world_size}'."
)
# Initialize torch distributed
torch.distributed.init_process_group(backend="nccl", rank=self.gpu_global_rank, world_size=self.gpu_world_size)
# Set device to the local rank
torch.cuda.set_device(self.gpu_local_rank)
def _construct_algorithm(self, obs: TensorDict) -> MoECTS:
"""Construct the actor-critic algorithm."""
# Resolve RND config if used
self.alg_cfg = resolve_rnd_config(self.alg_cfg, obs, self.cfg["obs_groups"], self.env)
# Resolve symmetry config if used
self.alg_cfg = resolve_symmetry_config(self.alg_cfg, self.env)
# Resolve deprecated normalization config
if self.cfg.get("empirical_normalization") is not None:
warnings.warn(
"The `empirical_normalization` parameter is deprecated. Please set `actor_obs_normalization` and "
"`critic_obs_normalization` as part of the `policy` configuration instead.",
DeprecationWarning,
)
if self.policy_cfg.get("actor_obs_normalization") is None:
self.policy_cfg["actor_obs_normalization"] = self.cfg["empirical_normalization"]
if self.policy_cfg.get("critic_obs_normalization") is None:
self.policy_cfg["critic_obs_normalization"] = self.cfg["empirical_normalization"]
# Initialize the policy
# actor_critic_class = resolve_callable(self.policy_cfg.pop("class_name"))
actor_critic_class = eval(self.policy_cfg.pop("class_name")) # temporally use eval to avoid import bugs
actor_critic: ActorCriticMoECTS = actor_critic_class(
obs, self.cfg["obs_groups"], self.env.num_actions, **self.policy_cfg
).to(self.device)
# Initialize the storage
storage = RolloutStorageCTS(
"rl", self.env.num_envs, max(int(self.env.num_envs*self.alg_cfg["teacher_env_ratio"]), 1), self.cfg["num_steps_per_env"], obs, [self.env.num_actions], self.device
)
# Initialize the algorithm
# alg_class = resolve_callable(self.alg_cfg.pop("class_name"))
alg_class = eval(self.alg_cfg.pop("class_name")) # temporally use eval to avoid import bugs
alg: MoECTS = alg_class(
actor_critic, storage, self.env.num_envs, device=self.device, **self.alg_cfg, multi_gpu_cfg=self.multi_gpu_cfg
)
return alg

View File

@@ -0,0 +1,11 @@
# Copyright (c) 2021-2026, ETH Zurich and NVIDIA CORPORATION
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
"""Implementation of transitions storage for RL-agent."""
from .rollout_storage import RolloutStorage
from .rollout_storage_cts import RolloutStorageCTS
__all__ = ["RolloutStorage", "RolloutStorageCTS"]

View File

@@ -0,0 +1,276 @@
# Copyright (c) 2021-2026, ETH Zurich and NVIDIA CORPORATION
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
import torch
from collections.abc import Generator
from tensordict import TensorDict
from rsl_rl.networks import HiddenState
from rsl_rl.utils import split_and_pad_trajectories
class RolloutStorage:
"""Storage for the data collected during a rollout.
The rollout storage is populated by adding transitions during the rollout phase. It then returns a generator for
learning, depending on the algorithm and the policy architecture.
"""
class Transition:
"""Storage for a single state transition."""
def __init__(self) -> None:
self.observations: TensorDict | None = None
self.actions: torch.Tensor | None = None
self.privileged_actions: torch.Tensor | None = None
self.rewards: torch.Tensor | None = None
self.dones: torch.Tensor | None = None
self.values: torch.Tensor | None = None
self.actions_log_prob: torch.Tensor
self.action_mean: torch.Tensor | None = None
self.action_sigma: torch.Tensor | None = None
self.hidden_states: tuple[HiddenState, HiddenState] = (None, None)
def clear(self) -> None:
self.__init__()
def __init__(
self,
training_type: str,
num_envs: int,
num_transitions_per_env: int,
obs: TensorDict,
actions_shape: tuple[int] | list[int],
device: str = "cpu",
) -> None:
self.training_type = training_type
self.device = device
self.num_transitions_per_env = num_transitions_per_env
self.num_envs = num_envs
self.actions_shape = actions_shape
# Core
self.observations = TensorDict(
{key: torch.zeros(num_transitions_per_env, *value.shape, device=device) for key, value in obs.items()},
batch_size=[num_transitions_per_env, num_envs],
device=self.device,
)
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 distillation
if training_type == "distillation":
self.privileged_actions = torch.zeros(num_transitions_per_env, num_envs, *actions_shape, device=self.device)
# For reinforcement learning
if training_type == "rl":
self.values = torch.zeros(num_transitions_per_env, num_envs, 1, device=self.device)
self.actions_log_prob = 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.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)
# For RNN networks
self.saved_hidden_state_a = None
self.saved_hidden_state_c = None
# Counter for the number of transitions stored
self.step = 0
def add_transition(self, transition: Transition) -> None:
# Check if the transition is valid
if self.step >= self.num_transitions_per_env:
raise OverflowError("Rollout buffer overflow! You should call clear() before adding new transitions.")
# Core
self.observations[self.step].copy_(transition.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))
# For distillation
if self.training_type == "distillation":
self.privileged_actions[self.step].copy_(transition.privileged_actions)
# For reinforcement learning
if self.training_type == "rl":
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)
# For RNN networks
self._save_hidden_states(transition.hidden_states)
# Increment the counter
self.step += 1
def clear(self) -> None:
self.step = 0
# For distillation
def generator(self) -> Generator:
if self.training_type != "distillation":
raise ValueError("This function is only available for distillation training.")
for i in range(self.num_transitions_per_env):
yield self.observations[i], self.actions[i], self.privileged_actions[i], self.dones[i]
# For reinforcement learning with feedforward networks
def mini_batch_generator(self, num_mini_batches: int, num_epochs: int = 8) -> Generator:
if self.training_type != "rl":
raise ValueError("This function is only available for reinforcement learning training.")
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)
# Core
observations = self.observations.flatten(0, 1)
actions = self.actions.flatten(0, 1)
values = self.values.flatten(0, 1)
returns = self.returns.flatten(0, 1)
# For PPO
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):
# Select the indices for the mini-batch
start = i * mini_batch_size
stop = (i + 1) * mini_batch_size
batch_idx = indices[start:stop]
# Create the mini-batch
obs_batch = 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]
hidden_state_a_batch = None
hidden_state_c_batch = None
masks_batch = None
# Yield the mini-batch
yield (
obs_batch,
actions_batch,
target_values_batch,
advantages_batch,
returns_batch,
old_actions_log_prob_batch,
old_mu_batch,
old_sigma_batch,
(
hidden_state_a_batch,
hidden_state_c_batch,
),
masks_batch,
)
# For reinforcement learning with recurrent networks
def recurrent_mini_batch_generator(self, num_mini_batches: int, num_epochs: int = 8) -> Generator:
if self.training_type != "rl":
raise ValueError("This function is only available for reinforcement learning training.")
padded_obs_trajectories, trajectory_masks = split_and_pad_trajectories(self.observations, self.dones)
mini_batch_size = self.num_envs // num_mini_batches
for ep in range(num_epochs):
first_traj = 0
for i in range(num_mini_batches):
start = i * mini_batch_size
stop = (i + 1) * mini_batch_size
dones = self.dones.squeeze(-1)
last_was_done = torch.zeros_like(dones, dtype=torch.bool)
last_was_done[1:] = dones[:-1]
last_was_done[0] = True
trajectories_batch_size = torch.sum(last_was_done[:, start:stop])
last_traj = first_traj + trajectories_batch_size
masks_batch = trajectory_masks[:, first_traj:last_traj]
obs_batch = padded_obs_trajectories[:, first_traj:last_traj]
actions_batch = self.actions[:, start:stop]
old_mu_batch = self.mu[:, start:stop]
old_sigma_batch = self.sigma[:, start:stop]
returns_batch = self.returns[:, start:stop]
advantages_batch = self.advantages[:, start:stop]
values_batch = self.values[:, start:stop]
old_actions_log_prob_batch = self.actions_log_prob[:, start:stop]
# Reshape to [num_envs, time, num layers, hidden dim]
# Original shape: [time, num_layers, num_envs, hidden_dim])
last_was_done = last_was_done.permute(1, 0)
# Take only time steps after dones (flattens num envs and time dimensions),
# take a batch of trajectories and finally reshape back to [num_layers, batch, hidden_dim]
hidden_state_a_batch = [
saved_hidden_state.permute(2, 0, 1, 3)[last_was_done][first_traj:last_traj]
.transpose(1, 0)
.contiguous()
for saved_hidden_state in self.saved_hidden_state_a
]
hidden_state_c_batch = [
saved_hidden_state.permute(2, 0, 1, 3)[last_was_done][first_traj:last_traj]
.transpose(1, 0)
.contiguous()
for saved_hidden_state in self.saved_hidden_state_c
]
# Remove the tuple for GRU
hidden_state_a_batch = (
hidden_state_a_batch[0] if len(hidden_state_a_batch) == 1 else hidden_state_a_batch
)
hidden_state_c_batch = (
hidden_state_c_batch[0] if len(hidden_state_c_batch) == 1 else hidden_state_c_batch
)
# Yield the mini-batch
yield (
obs_batch,
actions_batch,
values_batch,
advantages_batch,
returns_batch,
old_actions_log_prob_batch,
old_mu_batch,
old_sigma_batch,
(
hidden_state_a_batch,
hidden_state_c_batch,
),
masks_batch,
)
first_traj = last_traj
def _save_hidden_states(self, hidden_states: tuple[HiddenState, HiddenState]) -> None:
if hidden_states == (None, None):
return
# Make a tuple out of GRU hidden states to match the LSTM format
hidden_state_a = hidden_states[0] if isinstance(hidden_states[0], tuple) else (hidden_states[0],)
hidden_state_c = hidden_states[1] if isinstance(hidden_states[1], tuple) else (hidden_states[1],)
# Initialize hidden states if needed
if self.saved_hidden_state_a is None:
self.saved_hidden_state_a = [
torch.zeros(self.observations.shape[0], *hidden_state_a[i].shape, device=self.device)
for i in range(len(hidden_state_a))
]
self.saved_hidden_state_c = [
torch.zeros(self.observations.shape[0], *hidden_state_c[i].shape, device=self.device)
for i in range(len(hidden_state_c))
]
# Copy the states
for i in range(len(hidden_state_a)):
self.saved_hidden_state_a[i][self.step].copy_(hidden_state_a[i])
self.saved_hidden_state_c[i][self.step].copy_(hidden_state_c[i])

View File

@@ -0,0 +1,207 @@
# Copyright (c) 2021-2026, ETH Zurich and NVIDIA CORPORATION
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
import torch
from collections.abc import Generator
from tensordict import TensorDict
from rsl_rl.networks import HiddenState
from rsl_rl.utils import split_and_pad_trajectories
from functools import partial
class RolloutStorageCTS:
"""Storage for the data collected during a rollout.
The rollout storage is populated by adding transitions during the rollout phase. It then returns a generator for
learning, depending on the algorithm and the policy architecture.
"""
class Transition:
"""Storage for a single state transition."""
def __init__(self) -> None:
self.observations: TensorDict | None = None
self.actions: torch.Tensor | None = None
self.privileged_actions: torch.Tensor | None = None
self.rewards: torch.Tensor | None = None
self.dones: torch.Tensor | None = None
self.values: torch.Tensor | None = None
self.actions_log_prob: torch.Tensor
self.action_mean: torch.Tensor | None = None
self.action_sigma: torch.Tensor | None = None
self.hidden_states: tuple[HiddenState, HiddenState] = (None, None)
def clear(self) -> None:
self.__init__()
def __init__(
self,
training_type: str,
num_envs: int,
teacher_num_envs: int,
num_transitions_per_env: int,
obs: TensorDict,
actions_shape: tuple[int] | list[int],
device: str = "cpu",
) -> None:
self.training_type = training_type
self.device = device
self.num_transitions_per_env = num_transitions_per_env
self.num_envs = num_envs
self.actions_shape = actions_shape
self.teacher_num_envs = teacher_num_envs
self.student_num_envs = num_envs - teacher_num_envs
# Core
self.observations = TensorDict(
{key: torch.zeros(num_transitions_per_env, *value.shape, device=device) for key, value in obs.items()},
batch_size=[num_transitions_per_env, num_envs],
device=self.device,
)
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 distillation
if training_type == "distillation":
self.privileged_actions = torch.zeros(num_transitions_per_env, num_envs, *actions_shape, device=self.device)
# For reinforcement learning
if training_type == "rl":
self.values = torch.zeros(num_transitions_per_env, num_envs, 1, device=self.device)
self.actions_log_prob = 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.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)
# For RNN networks
self.saved_hidden_state_a = None
self.saved_hidden_state_c = None
# Counter for the number of transitions stored
self.step = 0
def add_transition(self, transition: Transition) -> None:
# Check if the transition is valid
if self.step >= self.num_transitions_per_env:
raise OverflowError("Rollout buffer overflow! You should call clear() before adding new transitions.")
# Core
self.observations[self.step].copy_(transition.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))
# For distillation
if self.training_type == "distillation":
self.privileged_actions[self.step].copy_(transition.privileged_actions)
# For reinforcement learning
if self.training_type == "rl":
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)
# For RNN networks
self._save_hidden_states(transition.hidden_states)
# Increment the counter
self.step += 1
def clear(self) -> None:
self.step = 0
# For distillation
def generator(self) -> Generator:
if self.training_type != "distillation":
raise ValueError("This function is only available for distillation training.")
for i in range(self.num_transitions_per_env):
yield self.observations[i], self.actions[i], self.privileged_actions[i], self.dones[i]
# For reinforcement learning with feedforward networks
def mini_batch_generator(self, num_mini_batches: int, num_epochs: int = 8) -> Generator:
if self.training_type != "rl":
raise ValueError("This function is only available for reinforcement learning training.")
# Prepare indices
teacher_samples_num = self.teacher_num_envs * self.num_transitions_per_env
student_samples_num = self.student_num_envs * self.num_transitions_per_env
teacher_mini_batch_size = teacher_samples_num // num_mini_batches
student_mini_batch_size = student_samples_num // num_mini_batches
teacher_indices = torch.randperm(teacher_samples_num, requires_grad=False, device=self.device)
student_indices = teacher_samples_num + torch.randperm(student_samples_num, requires_grad=False, device=self.device)
# Core
observations = self.observations.transpose(0, 1).flatten(0, 1)
actions = self.actions.transpose(0, 1).flatten(0, 1)
values = self.values.transpose(0, 1).flatten(0, 1)
returns = self.returns.transpose(0, 1).flatten(0, 1)
# For PPO
old_actions_log_prob = self.actions_log_prob.transpose(0, 1).flatten(0, 1)
advantages = self.advantages.transpose(0, 1).flatten(0, 1)
old_mu = self.mu.transpose(0, 1).flatten(0, 1)
old_sigma = self.sigma.transpose(0, 1).flatten(0, 1)
def _get_teacher_student_samples(data, slice):
(i1, i2), (j1, j2) = slice
return torch.cat([data[teacher_indices[i1:i2]], data[student_indices[j1:j2]]], 0).detach()
for epoch in range(num_epochs):
for i in range(num_mini_batches):
# Select the indices for the mini-batch
slice = (
(i * teacher_mini_batch_size, (i+1) * teacher_mini_batch_size),
(i * student_mini_batch_size, (i+1) * student_mini_batch_size),
)
# Create the mini-batch
get_batch = partial(_get_teacher_student_samples, slice=slice)
obs_batch, actions_batch, target_values_batch, returns_batch, \
old_actions_log_prob_batch, advantages_batch, old_mu_batch, \
old_sigma_batch = map(get_batch, [
observations,
actions,
values,
returns,
old_actions_log_prob,
advantages,
old_mu,
old_sigma
])
hidden_state_a_batch = None
hidden_state_c_batch = None
masks_batch = None
# Yield the mini-batch
yield (
obs_batch,
actions_batch,
target_values_batch,
advantages_batch,
returns_batch,
old_actions_log_prob_batch,
old_mu_batch,
old_sigma_batch,
(
hidden_state_a_batch,
hidden_state_c_batch,
),
masks_batch,
)
# For reinforcement learning with recurrent networks
def recurrent_mini_batch_generator(self, num_mini_batches: int, num_epochs: int = 8) -> Generator:
return NotImplementedError("CTS rollout storage does not support RNNs yet.")
def _save_hidden_states(self, hidden_states: tuple[HiddenState, HiddenState]) -> None:
return NotImplementedError("CTS rollout storage does not support RNNs yet.")

View File

@@ -0,0 +1,26 @@
# Copyright (c) 2021-2026, ETH Zurich and NVIDIA CORPORATION
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
"""Helper functions."""
from .utils import (
get_param,
resolve_callable,
resolve_nn_activation,
resolve_obs_groups,
resolve_optimizer,
split_and_pad_trajectories,
unpad_trajectories,
)
__all__ = [
"get_param",
"resolve_callable",
"resolve_nn_activation",
"resolve_obs_groups",
"resolve_optimizer",
"split_and_pad_trajectories",
"unpad_trajectories",
]

View File

@@ -0,0 +1,290 @@
# Copyright (c) 2021-2026, ETH Zurich and NVIDIA CORPORATION
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
import git
import os
import pathlib
import statistics
import time
import torch
from collections import deque
import rsl_rl
class Logger:
"""Logger to save the learning metrics to different logging services."""
def __init__(
self,
log_dir: str | None,
cfg: dict,
env_cfg: dict | object,
num_envs: int,
is_distributed: bool,
gpu_world_size: int,
gpu_global_rank: int,
device: str,
) -> None:
self.log_dir = log_dir
self.cfg = cfg
self.num_envs = num_envs
self.gpu_world_size = gpu_world_size
self.device = device
self.git_status_repos = [rsl_rl.__file__]
self.tot_timesteps = 0
self.tot_time = 0
# Create buffers
self.ep_extras = []
self.rewbuffer = deque(maxlen=100)
self.lenbuffer = deque(maxlen=100)
self.cur_reward_sum = torch.zeros(self.num_envs, dtype=torch.float, device=self.device)
self.cur_episode_length = torch.zeros(self.num_envs, dtype=torch.float, device=self.device)
# Create RND buffers
if self.cfg["algorithm"]["rnd_cfg"]:
self.erewbuffer = deque(maxlen=100)
self.irewbuffer = deque(maxlen=100)
self.cur_ereward_sum = torch.zeros(self.num_envs, dtype=torch.float, device=self.device)
self.cur_ireward_sum = torch.zeros(self.num_envs, dtype=torch.float, device=self.device)
# Decide whether to disable logging
# Note: We only log from the process with rank 0 (main process)
self.disable_logs = is_distributed and gpu_global_rank != 0
# Initialize the writer
self._prepare_logging_writer()
# Log code state
self._store_code_state()
# Log configuration
if self.writer and not self.disable_logs and self.logger_type in ["wandb", "neptune"]:
self.writer.store_config(env_cfg, self.cfg)
def process_env_step(
self,
rewards: torch.Tensor,
dones: torch.Tensor,
extras: dict,
intrinsic_rewards: torch.Tensor | None = None,
) -> None:
"""Add metrics from the environment step to the buffers."""
if self.log_dir is not None:
if "episode" in extras:
self.ep_extras.append(extras["episode"])
elif "log" in extras:
self.ep_extras.append(extras["log"])
# Update rewards and episode length
if intrinsic_rewards is not None:
self.cur_ereward_sum += rewards
self.cur_ireward_sum += intrinsic_rewards
self.cur_reward_sum += rewards + intrinsic_rewards
else:
self.cur_reward_sum += rewards
self.cur_episode_length += 1
# Clear data for completed episodes
new_ids = (dones > 0).nonzero(as_tuple=False)
self.rewbuffer.extend(self.cur_reward_sum[new_ids][:, 0].cpu().numpy().tolist())
self.lenbuffer.extend(self.cur_episode_length[new_ids][:, 0].cpu().numpy().tolist())
self.cur_reward_sum[new_ids] = 0
self.cur_episode_length[new_ids] = 0
if intrinsic_rewards is not None:
self.erewbuffer.extend(self.cur_ereward_sum[new_ids][:, 0].cpu().numpy().tolist())
self.irewbuffer.extend(self.cur_ireward_sum[new_ids][:, 0].cpu().numpy().tolist())
self.cur_ereward_sum[new_ids] = 0
self.cur_ireward_sum[new_ids] = 0
def log(
self,
it: int,
start_it: int,
total_it: int,
collect_time: float,
learn_time: float,
loss_dict: dict,
learning_rate: float,
action_std: torch.Tensor,
rnd_weight: float | None,
print_minimal: bool = False,
width: int = 80,
pad: int = 40,
) -> None:
"""Log the training metrics to the logging service and print them to the console."""
if self.log_dir is not None and not self.disable_logs:
collection_size = self.cfg["num_steps_per_env"] * self.num_envs * self.gpu_world_size
iteration_time = collect_time + learn_time
self.tot_timesteps += collection_size
self.tot_time += iteration_time
# Log episode extras
extras_string = ""
if self.ep_extras:
# Iterate over all keys in the episode info dictionary
for key in self.ep_extras[0]:
infotensor = torch.tensor([], device=self.device)
# Iterate over all steps
for ep_info in self.ep_extras:
# Handle missing, scalar, and zero dimensional tensors
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)))
value = torch.mean(infotensor)
if "/" in key:
self.writer.add_scalar(key, value, it)
extras_string += f"""{f"{key}:":>{pad}} {value:.4f}\n"""
else:
self.writer.add_scalar("Episode/" + key, value, it)
extras_string += f"""{f"Mean episode {key}:":>{pad}} {value:.4f}\n"""
# Log losses
for key, value in loss_dict.items():
self.writer.add_scalar(f"Loss/{key}", value, it)
self.writer.add_scalar("Loss/learning_rate", learning_rate, it)
# Log noise std
self.writer.add_scalar("Policy/mean_noise_std", action_std.mean().item(), it)
# Log performance
fps = int(collection_size / (collect_time + learn_time))
self.writer.add_scalar("Perf/total_fps", fps, it)
self.writer.add_scalar("Perf/collection_time", collect_time, it)
self.writer.add_scalar("Perf/learning_time", learn_time, it)
# Log rewards and episode length
if len(self.rewbuffer) > 0:
if self.cfg["algorithm"]["rnd_cfg"]:
self.writer.add_scalar("Rnd/mean_extrinsic_reward", statistics.mean(self.erewbuffer), it)
self.writer.add_scalar("Rnd/mean_intrinsic_reward", statistics.mean(self.irewbuffer), it)
self.writer.add_scalar("Rnd/weight", rnd_weight, it)
self.writer.add_scalar("Train/mean_reward", statistics.mean(self.rewbuffer), it)
self.writer.add_scalar("Train/mean_episode_length", statistics.mean(self.lenbuffer), it)
if self.logger_type != "wandb":
self.writer.add_scalar(
"Train/mean_reward/time", statistics.mean(self.rewbuffer), int(self.tot_time)
)
self.writer.add_scalar(
"Train/mean_episode_length/time", statistics.mean(self.lenbuffer), int(self.tot_time)
)
# Print to console
log_string = f"""{"#" * width}\n"""
log_string += f"""\033[1m{f" Learning iteration {it}/{total_it} ".center(width)}\033[0m \n\n"""
# Print run name if provided
run_name = self.cfg.get("run_name")
log_string += f"""{"Run name:":>{pad}} {run_name}\n""" if run_name else ""
# Print performance
log_string += (
f"""{"Total steps:":>{pad}} {self.tot_timesteps} \n"""
f"""{"Steps per second:":>{pad}} {fps:.0f} \n"""
f"""{"Collection time:":>{pad}} {collect_time:.3f}s \n"""
f"""{"Learning time:":>{pad}} {learn_time:.3f}s \n"""
)
# Print losses
for key, value in loss_dict.items():
log_string += f"""{f"Mean {key} loss:":>{pad}} {value:.4f}\n"""
# Print rewards and episode length
if len(self.rewbuffer) > 0:
if self.cfg["algorithm"]["rnd_cfg"]:
log_string += f"""{"Mean extrinsic reward:":>{pad}} {statistics.mean(self.erewbuffer):.2f}\n"""
log_string += f"""{"Mean intrinsic reward:":>{pad}} {statistics.mean(self.irewbuffer):.2f}\n"""
log_string += f"""{"Mean reward:":>{pad}} {statistics.mean(self.rewbuffer):.2f}\n"""
log_string += f"""{"Mean episode length:":>{pad}} {statistics.mean(self.lenbuffer):.2f}\n"""
# Print noise std
log_string += f"""{"Mean action noise std:":>{pad}} {action_std.mean().item():.2f}\n"""
# Print episode extras
if not print_minimal:
log_string += extras_string
# Print footer
done_it = it + 1 - start_it
remaining_it = total_it - start_it - done_it
eta = self.tot_time / done_it * remaining_it
log_string += (
f"""{"-" * width}\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(eta))}\n"""
)
print(log_string)
# Clear extras buffer
self.ep_extras.clear()
def save_model(self, path: str, it: int) -> None:
"""Save the model to external logging services if specified."""
if self.writer and not self.disable_logs and self.logger_type in ["neptune", "wandb"]:
self.writer.save_model(path, it)
def _prepare_logging_writer(self) -> None:
"""Prepare the logging writer, which can be either Tensorboard, W&B or Neptune."""
if self.log_dir is not None and not self.disable_logs:
self.logger_type = self.cfg.get("logger", "tensorboard")
self.logger_type = self.logger_type.lower()
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.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)
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 'wandb', 'neptune', or 'tensorboard'.")
else:
self.writer = None
def _store_code_state(self) -> None:
"""Store the current git diff of the code repositories involved in the experiment."""
if self.log_dir is not None and not self.disable_logs:
git_log_dir = os.path.join(self.log_dir, "git")
os.makedirs(git_log_dir, exist_ok=True)
file_paths = []
# Iterate over all repositories to log
for repository_file_path in self.git_status_repos:
try:
repo = git.Repo(repository_file_path, search_parent_directories=True)
t = repo.head.commit.tree
except Exception:
print(f"Could not find git repository in {repository_file_path}. Skipping.")
continue
# Get the name of the repository
repo_name = pathlib.Path(repo.working_dir).name
diff_file_name = os.path.join(git_log_dir, f"{repo_name}.diff")
# Check if the diff file already exists
if os.path.isfile(diff_file_name):
continue
# Write the diff file
print(f"Storing git diff for '{repo_name}' in: {diff_file_name}")
with open(diff_file_name, "x", encoding="utf-8") as f:
content = f"--- git status ---\n{repo.git.status()} \n\n\n--- git diff ---\n{repo.git.diff(t)}"
f.write(content)
# Add the file path to the list of files to be uploaded
file_paths.append(diff_file_name)
# Upload diff files to external logging services
if self.writer and self.logger_type in ["wandb", "neptune"] and file_paths:
for path in file_paths:
self.writer.save_file(path)

View File

@@ -0,0 +1,311 @@
# Copyright (c) 2021-2026, ETH Zurich and NVIDIA CORPORATION
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
import git
import os
import pathlib
import statistics
import time
import torch
from collections import deque
import rsl_rl
class LoggerCTS:
"""Logger to save the learning metrics to different logging services."""
def __init__(
self,
log_dir: str | None,
cfg: dict,
env_cfg: dict | object,
num_envs: int,
is_distributed: bool,
gpu_world_size: int,
gpu_global_rank: int,
teacher_env_idxs: torch.Tensor,
device: str,
) -> None:
self.log_dir = log_dir
self.cfg = cfg
self.num_envs = num_envs
self.gpu_world_size = gpu_world_size
self.device = device
self.git_status_repos = [rsl_rl.__file__]
self.tot_timesteps = 0
self.tot_time = 0
self.teacher_env_idxs = teacher_env_idxs # Indices of teacher envs in the total envs
# Create buffers
self.ep_extras = []
self.teacher_rewbuffer = deque(maxlen=100)
self.teacher_lenbuffer = deque(maxlen=100)
self.student_rewbuffer = deque(maxlen=100)
self.student_lenbuffer = deque(maxlen=100)
self.cur_reward_sum = torch.zeros(self.num_envs, dtype=torch.float, device=self.device)
self.cur_episode_length = torch.zeros(self.num_envs, dtype=torch.float, device=self.device)
# Create RND buffers
if self.cfg["algorithm"]["rnd_cfg"]:
self.erewbuffer = deque(maxlen=100)
self.irewbuffer = deque(maxlen=100)
self.cur_ereward_sum = torch.zeros(self.num_envs, dtype=torch.float, device=self.device)
self.cur_ireward_sum = torch.zeros(self.num_envs, dtype=torch.float, device=self.device)
# Decide whether to disable logging
# Note: We only log from the process with rank 0 (main process)
self.disable_logs = is_distributed and gpu_global_rank != 0
# Initialize the writer
self._prepare_logging_writer()
# Log code state
self._store_code_state()
# Log configuration
if self.writer and not self.disable_logs and self.logger_type in ["wandb", "neptune"]:
self.writer.store_config(env_cfg, self.cfg)
def process_env_step(
self,
rewards: torch.Tensor,
dones: torch.Tensor,
extras: dict,
intrinsic_rewards: torch.Tensor | None = None,
) -> None:
"""Add metrics from the environment step to the buffers."""
if self.log_dir is not None:
if "episode" in extras:
self.ep_extras.append(extras["episode"])
elif "log" in extras:
self.ep_extras.append(extras["log"])
# Update rewards and episode length
if intrinsic_rewards is not None:
self.cur_ereward_sum += rewards
self.cur_ireward_sum += intrinsic_rewards
self.cur_reward_sum += rewards + intrinsic_rewards
else:
self.cur_reward_sum += rewards
self.cur_episode_length += 1
# Clear data for completed episodes
new_ids = (dones > 0).nonzero(as_tuple=False)
if new_ids.shape[0]:
teacher_ids = new_ids[torch.isin(new_ids, self.teacher_env_idxs)]
student_ids = new_ids[~torch.isin(new_ids, self.teacher_env_idxs)]
self.teacher_rewbuffer.extend(self.cur_reward_sum[teacher_ids].cpu().numpy().tolist())
self.teacher_lenbuffer.extend(self.cur_episode_length[teacher_ids].cpu().numpy().tolist())
self.student_rewbuffer.extend(self.cur_reward_sum[student_ids].cpu().numpy().tolist())
self.student_lenbuffer.extend(self.cur_episode_length[student_ids].cpu().numpy().tolist())
self.cur_reward_sum[new_ids] = 0
self.cur_episode_length[new_ids] = 0
if intrinsic_rewards is not None:
self.erewbuffer.extend(self.cur_ereward_sum[new_ids][:, 0].cpu().numpy().tolist())
self.irewbuffer.extend(self.cur_ireward_sum[new_ids][:, 0].cpu().numpy().tolist())
self.cur_ereward_sum[new_ids] = 0
self.cur_ireward_sum[new_ids] = 0
def log(
self,
it: int,
start_it: int,
total_it: int,
collect_time: float,
learn_time: float,
loss_dict: dict,
learning_rate: float,
action_std: torch.Tensor,
rnd_weight: float | None,
print_minimal: bool = False,
width: int = 80,
pad: int = 40,
) -> None:
"""Log the training metrics to the logging service and print them to the console."""
if self.log_dir is not None and not self.disable_logs:
collection_size = self.cfg["num_steps_per_env"] * self.num_envs * self.gpu_world_size
iteration_time = collect_time + learn_time
self.tot_timesteps += collection_size
self.tot_time += iteration_time
# Log episode extras
extras_string = ""
if self.ep_extras:
# Iterate over all keys in the episode info dictionary
for key in self.ep_extras[0]:
infotensor = torch.tensor([], device=self.device)
# Iterate over all steps
for ep_info in self.ep_extras:
# Handle missing, scalar, and zero dimensional tensors
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)))
value = torch.mean(infotensor)
if "/" in key:
self.writer.add_scalar(key, value, it)
extras_string += f"""{f"{key}:":>{pad}} {value:.4f}\n"""
else:
self.writer.add_scalar("Episode/" + key, value, it)
extras_string += f"""{f"Mean episode {key}:":>{pad}} {value:.4f}\n"""
# Log losses
for key, value in loss_dict.items():
self.writer.add_scalar(f"Loss/{key}", value, it)
self.writer.add_scalar("Loss/learning_rate", learning_rate, it)
# Log noise std
self.writer.add_scalar("Policy/mean_noise_std", action_std.mean().item(), it)
# Log performance
fps = int(collection_size / (collect_time + learn_time))
self.writer.add_scalar("Perf/total_fps", fps, it)
self.writer.add_scalar("Perf/collection_time", collect_time, it)
self.writer.add_scalar("Perf/learning_time", learn_time, it)
# Log rewards and episode length
if self.cfg["algorithm"]["rnd_cfg"] and len(self.erewbuffer) > 0:
self.writer.add_scalar("Rnd/mean_extrinsic_reward", statistics.mean(self.erewbuffer), it)
self.writer.add_scalar("Rnd/mean_intrinsic_reward", statistics.mean(self.irewbuffer), it)
self.writer.add_scalar("Rnd/weight", rnd_weight, it)
if len(self.teacher_rewbuffer) > 0:
self.writer.add_scalar("Train/mean_teacher_reward", statistics.mean(self.teacher_rewbuffer), it)
self.writer.add_scalar("Train/mean_teacher_episode_length", statistics.mean(self.teacher_lenbuffer), it)
if self.logger_type != "wandb":
self.writer.add_scalar(
"Train/mean_teacher_reward/time", statistics.mean(self.teacher_rewbuffer), int(self.tot_time)
)
self.writer.add_scalar(
"Train/mean_teacher_episode_length/time", statistics.mean(self.teacher_lenbuffer), int(self.tot_time)
)
if len(self.student_rewbuffer) > 0:
self.writer.add_scalar("Train/mean_student_reward", statistics.mean(self.student_rewbuffer), it)
self.writer.add_scalar("Train/mean_student_episode_length", statistics.mean(self.student_lenbuffer), it)
if self.logger_type != "wandb":
self.writer.add_scalar(
"Train/mean_student_reward/time", statistics.mean(self.student_rewbuffer), int(self.tot_time)
)
self.writer.add_scalar(
"Train/mean_student_episode_length/time", statistics.mean(self.student_lenbuffer), int(self.tot_time)
)
# Print to console
log_string = f"""{"#" * width}\n"""
log_string += f"""\033[1m{f" Learning iteration {it}/{total_it} ".center(width)}\033[0m \n\n"""
# Print run name if provided
run_name = self.cfg.get("run_name")
log_string += f"""{"Run name:":>{pad}} {run_name}\n""" if run_name else ""
# Print performance
log_string += (
f"""{"Total steps:":>{pad}} {self.tot_timesteps} \n"""
f"""{"Steps per second:":>{pad}} {fps:.0f} \n"""
f"""{"Collection time:":>{pad}} {collect_time:.3f}s \n"""
f"""{"Learning time:":>{pad}} {learn_time:.3f}s \n"""
)
# Print losses
for key, value in loss_dict.items():
log_string += f"""{f"Mean {key} loss:":>{pad}} {value:.4f}\n"""
# Print rewards and episode length
if self.cfg["algorithm"]["rnd_cfg"] and len(self.erewbuffer) > 0:
log_string += f"""{"Mean extrinsic reward:":>{pad}} {statistics.mean(self.erewbuffer):.2f}\n"""
log_string += f"""{"Mean intrinsic reward:":>{pad}} {statistics.mean(self.irewbuffer):.2f}\n"""
if len(self.teacher_rewbuffer) > 0:
log_string += f"""{"Mean teacher reward:":>{pad}} {statistics.mean(self.teacher_rewbuffer):.2f}\n"""
log_string += f"""{"Mean teacher episode length:":>{pad}} {statistics.mean(self.teacher_lenbuffer):.2f}\n"""
if len(self.student_rewbuffer) > 0:
log_string += f"""{"Mean student reward:":>{pad}} {statistics.mean(self.student_rewbuffer):.2f}\n"""
log_string += f"""{"Mean student episode length:":>{pad}} {statistics.mean(self.student_lenbuffer):.2f}\n"""
# Print noise std
log_string += f"""{"Mean action noise std:":>{pad}} {action_std.mean().item():.2f}\n"""
# Print episode extras
if not print_minimal:
log_string += extras_string
# Print footer
done_it = it + 1 - start_it
remaining_it = total_it - start_it - done_it
eta = self.tot_time / done_it * remaining_it
log_string += (
f"""{"-" * width}\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(eta))}\n"""
)
print(log_string)
# Clear extras buffer
self.ep_extras.clear()
def save_model(self, path: str, it: int) -> None:
"""Save the model to external logging services if specified."""
if self.writer and not self.disable_logs and self.logger_type in ["neptune", "wandb"]:
self.writer.save_model(path, it)
def _prepare_logging_writer(self) -> None:
"""Prepare the logging writer, which can be either Tensorboard, W&B or Neptune."""
if self.log_dir is not None and not self.disable_logs:
self.logger_type = self.cfg.get("logger", "tensorboard")
self.logger_type = self.logger_type.lower()
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.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)
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 'wandb', 'neptune', or 'tensorboard'.")
else:
self.writer = None
def _store_code_state(self) -> None:
"""Store the current git diff of the code repositories involved in the experiment."""
if self.log_dir is not None and not self.disable_logs:
git_log_dir = os.path.join(self.log_dir, "git")
os.makedirs(git_log_dir, exist_ok=True)
file_paths = []
# Iterate over all repositories to log
for repository_file_path in self.git_status_repos:
try:
repo = git.Repo(repository_file_path, search_parent_directories=True)
t = repo.head.commit.tree
except Exception:
print(f"Could not find git repository in {repository_file_path}. Skipping.")
continue
# Get the name of the repository
repo_name = pathlib.Path(repo.working_dir).name
diff_file_name = os.path.join(git_log_dir, f"{repo_name}.diff")
# Check if the diff file already exists
if os.path.isfile(diff_file_name):
continue
# Write the diff file
print(f"Storing git diff for '{repo_name}' in: {diff_file_name}")
with open(diff_file_name, "x", encoding="utf-8") as f:
content = f"--- git status ---\n{repo.git.status()} \n\n\n--- git diff ---\n{repo.git.diff(t)}"
f.write(content)
# Add the file path to the list of files to be uploaded
file_paths.append(diff_file_name)
# Upload diff files to external logging services
if self.writer and self.logger_type in ["wandb", "neptune"] and file_paths:
for path in file_paths:
self.writer.save_file(path)

View File

@@ -0,0 +1,96 @@
# Copyright (c) 2021-2026, ETH Zurich and NVIDIA CORPORATION
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
import os
from dataclasses import asdict
from torch.utils.tensorboard import SummaryWriter
try:
import neptune
except ModuleNotFoundError:
raise ModuleNotFoundError("neptune-client is required to log to Neptune.") from None
class NeptuneSummaryWriter(SummaryWriter):
"""Summary writer for Neptune."""
def __init__(self, log_dir: str, flush_secs: int, cfg: dict) -> None:
super().__init__(log_dir, flush_secs)
# Get the run name
run_name = os.path.split(log_dir)[-1]
# Get neptune project and entity
try:
project = cfg["neptune_project"]
except KeyError:
raise KeyError("Please specify neptune_project in the runner config, e.g. legged_gym.") from None
try:
token = os.environ["NEPTUNE_API_TOKEN"]
except KeyError:
raise KeyError(
"Neptune api token not found. Please run or add to ~/.bashrc: export NEPTUNE_API_TOKEN=YOUR_API_TOKEN"
) from None
try:
entity = os.environ["NEPTUNE_USERNAME"]
except KeyError:
raise KeyError(
"Neptune username not found. Please run or add to ~/.bashrc: export NEPTUNE_USERNAME=YOUR_USERNAME"
) from None
# Initialize neptune
neptune_project = entity + "/" + project
self.run = neptune.init_run(project=neptune_project, api_token=token)
self.run["log_dir"].log(run_name)
# Name mapping for incompatible characters
self.name_map = {
"Train/mean_reward/time": "Train/mean_reward_time",
"Train/mean_episode_length/time": "Train/mean_episode_length_time",
}
def store_config(self, env_cfg: dict | object, train_cfg: dict) -> None:
self.run["runner_cfg"] = train_cfg
self.run["policy_cfg"] = train_cfg["policy"]
self.run["alg_cfg"] = train_cfg["algorithm"]
try:
self.run["env_cfg"] = env_cfg.to_dict()
except Exception:
self.run["env_cfg"] = asdict(env_cfg)
def add_scalar(
self,
tag: str,
scalar_value: float,
global_step: int | None = None,
walltime: float | None = None,
new_style: bool = False,
) -> None:
super().add_scalar(
tag,
scalar_value,
global_step=global_step,
walltime=walltime,
new_style=new_style,
)
self.run[self._map_path(tag)].log(scalar_value, step=global_step)
def stop(self) -> None:
self.run.stop()
def save_model(self, model_path: str, it: int) -> None:
self.run["model/saved_model_" + str(it)].upload(model_path)
def save_file(self, path: str) -> None:
name = path.rsplit("/", 1)[-1].split(".")[0]
self.run["git_diff/" + name].upload(path)
def _map_path(self, path: str) -> str:
if path in self.name_map:
return self.name_map[path]
else:
return path

View File

@@ -0,0 +1,340 @@
# Copyright (c) 2021-2026, ETH Zurich and NVIDIA CORPORATION
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
import importlib
import pkgutil
import torch
import warnings
from tensordict import TensorDict
from typing import Any, Callable
import rsl_rl
def get_param(param: Any, idx: int) -> Any:
"""Get a parameter for the given index.
Args:
param: Parameter or list/tuple of parameters.
idx: Index to get the parameter for.
"""
if isinstance(param, (tuple, list)):
return param[idx]
else:
return param
def resolve_nn_activation(act_name: str) -> torch.nn.Module:
"""Resolve the activation function from the name.
Args:
act_name: Name of the activation function.
Returns:
The activation function.
Raises:
ValueError: If the activation function is not found.
"""
act_dict = {
"elu": torch.nn.ELU(),
"selu": torch.nn.SELU(),
"relu": torch.nn.ReLU(),
"crelu": torch.nn.CELU(),
"lrelu": torch.nn.LeakyReLU(),
"tanh": torch.nn.Tanh(),
"sigmoid": torch.nn.Sigmoid(),
"softplus": torch.nn.Softplus(),
"gelu": torch.nn.GELU(),
"swish": torch.nn.SiLU(),
"mish": torch.nn.Mish(),
"identity": torch.nn.Identity(),
}
act_name = act_name.lower()
if act_name in act_dict:
return act_dict[act_name]
else:
raise ValueError(f"Invalid activation function '{act_name}'. Valid activations are: {list(act_dict.keys())}")
def resolve_optimizer(optimizer_name: str) -> torch.optim.Optimizer:
"""Resolve the optimizer from the name.
Args:
optimizer_name: Name of the optimizer.
Returns:
The optimizer.
Raises:
ValueError: If the optimizer is not found.
"""
optimizer_dict = {
"adam": torch.optim.Adam,
"adamw": torch.optim.AdamW,
"sgd": torch.optim.SGD,
"rmsprop": torch.optim.RMSprop,
}
optimizer_name = optimizer_name.lower()
if optimizer_name in optimizer_dict:
return optimizer_dict[optimizer_name]
else:
raise ValueError(f"Invalid optimizer '{optimizer_name}'. Valid optimizers are: {list(optimizer_dict.keys())}")
def split_and_pad_trajectories(
tensor: torch.Tensor | TensorDict, dones: torch.Tensor
) -> tuple[torch.Tensor | TensorDict, torch.Tensor]:
"""Split trajectories at done indices.
Split trajectories, concatenate them and pad with zeros up to the length of the longest trajectory. Return masks
corresponding to valid parts of the trajectories.
Example (transposed for readability):
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 input has the following order of dimensions: [time, number of envs, additional dimensions]
"""
dones = dones.clone()
dones[-1] = 1
# Permute the buffers to have the 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
if isinstance(tensor, TensorDict):
padded_trajectories = {}
for k, v in tensor.items():
# Split the tensor into trajectories
trajectories = torch.split(v.transpose(1, 0).flatten(0, 1), trajectory_lengths_list)
# Add at least one full length trajectory
trajectories = (*trajectories, torch.zeros(v.shape[0], *v.shape[2:], device=v.device))
# Pad the trajectories to the length of the longest trajectory
padded_trajectories[k] = torch.nn.utils.rnn.pad_sequence(trajectories) # type: ignore
# Remove the added trajectory
padded_trajectories[k] = padded_trajectories[k][:, :-1]
padded_trajectories = TensorDict(
padded_trajectories, batch_size=[tensor.batch_size[0], len(trajectory_lengths_list)], device=tensor.device
)
else:
# Split the tensor into trajectories
trajectories = torch.split(tensor.transpose(1, 0).flatten(0, 1), trajectory_lengths_list)
# Add at least one full length trajectory
trajectories = (*trajectories, torch.zeros(tensor.shape[0], *tensor.shape[2:], device=tensor.device))
# Pad the trajectories to the length of the longest trajectory
padded_trajectories = torch.nn.utils.rnn.pad_sequence(trajectories) # type: ignore
# Remove the added trajectory
padded_trajectories = padded_trajectories[:, :-1]
# Create masks for the valid parts of the 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: torch.Tensor | TensorDict, masks: torch.Tensor) -> torch.Tensor | TensorDict:
"""Do 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)
)
def resolve_callable(callable_or_name: type | Callable | str) -> Callable:
"""Resolve a callable from a string, type, or return callable directly.
This function enables passing custom classes or functions directly or as strings. The following formats are
supported:
- Direct callable: Pass a type or function directly (e.g., MyClass, my_func)
- Qualified name with colon: "module.path:Attr.Nested" (explicit, recommended)
- Qualified name with dot: "module.path.ClassName" (implicit)
- Simple name: e.g. "PPO", "ActorCritic", ... (looks for callable in rsl_rl)
Args:
callable_or_name: A callable (type/function) or string name.
Returns:
The resolved callable.
Raises:
TypeError: If input is neither a callable nor a string.
ImportError: If the module cannot be imported.
AttributeError: If the attribute cannot be found in the module.
ValueError: If a simple name cannot be found in rsl_rl packages.
"""
# Already a callable - return directly
if callable(callable_or_name):
return callable_or_name
# Must be a string at this point
if not isinstance(callable_or_name, str):
raise TypeError(f"Expected callable or string, got {type(callable_or_name)}")
# Handle qualified name with colon separator (e.g., "module.path:Attr.Nested")
if ":" in callable_or_name:
module_path, attr_path = callable_or_name.rsplit(":", 1)
# Try to import the module
module = importlib.import_module(module_path)
# Try to get the attribute
obj = module
for attr in attr_path.split("."):
obj = getattr(obj, attr)
return obj # type: ignore
# Handle qualified name with dot separator (e.g., "module.path.ClassName")
if "." in callable_or_name:
parts = callable_or_name.split(".")
module_found = False
for i in range(len(parts) - 1, 0, -1):
# Try to import the module with the first i parts
module_path = ".".join(parts[:i])
attr_parts = parts[i:]
try:
module = importlib.import_module(module_path)
except ModuleNotFoundError:
continue
module_found = True
# Once a module is found, try to get the attribute
obj = module
try:
for attr in attr_parts:
obj = getattr(obj, attr)
return obj # type: ignore
except AttributeError:
continue
if module_found:
raise AttributeError(f"Could not resolve '{callable_or_name}': attribute not found in module")
else:
raise ImportError(f"Could not resolve '{callable_or_name}': no valid module.attr split found")
# Simple name - look for it in rsl_rl
for _, module_name, _ in pkgutil.iter_modules(rsl_rl.__path__, "rsl_rl."):
module = importlib.import_module(module_name)
if hasattr(module, callable_or_name):
return getattr(module, callable_or_name)
# Raise error if no approach worked
raise ValueError(
f"Could not resolve '{callable_or_name}'. Use qualified name like 'module.path:ClassName' "
f"or pass the class directly."
)
def resolve_obs_groups(
obs: TensorDict, obs_groups: dict[str, list[str]], default_sets: list[str]
) -> dict[str, list[str]]:
"""Validate the observation configuration and resolve missing observation sets.
The input is an observation dictionary `obs` containing observation groups and a configuration dictionary
`obs_groups` where the keys are the observation sets and the values are lists of observation groups.
The configuration dictionary could for example look like:
{
"policy": ["group_1", "group_2"],
"critic": ["group_1", "group_3"]
}
This means that the 'policy' observation set will contain the observations "group_1" and "group_2" and the 'critic'
observation set will contain the observations "group_1" and "group_3". This function will check that all the
observations in the 'policy' and 'critic' observation sets are present in the observation dictionary from the
environment.
Additionally, if one of the `default_sets`, e.g. "critic", is not present in the configuration dictionary, this
function will:
1. Check if a group with the same name exists in the observations and assign this group to the observation set.
2. If 1. fails, it will assign the observations from the 'policy' observation set to the default observation set.
Args:
obs: Observations from the environment in the form of a dictionary.
obs_groups: Observation sets configuration.
default_sets: Reserved observation set names used by the algorithm (besides 'policy'). If not provided in
'obs_groups', a default behavior gets triggered.
Returns:
The resolved observation groups.
Raises:
ValueError: If any observation set is an empty list.
ValueError: If any observation set contains an observation term that is not present in the observations.
"""
# Check if policy observation set exists
if "policy" not in obs_groups:
if "policy" in obs:
obs_groups["policy"] = ["policy"]
warnings.warn(
"The observation configuration dictionary 'obs_groups' must contain the 'policy' key."
" As an observation group with the name 'policy' was found, this is assumed to be the observation set."
" Consider adding the 'policy' key to the 'obs_groups' dictionary for clarity."
" This behavior will be removed in a future version."
)
else:
raise ValueError(
"The observation configuration dictionary 'obs_groups' must contain the 'policy' key."
f" Found keys: {list(obs_groups.keys())}"
)
# Check all observation sets for valid observation groups
for set_name, groups in obs_groups.items():
# Check if the list is empty
if len(groups) == 0:
msg = f"The '{set_name}' key in the 'obs_groups' dictionary can not be an empty list."
if set_name in default_sets:
if set_name not in obs:
msg += " Consider removing the key to default to the observations used for the 'policy' set."
else:
msg += (
f" Consider removing the key to default to the observation '{set_name}' from the environment."
)
raise ValueError(msg)
# Check groups exist inside the observations from the environment
for group in groups:
if group not in obs:
raise ValueError(
f"Observation '{group}' in observation set '{set_name}' not found in the observations from the"
f" environment. Available observations from the environment: {list(obs.keys())}"
)
# Fill missing observation sets
for default_set_name in default_sets:
if default_set_name not in obs_groups:
if default_set_name in obs:
obs_groups[default_set_name] = [default_set_name]
warnings.warn(
f"The observation configuration dictionary 'obs_groups' must contain the '{default_set_name}' key."
f" As an observation group with the name '{default_set_name}' was found, this is assumed to be the"
f" observation set. Consider adding the '{default_set_name}' key to the 'obs_groups' dictionary for"
" clarity. This behavior will be removed in a future version."
)
else:
obs_groups[default_set_name] = obs_groups["policy"].copy()
warnings.warn(
f"The observation configuration dictionary 'obs_groups' must contain the '{default_set_name}' key."
f" As the configuration for '{default_set_name}' is missing, the observations from the 'policy' set"
f" are used. Consider adding the '{default_set_name}' key to the 'obs_groups' dictionary for"
" clarity. This behavior will be removed in a future version."
)
# Print the final parsed observation sets
print("-" * 80)
print("Resolved observation sets: ")
for set_name, groups in obs_groups.items():
print("\t", set_name, ": ", groups)
print("-" * 80)
return obs_groups

View File

@@ -0,0 +1,74 @@
# Copyright (c) 2021-2026, ETH Zurich and NVIDIA CORPORATION
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
import os
from dataclasses import asdict
from torch.utils.tensorboard import SummaryWriter
try:
import wandb
except ModuleNotFoundError:
raise ModuleNotFoundError("wandb package is required to log to Weights and Biases.") from None
class WandbSummaryWriter(SummaryWriter):
"""Summary writer for Weights and Biases."""
def __init__(self, log_dir: str, flush_secs: int, cfg: dict) -> None:
super().__init__(log_dir, flush_secs)
# Get the run name
run_name = os.path.split(log_dir)[-1]
# Get wandb project and entity
try:
project = cfg["wandb_project"]
except KeyError:
raise KeyError("Please specify wandb_project in the runner config, e.g. legged_gym.") from None
try:
entity = os.environ["WANDB_USERNAME"]
except KeyError:
entity = None
# Initialize wandb
wandb.init(project=project, entity=entity, name=run_name)
wandb.config.update({"log_dir": log_dir})
def store_config(self, env_cfg: dict | object, train_cfg: dict) -> None:
wandb.config.update({"runner_cfg": train_cfg})
wandb.config.update({"policy_cfg": train_cfg["policy"]})
wandb.config.update({"alg_cfg": train_cfg["algorithm"]})
try:
wandb.config.update({"env_cfg": env_cfg.to_dict()})
except Exception:
wandb.config.update({"env_cfg": asdict(env_cfg)})
def add_scalar(
self,
tag: str,
scalar_value: float,
global_step: int | None = None,
walltime: float | None = None,
new_style: bool = False,
) -> None:
super().add_scalar(
tag,
scalar_value,
global_step=global_step,
walltime=walltime,
new_style=new_style,
)
wandb.log({tag: scalar_value}, step=global_step)
def stop(self) -> None:
wandb.finish()
def save_model(self, model_path: str, it: int) -> None:
wandb.save(model_path, base_path=os.path.dirname(model_path))
def save_file(self, path: str) -> None:
wandb.save(path, base_path=os.path.dirname(path))

71
source/rsl_rl/ruff.toml Normal file
View File

@@ -0,0 +1,71 @@
line-length = 120
target-version = "py39"
preview = true
[lint]
select = [
# pycodestyle
"E", "W",
# pydocstyle
"D",
# pylint for later
# "PL",
# pyflakes
"F",
# pyupgrade
"UP",
# pep8-naming
"N",
# flake8-bugbear
"B",
# flake8-simplify
"SIM",
# flake8-tidy-imports
"TID",
# flake8-annotations
"ANN",
# isort
"I",
# perflint
"PERF",
# ruff
"RUF",
]
ignore = ["B006",
"B007",
"B028",
"ANN401",
"D100",
"D101",
"D102",
"D103",
"D104",
"D105",
"D106",
"D107",
"D203",
"D213",
"D413",
]
per-file-ignores = {"*/__init__.py" = ["F401"]}
[lint.isort]
# Order of imports
section-order = [
"future",
"standard-library",
"third-party",
"first-party",
"local-folder",
]
# Extra standard libraries considered as part of python (permissive licenses)
extra-standard-library = [
"numpy",
"torch",
"tensordict",
"warp",
"typing_extensions",
"git",
]
# Imports from this repository
known-first-party = ["rsl_rl"]

8
source/rsl_rl/setup.py Normal file
View File

@@ -0,0 +1,8 @@
# Copyright (c) 2021-2026, ETH Zurich and NVIDIA CORPORATION
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
from setuptools import setup
setup()

View File

@@ -0,0 +1,6 @@
# Copyright (c) 2021-2026, ETH Zurich and NVIDIA CORPORATION
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
"""Tests for rsl_rl."""

View File

@@ -0,0 +1,6 @@
# Copyright (c) 2021-2026, ETH Zurich and NVIDIA CORPORATION
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
"""Tests for the utils module of rsl_rl."""

View File

@@ -0,0 +1,148 @@
# Copyright (c) 2021-2026, ETH Zurich and NVIDIA CORPORATION
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
"""Tests for resolve_callable utility function."""
import pytest
from rsl_rl.utils import resolve_callable
# Test fixtures - nested class for testing nested attribute resolution
class OuterClass:
"""Outer class for testing nested attribute resolution."""
class InnerClass:
"""Inner nested class."""
pass
@staticmethod
def static_method() -> str:
return "static"
def sample_function() -> str:
"""Sample function for testing."""
return "sample"
class TestResolveCallableDirect:
"""Tests for direct callable passing."""
def test_direct_class(self) -> None:
"""Passing a class directly should return it unchanged."""
from rsl_rl.algorithms import PPO
result = resolve_callable(PPO)
assert result is PPO
def test_direct_function(self) -> None:
"""Passing a function directly should return it unchanged."""
result = resolve_callable(sample_function)
assert result is sample_function
def test_direct_builtin(self) -> None:
"""Passing a builtin should return it unchanged."""
result = resolve_callable(len)
assert result is len
class TestResolveCallableColonFormat:
"""Tests for colon-separated format 'module:attr'."""
def test_colon_format_class(self) -> None:
"""Should resolve 'module:Class' format."""
result = resolve_callable("rsl_rl.algorithms:PPO")
from rsl_rl.algorithms import PPO
assert result is PPO
def test_colon_format_nested(self) -> None:
"""Should resolve 'module:Outer.Inner' nested format."""
result = resolve_callable("tests.utils.test_resolve_callable:OuterClass.InnerClass")
assert result is OuterClass.InnerClass
def test_colon_format_static_method(self) -> None:
"""Should resolve nested static methods."""
result = resolve_callable("tests.utils.test_resolve_callable:OuterClass.static_method")
assert result is OuterClass.static_method
def test_colon_format_invalid_module(self) -> None:
"""Should raise ImportError for invalid module."""
with pytest.raises(ImportError):
resolve_callable("nonexistent_module:SomeClass")
def test_colon_format_invalid_attr(self) -> None:
"""Should raise AttributeError for invalid attribute."""
with pytest.raises(AttributeError):
resolve_callable("rsl_rl.algorithms:NonexistentClass")
class TestResolveCallableDotFormat:
"""Tests for dot-separated format 'module.attr'."""
def test_dot_format_class(self) -> None:
"""Should resolve 'module.Class' format."""
result = resolve_callable("rsl_rl.algorithms.PPO")
from rsl_rl.algorithms import PPO
assert result is PPO
def test_dot_format_nested(self) -> None:
"""Should resolve 'module.Outer.Inner' nested format."""
# This tests the progressive module path splitting
result = resolve_callable("tests.utils.test_resolve_callable.OuterClass.InnerClass")
assert result is OuterClass.InnerClass
def test_dot_format_static_method(self) -> None:
"""Should resolve nested static methods."""
result = resolve_callable("tests.utils.test_resolve_callable.OuterClass.static_method")
assert result is OuterClass.static_method
def test_dot_format_invalid_module(self) -> None:
"""Should raise ImportError for invalid module."""
with pytest.raises(ImportError):
resolve_callable("nonexistent_module.SomeClass")
def test_dot_format_invalid_attr(self) -> None:
"""Should raise AttributeError for invalid attribute."""
with pytest.raises(AttributeError):
resolve_callable("rsl_rl.algorithms.NonexistentClass")
class TestResolveCallableSimpleName:
"""Tests for simple name resolution via rsl_rl packages."""
def test_simple_name(self) -> None:
"""Should resolve 'PPO' from rsl_rl.algorithms."""
result = resolve_callable("PPO")
from rsl_rl.algorithms import PPO
assert result is PPO
def test_simple_name_unknown(self) -> None:
"""Should raise ValueError for unknown simple names."""
with pytest.raises(ValueError, match="Could not resolve"):
resolve_callable("NonexistentClassName")
class TestResolveCallableErrors:
"""Tests for error handling."""
def test_type_error_none(self) -> None:
"""Should raise TypeError for None input."""
with pytest.raises(TypeError, match="Expected callable or string"):
resolve_callable(None)
def test_type_error_int(self) -> None:
"""Should raise TypeError for int input."""
with pytest.raises(TypeError, match="Expected callable or string"):
resolve_callable(42)
def test_type_error_list(self) -> None:
"""Should raise TypeError for list input."""
with pytest.raises(TypeError, match="Expected callable or string"):
resolve_callable(["PPO"])