Add robogauge eval; update single obs export jit
This commit is contained in:
6
MUJOCO_LOG.TXT
Normal file
6
MUJOCO_LOG.TXT
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
Sat Apr 11 17:39:23 2026
|
||||||
|
WARNING: Nan, Inf or huge value in CTRL at ACTUATOR 9. The simulation is unstable. Time = 4.4000.
|
||||||
|
|
||||||
|
Sat Apr 11 17:39:34 2026
|
||||||
|
WARNING: Nan, Inf or huge value in CTRL at ACTUATOR 9. The simulation is unstable. Time = 4.4000.
|
||||||
|
|
||||||
@@ -1,4 +1,16 @@
|
|||||||
"""Simplified CTS policy deployment for Unitree Go2 in MuJoCo."""
|
"""Run CTS Go2 policy deployment in MuJoCo with joystick command input.
|
||||||
|
|
||||||
|
Overview:
|
||||||
|
This script loads a TorchScript policy, steps MuJoCo simulation with PD control, and
|
||||||
|
queries the policy at control decimation using only current-frame observations. The
|
||||||
|
exported policy manages internal observation history for CTS inference.
|
||||||
|
|
||||||
|
Quick Start:
|
||||||
|
python deploy/deploy_mujoco/deploy_go2.py
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
This script currently uses CONFIG_NAME = "go2.yaml" and does not expose CLI flags.
|
||||||
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -12,13 +24,10 @@ from utils import (
|
|||||||
build_delay_buffers,
|
build_delay_buffers,
|
||||||
display_current_command,
|
display_current_command,
|
||||||
gravity_from_quat,
|
gravity_from_quat,
|
||||||
infer_action,
|
|
||||||
init_joystick,
|
init_joystick,
|
||||||
latest_obs_frame,
|
|
||||||
load_config,
|
load_config,
|
||||||
open_video_writer,
|
open_video_writer,
|
||||||
pd_control,
|
pd_control,
|
||||||
push_obs_history,
|
|
||||||
read_joystick_command,
|
read_joystick_command,
|
||||||
sample_delayed_targets,
|
sample_delayed_targets,
|
||||||
set_initial_state,
|
set_initial_state,
|
||||||
@@ -37,6 +46,17 @@ ACTUATOR_GROUPS = (
|
|||||||
|
|
||||||
|
|
||||||
def build_features(data, action: np.ndarray, cmd: np.ndarray, cfg):
|
def build_features(data, action: np.ndarray, cmd: np.ndarray, cfg):
|
||||||
|
"""Build the current single-frame feature dictionary for policy inference.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: MuJoCo runtime data object.
|
||||||
|
action: Latest action in MuJoCo joint order.
|
||||||
|
cmd: Current command vector.
|
||||||
|
cfg: Loaded deployment configuration.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A feature dictionary keyed by observation group name.
|
||||||
|
"""
|
||||||
joint_pos = (data.qpos[7:] - cfg.default_angles) * cfg.dof_pos_scale
|
joint_pos = (data.qpos[7:] - cfg.default_angles) * cfg.dof_pos_scale
|
||||||
joint_vel = data.qvel[6:] * cfg.dof_vel_scale
|
joint_vel = data.qvel[6:] * cfg.dof_vel_scale
|
||||||
return {
|
return {
|
||||||
@@ -50,10 +70,33 @@ def build_features(data, action: np.ndarray, cmd: np.ndarray, cfg):
|
|||||||
|
|
||||||
|
|
||||||
def action_to_target(action: np.ndarray, cfg):
|
def action_to_target(action: np.ndarray, cfg):
|
||||||
|
"""Convert normalized action output to joint position targets.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
action: Action in model joint order.
|
||||||
|
cfg: Loaded deployment configuration.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Target joint positions in model joint order.
|
||||||
|
"""
|
||||||
return cfg.default_angles + action * cfg.action_pos_scale
|
return cfg.default_angles + action * cfg.action_pos_scale
|
||||||
|
|
||||||
|
|
||||||
|
def build_single_obs(features: dict[str, np.ndarray], layout: list[tuple[str, int]]) -> np.ndarray:
|
||||||
|
"""Flatten current feature groups into one single observation vector.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
features: Current-step feature dictionary.
|
||||||
|
layout: Ordered feature layout specification.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A concatenated single-frame observation vector.
|
||||||
|
"""
|
||||||
|
return np.concatenate([features[name] for name, _ in layout], axis=0).astype(np.float32, copy=False)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
"""Run MuJoCo simulation and deploy the CTS policy in closed-loop control."""
|
||||||
cfg = load_config(CONFIG_NAME)
|
cfg = load_config(CONFIG_NAME)
|
||||||
layout = [
|
layout = [
|
||||||
("ang_vel", 3),
|
("ang_vel", 3),
|
||||||
@@ -82,7 +125,6 @@ def main() -> None:
|
|||||||
action = np.zeros(cfg.num_actions, dtype=np.float32)
|
action = np.zeros(cfg.num_actions, dtype=np.float32)
|
||||||
target_pos = cfg.default_angles.copy()
|
target_pos = cfg.default_angles.copy()
|
||||||
target_vel = np.zeros(cfg.num_actions, dtype=np.float32)
|
target_vel = np.zeros(cfg.num_actions, dtype=np.float32)
|
||||||
obs = np.zeros(cfg.num_obs * cfg.history_len, dtype=np.float32)
|
|
||||||
pos_history = build_delay_buffers(target_pos, delay_max=cfg.delay_max)
|
pos_history = build_delay_buffers(target_pos, delay_max=cfg.delay_max)
|
||||||
delay_rng = np.random.default_rng(cfg.delay_seed)
|
delay_rng = np.random.default_rng(cfg.delay_seed)
|
||||||
render_substeps = max(1, int((1.0 / cfg.render_fps) / cfg.dt))
|
render_substeps = max(1, int((1.0 / cfg.render_fps) / cfg.dt))
|
||||||
@@ -112,8 +154,10 @@ def main() -> None:
|
|||||||
|
|
||||||
counter += 1
|
counter += 1
|
||||||
if counter % cfg.decimation == 0:
|
if counter % cfg.decimation == 0:
|
||||||
push_obs_history(obs, build_features(data, action, cmd, cfg), layout, cfg.history_len)
|
features = build_features(data, action, cmd, cfg)
|
||||||
action = infer_action(policy, obs, latest_obs_frame(obs, layout, cfg.history_len), cfg.idx_model2mj)
|
single_obs = build_single_obs(features, layout)
|
||||||
|
action_tensor = policy(torch.from_numpy(single_obs).unsqueeze(0))
|
||||||
|
action = action_tensor.detach().cpu().numpy().squeeze()[cfg.idx_model2mj]
|
||||||
pos_history.append(action_to_target(action, cfg).copy())
|
pos_history.append(action_to_target(action, cfg).copy())
|
||||||
target_pos = sample_delayed_targets(pos_history, ACTUATOR_GROUPS, cfg.delay_min, cfg.delay_max, delay_rng)
|
target_pos = sample_delayed_targets(pos_history, ACTUATOR_GROUPS, cfg.delay_min, cfg.delay_max, delay_rng)
|
||||||
display_current_command(cmd)
|
display_current_command(cmd)
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ def add_rsl_rl_args(parser: argparse.ArgumentParser):
|
|||||||
arg_group.add_argument(
|
arg_group.add_argument(
|
||||||
"--log_project_name", type=str, default=None, help="Name of the logging project when using wandb or neptune."
|
"--log_project_name", type=str, default=None, help="Name of the logging project when using wandb or neptune."
|
||||||
)
|
)
|
||||||
|
arg_group.add_argument(
|
||||||
|
"--robogauge", action="store_true", default=False, help="Enable robogauge evaluation interface."
|
||||||
|
)
|
||||||
|
arg_group.add_argument(
|
||||||
|
"--robogauge_port", type=int, default=9973, help="Port for robogauge evaluation interface."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def parse_rsl_rl_cfg(task_name: str, args_cli: argparse.Namespace) -> RslRlBaseRunnerCfg:
|
def parse_rsl_rl_cfg(task_name: str, args_cli: argparse.Namespace) -> RslRlBaseRunnerCfg:
|
||||||
|
|||||||
@@ -108,6 +108,11 @@ def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agen
|
|||||||
"""Train with RSL-RL agent."""
|
"""Train with RSL-RL agent."""
|
||||||
# override configurations with non-hydra CLI arguments
|
# override configurations with non-hydra CLI arguments
|
||||||
agent_cfg = cli_args.update_rsl_rl_cfg(agent_cfg, args_cli)
|
agent_cfg = cli_args.update_rsl_rl_cfg(agent_cfg, args_cli)
|
||||||
|
agent_cfg_dict = agent_cfg.to_dict()
|
||||||
|
agent_cfg_dict["robogauge"] = {
|
||||||
|
"enabled": args_cli.robogauge,
|
||||||
|
"port": args_cli.robogauge_port,
|
||||||
|
}
|
||||||
env_cfg.scene.num_envs = args_cli.num_envs if args_cli.num_envs is not None else env_cfg.scene.num_envs
|
env_cfg.scene.num_envs = args_cli.num_envs if args_cli.num_envs is not None else env_cfg.scene.num_envs
|
||||||
agent_cfg.max_iterations = (
|
agent_cfg.max_iterations = (
|
||||||
args_cli.max_iterations if args_cli.max_iterations is not None else agent_cfg.max_iterations
|
args_cli.max_iterations if args_cli.max_iterations is not None else agent_cfg.max_iterations
|
||||||
@@ -185,11 +190,11 @@ def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agen
|
|||||||
|
|
||||||
# create runner from rsl-rl
|
# create runner from rsl-rl
|
||||||
if agent_cfg.class_name == "OnPolicyRunner":
|
if agent_cfg.class_name == "OnPolicyRunner":
|
||||||
runner = OnPolicyRunner(env, agent_cfg.to_dict(), log_dir=log_dir, device=agent_cfg.device)
|
runner = OnPolicyRunner(env, agent_cfg_dict, log_dir=log_dir, device=agent_cfg.device)
|
||||||
elif agent_cfg.class_name == "DistillationRunner":
|
elif agent_cfg.class_name == "DistillationRunner":
|
||||||
runner = DistillationRunner(env, agent_cfg.to_dict(), log_dir=log_dir, device=agent_cfg.device)
|
runner = DistillationRunner(env, agent_cfg_dict, log_dir=log_dir, device=agent_cfg.device)
|
||||||
elif agent_cfg.class_name == "OnPolicyRunnerCTS":
|
elif agent_cfg.class_name == "OnPolicyRunnerCTS":
|
||||||
runner = OnPolicyRunnerCTS(env, agent_cfg.to_dict(), log_dir=log_dir, device=agent_cfg.device)
|
runner = OnPolicyRunnerCTS(env, agent_cfg_dict, log_dir=log_dir, device=agent_cfg.device)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unsupported runner class: {agent_cfg.class_name}")
|
raise ValueError(f"Unsupported runner class: {agent_cfg.class_name}")
|
||||||
# write git state to logs
|
# write git state to logs
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import torch
|
|||||||
import re
|
import re
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from typing import NamedTuple
|
|
||||||
|
|
||||||
"Script to log terminal output to a file, stripping ANSI escape codes."
|
"Script to log terminal output to a file, stripping ANSI escape codes."
|
||||||
class Logger:
|
class Logger:
|
||||||
@@ -28,11 +27,6 @@ class Logger:
|
|||||||
self.terminal.flush()
|
self.terminal.flush()
|
||||||
self.log.flush()
|
self.log.flush()
|
||||||
|
|
||||||
# Inputs of CTS Policy is a TensorDict with 'policy' and 'single_obs' keys, we simulate this with a NamedTuple.
|
|
||||||
class CTSPolicyInputs(NamedTuple):
|
|
||||||
policy: torch.Tensor
|
|
||||||
single_obs: torch.Tensor
|
|
||||||
|
|
||||||
def export_cts_policy_as_jit(policy: object, actor_obs_normalizer: object | None, single_obs_normalizer: object | None, path: str, filename="policy.pt"):
|
def export_cts_policy_as_jit(policy: object, actor_obs_normalizer: object | None, single_obs_normalizer: object | None, path: str, filename="policy.pt"):
|
||||||
"""Export CTS policy into a Torch JIT file.
|
"""Export CTS policy into a Torch JIT file.
|
||||||
|
|
||||||
@@ -75,6 +69,16 @@ class _TorchPolicyExporter(torch.nn.Module):
|
|||||||
"""Exporter of actor-critic into JIT file."""
|
"""Exporter of actor-critic into JIT file."""
|
||||||
|
|
||||||
def __init__(self, policy, actor_obs_normalizer=None, single_obs_normalizer=None):
|
def __init__(self, policy, actor_obs_normalizer=None, single_obs_normalizer=None):
|
||||||
|
"""Initialize a TorchScript exporter for CTS policy inference.
|
||||||
|
|
||||||
|
The exported model consumes only the current `single_obs` frame and maintains
|
||||||
|
internal stacked history to reconstruct actor observations expected by the student encoder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
policy: Source CTS policy module to export.
|
||||||
|
actor_obs_normalizer: Normalizer applied to stacked actor observations.
|
||||||
|
single_obs_normalizer: Normalizer applied to current single-frame observations.
|
||||||
|
"""
|
||||||
assert not policy.is_recurrent, "CTS policy should not be recurrent"
|
assert not policy.is_recurrent, "CTS policy should not be recurrent"
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
@@ -87,6 +91,22 @@ class _TorchPolicyExporter(torch.nn.Module):
|
|||||||
raise ValueError("Policy does not have an actor/student module.")
|
raise ValueError("Policy does not have an actor/student module.")
|
||||||
self.student_moe_encoder = copy.deepcopy(policy.student_moe_encoder)
|
self.student_moe_encoder = copy.deepcopy(policy.student_moe_encoder)
|
||||||
self.state_dependent_std = policy.state_dependent_std
|
self.state_dependent_std = policy.state_dependent_std
|
||||||
|
self.num_actions = int(policy.num_actions)
|
||||||
|
self.num_single_obs = int(policy.num_single_obs)
|
||||||
|
self.num_actor_obs = int(policy.num_actor_obs)
|
||||||
|
if self.num_actor_obs % self.num_single_obs != 0:
|
||||||
|
raise ValueError(
|
||||||
|
f"num_actor_obs ({self.num_actor_obs}) must be divisible by num_single_obs ({self.num_single_obs})."
|
||||||
|
)
|
||||||
|
self.history_len = self.num_actor_obs // self.num_single_obs
|
||||||
|
# Keep the same per-term history layout as deploy-side push_obs_history:
|
||||||
|
# [ang_vel(3), gravity(3), cmd(3), joint_pos(A), joint_vel(A), last_action(A)].
|
||||||
|
self.feature_dims = [3, 3, 3, self.num_actions, self.num_actions, self.num_actions]
|
||||||
|
if sum(self.feature_dims) != self.num_single_obs:
|
||||||
|
raise ValueError(
|
||||||
|
"Unsupported single_obs layout: expected 3+3+3+3*num_actions to match num_single_obs."
|
||||||
|
)
|
||||||
|
self.register_buffer("obs_history", torch.zeros(1, self.num_actor_obs, dtype=torch.float32))
|
||||||
|
|
||||||
# copy normalizer if exists
|
# copy normalizer if exists
|
||||||
if actor_obs_normalizer:
|
if actor_obs_normalizer:
|
||||||
@@ -98,9 +118,43 @@ class _TorchPolicyExporter(torch.nn.Module):
|
|||||||
else:
|
else:
|
||||||
self.single_obs_normalizer = torch.nn.Identity()
|
self.single_obs_normalizer = torch.nn.Identity()
|
||||||
|
|
||||||
def forward(self, x: CTSPolicyInputs):
|
def forward(self, single_obs: torch.Tensor):
|
||||||
single_obs = self.single_obs_normalizer(x.single_obs)
|
"""Compute policy action from one current observation frame.
|
||||||
obs_a = self.actor_obs_normalizer(x.policy)
|
|
||||||
|
The exporter keeps an internal FIFO history buffer and shifts it by one frame
|
||||||
|
on each forward call before appending the latest observation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
single_obs: Current-step observation tensor with shape `[B, num_single_obs]`.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The policy action tensor.
|
||||||
|
"""
|
||||||
|
if single_obs.dim() == 1:
|
||||||
|
single_obs = single_obs.unsqueeze(0)
|
||||||
|
if single_obs.shape[-1] != self.num_single_obs:
|
||||||
|
raise ValueError(
|
||||||
|
f"Expected single_obs last dimension {self.num_single_obs}, got {single_obs.shape[-1]}."
|
||||||
|
)
|
||||||
|
if single_obs.shape[0] != 1:
|
||||||
|
raise ValueError("TorchScript CTS deployment currently supports batch size 1 only.")
|
||||||
|
|
||||||
|
next_history = self.obs_history.clone()
|
||||||
|
history_offset = 0
|
||||||
|
single_offset = 0
|
||||||
|
for dim in self.feature_dims:
|
||||||
|
block_size = dim * self.history_len
|
||||||
|
block_end = history_offset + block_size
|
||||||
|
single_end = single_offset + dim
|
||||||
|
block = self.obs_history[:, history_offset:block_end]
|
||||||
|
shifted_block = torch.cat([block[:, dim:], single_obs[:, single_offset:single_end]], dim=-1)
|
||||||
|
next_history[:, history_offset:block_end] = shifted_block
|
||||||
|
history_offset = block_end
|
||||||
|
single_offset = single_end
|
||||||
|
self.obs_history.copy_(next_history)
|
||||||
|
|
||||||
|
single_obs = self.single_obs_normalizer(single_obs)
|
||||||
|
obs_a = self.actor_obs_normalizer(self.obs_history)
|
||||||
latent, _ = self.student_moe_encoder(obs_a)
|
latent, _ = self.student_moe_encoder(obs_a)
|
||||||
latent_and_obs = torch.cat([latent, single_obs], dim=-1)
|
latent_and_obs = torch.cat([latent, single_obs], dim=-1)
|
||||||
if self.state_dependent_std:
|
if self.state_dependent_std:
|
||||||
@@ -110,7 +164,8 @@ class _TorchPolicyExporter(torch.nn.Module):
|
|||||||
|
|
||||||
@torch.jit.export
|
@torch.jit.export
|
||||||
def reset(self):
|
def reset(self):
|
||||||
pass
|
"""Reset internal observation history state."""
|
||||||
|
self.obs_history.zero_()
|
||||||
|
|
||||||
def export(self, path, filename):
|
def export(self, path, filename):
|
||||||
os.makedirs(path, exist_ok=True)
|
os.makedirs(path, exist_ok=True)
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import os
|
|||||||
import time
|
import time
|
||||||
import torch
|
import torch
|
||||||
import warnings
|
import warnings
|
||||||
|
import yaml
|
||||||
|
import numpy as np
|
||||||
from tensordict import TensorDict
|
from tensordict import TensorDict
|
||||||
|
|
||||||
from rsl_rl.algorithms import MoECTS
|
from rsl_rl.algorithms import MoECTS
|
||||||
@@ -21,6 +23,21 @@ from rsl_rl.modules import (
|
|||||||
from rsl_rl.storage import RolloutStorageCTS
|
from rsl_rl.storage import RolloutStorageCTS
|
||||||
from rsl_rl.utils import resolve_callable, resolve_obs_groups
|
from rsl_rl.utils import resolve_callable, resolve_obs_groups
|
||||||
from rsl_rl.utils.logger_cts import LoggerCTS
|
from rsl_rl.utils.logger_cts import LoggerCTS
|
||||||
|
from rsl_rl.utils.exporter_cts import export_cts_policy_as_jit
|
||||||
|
|
||||||
|
|
||||||
|
def numpy_representer(dumper: yaml.SafeDumper, data: np.floating) -> yaml.Node:
|
||||||
|
return dumper.represent_float(float(data))
|
||||||
|
|
||||||
|
|
||||||
|
def numpy_int_representer(dumper: yaml.SafeDumper, data: np.integer) -> yaml.Node:
|
||||||
|
return dumper.represent_int(int(data))
|
||||||
|
|
||||||
|
|
||||||
|
yaml.add_representer(np.float32, numpy_representer, Dumper=yaml.SafeDumper)
|
||||||
|
yaml.add_representer(np.float64, numpy_representer, Dumper=yaml.SafeDumper)
|
||||||
|
yaml.add_representer(np.int32, numpy_int_representer, Dumper=yaml.SafeDumper)
|
||||||
|
yaml.add_representer(np.int64, numpy_int_representer, Dumper=yaml.SafeDumper)
|
||||||
|
|
||||||
|
|
||||||
class OnPolicyRunnerCTS:
|
class OnPolicyRunnerCTS:
|
||||||
@@ -58,6 +75,18 @@ class OnPolicyRunnerCTS:
|
|||||||
|
|
||||||
self.current_learning_iteration = 0
|
self.current_learning_iteration = 0
|
||||||
|
|
||||||
|
# robogauge client
|
||||||
|
try:
|
||||||
|
robogauge_cfg = train_cfg.get("robogauge", {})
|
||||||
|
if not robogauge_cfg.get("enabled", False):
|
||||||
|
raise ImportError("config disabled")
|
||||||
|
from robogauge.scripts.client import RoboGaugeClient
|
||||||
|
|
||||||
|
self.robogauge_client = RoboGaugeClient(f"http://127.0.0.1:{robogauge_cfg.get('port', 9973)}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[INFO] RoboGauge client could not be initialized: {e}, disabling RoboGauge interface.")
|
||||||
|
self.robogauge_client = None
|
||||||
|
|
||||||
def learn(self, num_learning_iterations: int, init_at_random_ep_len: bool = False) -> None:
|
def learn(self, num_learning_iterations: int, init_at_random_ep_len: bool = False) -> None:
|
||||||
# Randomize initial episode lengths (for exploration)
|
# Randomize initial episode lengths (for exploration)
|
||||||
if init_at_random_ep_len:
|
if init_at_random_ep_len:
|
||||||
@@ -124,13 +153,17 @@ class OnPolicyRunnerCTS:
|
|||||||
|
|
||||||
# Save model
|
# Save model
|
||||||
if it % self.cfg["save_interval"] == 0:
|
if it % self.cfg["save_interval"] == 0:
|
||||||
self.save(os.path.join(self.logger.log_dir, f"model_{it}.pt")) # type: ignore
|
self.save(os.path.join(self.logger.log_dir, f"model_{it}.pt"), it=it, last_model=False) # type: ignore
|
||||||
|
|
||||||
# Save the final model after training
|
# Save the final model after training
|
||||||
if self.logger.log_dir is not None and not self.logger.disable_logs:
|
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"))
|
self.save(
|
||||||
|
os.path.join(self.logger.log_dir, f"model_{self.current_learning_iteration}.pt"),
|
||||||
|
it=self.current_learning_iteration,
|
||||||
|
last_model=True,
|
||||||
|
)
|
||||||
|
|
||||||
def save(self, path: str, infos: dict | None = None) -> None:
|
def save(self, path: str, it: int, last_model: bool, infos: dict | None = None) -> None:
|
||||||
# Save model
|
# Save model
|
||||||
saved_dict = {
|
saved_dict = {
|
||||||
"model_state_dict": self.alg.policy.state_dict(),
|
"model_state_dict": self.alg.policy.state_dict(),
|
||||||
@@ -148,6 +181,80 @@ class OnPolicyRunnerCTS:
|
|||||||
|
|
||||||
# Upload model to external logging services
|
# Upload model to external logging services
|
||||||
self.logger.save_model(path, self.current_learning_iteration)
|
self.logger.save_model(path, self.current_learning_iteration)
|
||||||
|
self.update_robogauge(it, last_model)
|
||||||
|
|
||||||
|
def update_robogauge(self, it: int, last_model: bool) -> None:
|
||||||
|
if self.robogauge_client is None or self.logger.log_dir is None or self.logger.disable_logs:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
if it % 500 == 0 or last_model:
|
||||||
|
# export jit model
|
||||||
|
jit_dir = os.path.join(self.logger.log_dir, "jit_models")
|
||||||
|
jit_path = os.path.join(jit_dir, f"policy_jit_{it}.pt")
|
||||||
|
export_cts_policy_as_jit(
|
||||||
|
self.alg.policy,
|
||||||
|
actor_obs_normalizer=self.alg.policy.actor_obs_normalizer,
|
||||||
|
single_obs_normalizer=self.alg.policy.single_obs_normalizer,
|
||||||
|
path=jit_dir,
|
||||||
|
filename=f"policy_jit_{it}.pt",
|
||||||
|
)
|
||||||
|
# upload to robogauge
|
||||||
|
self.robogauge_client.submit_task(
|
||||||
|
model_path=jit_path,
|
||||||
|
step=it,
|
||||||
|
task_name="go2_lab",
|
||||||
|
experiment_name=self.cfg["experiment_name"],
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARN] RoboGauge submit failed at step {it}: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
check_times = 1
|
||||||
|
if last_model:
|
||||||
|
check_times = int(1e9) # keep checking until manually stopped
|
||||||
|
|
||||||
|
while check_times > 0:
|
||||||
|
check_times -= 1
|
||||||
|
try:
|
||||||
|
self.robogauge_client.monitor_tasks()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARN] RoboGauge monitor failed at step {it}: {e}")
|
||||||
|
break
|
||||||
|
|
||||||
|
results_dir = os.path.join(self.logger.log_dir, "robogauge_results")
|
||||||
|
os.makedirs(results_dir, exist_ok=True)
|
||||||
|
result_received = False
|
||||||
|
|
||||||
|
for task_id, resp in self.robogauge_client.response_data.items():
|
||||||
|
if not isinstance(resp, dict):
|
||||||
|
print(f"[WARN] RoboGauge returned an invalid response for task {task_id}: {resp}")
|
||||||
|
continue
|
||||||
|
results = resp.get("results")
|
||||||
|
step = resp.get("step", it)
|
||||||
|
if results is None:
|
||||||
|
print(f"[WARN] RoboGauge returned empty results for task {task_id} at step {step}.")
|
||||||
|
continue
|
||||||
|
scores = results.get("scores")
|
||||||
|
if scores is None:
|
||||||
|
print(f"[WARN] RoboGauge results for task {task_id} at step {step} do not contain 'scores'.")
|
||||||
|
continue
|
||||||
|
if step == it:
|
||||||
|
result_received = True
|
||||||
|
if self.logger.writer is not None:
|
||||||
|
for key, val in scores.items():
|
||||||
|
self.logger.writer.add_scalar(f"RoboGauge/{key}", val, step)
|
||||||
|
results_path = os.path.join(results_dir, f"results_{step}.yaml")
|
||||||
|
with open(results_path, "w", encoding="utf-8") as f:
|
||||||
|
yaml.dump(results, f, allow_unicode=True, sort_keys=False)
|
||||||
|
|
||||||
|
if last_model and result_received:
|
||||||
|
print(f"RoboGauge result for step {it} received. Exiting wait loop.")
|
||||||
|
break
|
||||||
|
|
||||||
|
if check_times > 0:
|
||||||
|
print("Sleeping for 1 minute before checking RoboGauge results again...")
|
||||||
|
time.sleep(60) # wait for 1 minute before checking again
|
||||||
|
|
||||||
def load(self, path: str, load_optimizer: bool = True, map_location: str | None = None) -> dict:
|
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)
|
loaded_dict = torch.load(path, weights_only=False, map_location=map_location)
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ from .utils import (
|
|||||||
split_and_pad_trajectories,
|
split_and_pad_trajectories,
|
||||||
unpad_trajectories,
|
unpad_trajectories,
|
||||||
)
|
)
|
||||||
|
from .exporter_cts import export_cts_policy_as_jit
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"export_cts_policy_as_jit",
|
||||||
"get_param",
|
"get_param",
|
||||||
"resolve_callable",
|
"resolve_callable",
|
||||||
"resolve_nn_activation",
|
"resolve_nn_activation",
|
||||||
|
|||||||
98
source/rsl_rl/rsl_rl/utils/exporter_cts.py
Normal file
98
source/rsl_rl/rsl_rl/utils/exporter_cts.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import copy
|
||||||
|
import os
|
||||||
|
import torch
|
||||||
|
|
||||||
|
|
||||||
|
def export_cts_policy_as_jit(
|
||||||
|
policy: object,
|
||||||
|
actor_obs_normalizer: object | None,
|
||||||
|
single_obs_normalizer: object | None,
|
||||||
|
path: str,
|
||||||
|
filename: str = "policy.pt",
|
||||||
|
) -> None:
|
||||||
|
"""Export CTS policy into a Torch JIT file with single_obs input."""
|
||||||
|
policy_exporter = _TorchPolicyExporter(policy, actor_obs_normalizer, single_obs_normalizer)
|
||||||
|
policy_exporter.export(path, filename)
|
||||||
|
|
||||||
|
|
||||||
|
class _TorchPolicyExporter(torch.nn.Module):
|
||||||
|
"""Exporter of CTS actor-critic into JIT file."""
|
||||||
|
|
||||||
|
def __init__(self, policy, actor_obs_normalizer=None, single_obs_normalizer=None):
|
||||||
|
assert not policy.is_recurrent, "CTS policy should not be recurrent"
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
if hasattr(policy, "actor"):
|
||||||
|
self.actor = copy.deepcopy(policy.actor)
|
||||||
|
elif hasattr(policy, "student"):
|
||||||
|
self.actor = copy.deepcopy(policy.student)
|
||||||
|
else:
|
||||||
|
raise ValueError("Policy does not have an actor/student module.")
|
||||||
|
self.student_moe_encoder = copy.deepcopy(policy.student_moe_encoder)
|
||||||
|
self.state_dependent_std = policy.state_dependent_std
|
||||||
|
self.num_actions = int(policy.num_actions)
|
||||||
|
self.num_single_obs = int(policy.num_single_obs)
|
||||||
|
self.num_actor_obs = int(policy.num_actor_obs)
|
||||||
|
if self.num_actor_obs % self.num_single_obs != 0:
|
||||||
|
raise ValueError(
|
||||||
|
f"num_actor_obs ({self.num_actor_obs}) must be divisible by num_single_obs ({self.num_single_obs})."
|
||||||
|
)
|
||||||
|
self.history_len = self.num_actor_obs // self.num_single_obs
|
||||||
|
self.feature_dims = [3, 3, 3, self.num_actions, self.num_actions, self.num_actions]
|
||||||
|
if sum(self.feature_dims) != self.num_single_obs:
|
||||||
|
raise ValueError(
|
||||||
|
"Unsupported single_obs layout: expected 3+3+3+3*num_actions to match num_single_obs."
|
||||||
|
)
|
||||||
|
self.register_buffer("obs_history", torch.zeros(1, self.num_actor_obs, dtype=torch.float32))
|
||||||
|
|
||||||
|
if actor_obs_normalizer:
|
||||||
|
self.actor_obs_normalizer = copy.deepcopy(actor_obs_normalizer)
|
||||||
|
else:
|
||||||
|
self.actor_obs_normalizer = torch.nn.Identity()
|
||||||
|
if single_obs_normalizer:
|
||||||
|
self.single_obs_normalizer = copy.deepcopy(single_obs_normalizer)
|
||||||
|
else:
|
||||||
|
self.single_obs_normalizer = torch.nn.Identity()
|
||||||
|
|
||||||
|
def forward(self, single_obs: torch.Tensor):
|
||||||
|
if single_obs.dim() == 1:
|
||||||
|
single_obs = single_obs.unsqueeze(0)
|
||||||
|
if single_obs.shape[-1] != self.num_single_obs:
|
||||||
|
raise ValueError(
|
||||||
|
f"Expected single_obs last dimension {self.num_single_obs}, got {single_obs.shape[-1]}."
|
||||||
|
)
|
||||||
|
if single_obs.shape[0] != 1:
|
||||||
|
raise ValueError("TorchScript CTS deployment currently supports batch size 1 only.")
|
||||||
|
|
||||||
|
next_history = self.obs_history.clone()
|
||||||
|
history_offset = 0
|
||||||
|
single_offset = 0
|
||||||
|
for dim in self.feature_dims:
|
||||||
|
block_size = dim * self.history_len
|
||||||
|
block_end = history_offset + block_size
|
||||||
|
single_end = single_offset + dim
|
||||||
|
block = self.obs_history[:, history_offset:block_end]
|
||||||
|
shifted_block = torch.cat([block[:, dim:], single_obs[:, single_offset:single_end]], dim=-1)
|
||||||
|
next_history[:, history_offset:block_end] = shifted_block
|
||||||
|
history_offset = block_end
|
||||||
|
single_offset = single_end
|
||||||
|
self.obs_history.copy_(next_history)
|
||||||
|
|
||||||
|
single_obs = self.single_obs_normalizer(single_obs)
|
||||||
|
obs_a = self.actor_obs_normalizer(self.obs_history)
|
||||||
|
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, :]
|
||||||
|
return self.actor(latent_and_obs)
|
||||||
|
|
||||||
|
@torch.jit.export
|
||||||
|
def reset(self):
|
||||||
|
self.obs_history.zero_()
|
||||||
|
|
||||||
|
def export(self, path, filename):
|
||||||
|
os.makedirs(path, exist_ok=True)
|
||||||
|
path = os.path.join(path, filename)
|
||||||
|
self.to("cpu")
|
||||||
|
traced_script_module = torch.jit.script(self)
|
||||||
|
traced_script_module.save(path)
|
||||||
Reference in New Issue
Block a user