337 lines
14 KiB
Python
337 lines
14 KiB
Python
"""V4-upward helpers: sign-aware upright reward, reward clamps and sustained fall.
|
|
|
|
This module is used only by the ``s10-v4-upward`` task. V4 itself keeps its
|
|
baseline exp-upright reward and immediate 70-degree fall termination.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
|
|
import torch
|
|
from mjlab.envs import mdp
|
|
from mjlab.managers import ManagerTermBase
|
|
from mjlab.managers.scene_entity_config import SceneEntityCfg
|
|
|
|
from .world_model import GRAVITY
|
|
|
|
# Raw-term caps (before weight and dt) for the recovery task.
|
|
# Chosen from v4 random-orientation samples: p99/max were approximately
|
|
# leg_posture .78/.86, standstill 5.37/7.90, joint_torques 4772/5016,
|
|
# unnecessary_wheel_lift 750/826, rolling_leg_motion 2.38/4.47.
|
|
UPWARD_REWARD_CLAMPS = {
|
|
"leg_posture": 2.0,
|
|
"standstill": 4.0,
|
|
"action_rate_l2": 4.0,
|
|
"joint_torques_l2": 5000.0,
|
|
"dof_pos_limits": 0.5,
|
|
"unnecessary_wheel_lift": 2.0,
|
|
"rolling_leg_motion": 5.0,
|
|
}
|
|
|
|
# IsaacLab upward: upward max 4 (raw 4 x weight 1), bad-orientation -80,
|
|
# i.e. the bad penalty is 20x the upward maximum. Here upward max is 2
|
|
# (raw 1 x weight 2), so the same ratio is -40.
|
|
BAD_ORIENTATION_WEIGHT = -40.0
|
|
BAD_ORIENTATION_XY = 0.7
|
|
# The old max(gz, |gx|-0.7, |gy|-0.7) severity saturated for gz >= 0.3 and had
|
|
# a local maximum near 105 deg, where the |gx|-0.7 term crossed gz. The reward
|
|
# severity is now a function of the tilt angle only: 0 at 45 deg, 1 at 180 deg,
|
|
# monotonic in between. The correction term steepens the near-threshold part
|
|
# while keeping a positive slope at 180 deg and a finite derivative at 45 deg.
|
|
BAD_ORIENTATION_START_DEG = 45.0
|
|
BAD_ORIENTATION_CORRECTION = 2.5
|
|
|
|
# Per-environment recovery curriculum, IsaacLab terrain-curriculum style.
|
|
# Each env keeps the outcomes of its last HISTORY_LENGTH episodes and advances
|
|
# when at least PROMOTE_SUCCESSES of them recovered (16/20 = 80% > 75%).
|
|
# There is no global batch gate: terrain and tilt levels are per environment.
|
|
TILT_STAGES_DEG = (60.0, 135.0, 180.0)
|
|
HISTORY_LENGTH = 20
|
|
PROMOTE_SUCCESSES = 16
|
|
DEMOTE_SUCCESSES = 5
|
|
|
|
|
|
def upright_upward_value(gravity: torch.Tensor) -> torch.Tensor:
|
|
"""Normalized upward reward in [0, 1].
|
|
|
|
``projected_gravity_b[:, 2]`` is -1 upright, 0 sideways and +1 upside down.
|
|
The old v4 exp-upright term is sign-blind: upside-down also scores 1. This
|
|
linear form is monotonic through the whole range and has a constant gradient
|
|
with respect to gravity_z, which is what the recovery task needs.
|
|
"""
|
|
return (0.5 * (1.0 - gravity[:, 2])).clamp(0.0, 1.0)
|
|
|
|
|
|
def upright_upward(env, asset_cfg: SceneEntityCfg = SceneEntityCfg("robot")) -> torch.Tensor:
|
|
"""Environment reward term replacing v4 ``upright`` for this task."""
|
|
gravity = env.scene[asset_cfg.name].data.projected_gravity_b
|
|
return upright_upward_value(gravity)
|
|
|
|
|
|
def bad_orientation_value(gravity: torch.Tensor) -> torch.Tensor:
|
|
"""Monotonic tilt-based bad-orientation severity in [0, 1].
|
|
|
|
``projected_gravity_b[:, 2]`` is -1 upright and +1 upside down, so
|
|
``tilt = acos(-gz)`` goes from 0 to pi. Severity is 0 below
|
|
``BAD_ORIENTATION_START_DEG``, then increases monotonically to 1 at 180 deg:
|
|
|
|
x = clamp((tilt - 45 deg) / 135 deg, 0, 1)
|
|
severity = x + 2.5 * x * (1 - x)^2
|
|
|
|
Unlike the previous max(gz, |gx|-0.7, |gy|-0.7) formula, this has no
|
|
saturation plateau for deep inversion and no local maximum near 105 deg.
|
|
The polynomial correction is used instead of a fractional power because
|
|
``x ** (1/3)`` has an infinite derivative at x=0 (upright), which would
|
|
produce non-finite gradients during imagination.
|
|
"""
|
|
gz = gravity[:, 2].clamp(-1.0 + 1e-4, 1.0 - 1e-4)
|
|
tilt = torch.acos(-gz)
|
|
start = math.radians(BAD_ORIENTATION_START_DEG)
|
|
x = ((tilt - start) / (math.pi - start)).clamp(0.0, 1.0)
|
|
return x + BAD_ORIENTATION_CORRECTION * x * (1.0 - x).square()
|
|
|
|
|
|
def bad_orientation_mask(gravity: torch.Tensor) -> torch.Tensor:
|
|
"""Binary bad set used by the 4-second termination.
|
|
|
|
This intentionally keeps the IsaacLab-style boundary: upside-down
|
|
hemisphere or |gx|/|gy| beyond 0.7. The reward severity above is a smooth
|
|
tilt-based shaping term and is not used to decide termination.
|
|
"""
|
|
return ((gravity[:, 2] > 0.0)
|
|
| (gravity[:, :2].abs() > BAD_ORIENTATION_XY).any(-1))
|
|
|
|
|
|
def bad_orientation_penalty(env, asset_cfg: SceneEntityCfg = SceneEntityCfg("robot")) -> torch.Tensor:
|
|
"""Continuous per-step bad-orientation penalty used by the environment."""
|
|
gravity = env.scene[asset_cfg.name].data.projected_gravity_b
|
|
return bad_orientation_value(gravity)
|
|
|
|
|
|
def is_upward_task(env) -> bool:
|
|
"""Identify the task from its upright reward function, not from a new flag."""
|
|
rewards = getattr(env.cfg, "rewards", {})
|
|
term = rewards.get("upright") if hasattr(rewards, "get") else None
|
|
return term is not None and getattr(term, "func", None) is upright_upward
|
|
|
|
|
|
def _sample_offsets(range_dict, count: int, device):
|
|
"""Sample x/y/z/roll/pitch/yaw offsets; missing keys are zero."""
|
|
offsets = []
|
|
for key in ("x", "y", "z", "roll", "pitch", "yaw"):
|
|
lo, hi = (range_dict or {}).get(key, (0.0, 0.0))
|
|
lo, hi = float(lo), float(hi)
|
|
if hi <= lo:
|
|
offsets.append(torch.full((count,), lo, device=device))
|
|
else:
|
|
offsets.append(torch.empty(count, device=device).uniform_(lo, hi))
|
|
return torch.stack(offsets, -1)
|
|
|
|
|
|
def reset_root_state_upward(env, env_ids, pose_range, velocity_range=None,
|
|
asset_cfg: SceneEntityCfg = SceneEntityCfg("robot")):
|
|
"""Reset with a per-environment roll/pitch limit.
|
|
|
|
``env._upward_tilt_limits`` is a per-env tensor updated by the curriculum.
|
|
Play/evaluation has no such tensor and falls back to the full range in
|
|
``pose_range`` (the task sets +/-pi there).
|
|
"""
|
|
env_ids = mdp.resolve_env_ids(env, env_ids)
|
|
asset = env.scene[asset_cfg.name]
|
|
count = len(env_ids)
|
|
pose = _sample_offsets(pose_range, count, env.device)
|
|
limits = getattr(env, "_upward_tilt_limits", None)
|
|
if limits is None:
|
|
roll = pose_range.get("roll", (-math.pi, math.pi))
|
|
limit = max(abs(float(roll[0])), abs(float(roll[1])))
|
|
limits = torch.full((count,), limit, device=env.device)
|
|
else:
|
|
limits = limits[env_ids]
|
|
pose[:, 3] = (2.0 * torch.rand(count, device=env.device) - 1.0) * limits
|
|
pose[:, 4] = (2.0 * torch.rand(count, device=env.device) - 1.0) * limits
|
|
default = asset.data.default_root_state[env_ids].clone()
|
|
positions = default[:, 0:3] + pose[:, 0:3] + env.scene.env_origins[env_ids]
|
|
delta = mdp.quat_from_euler_xyz(pose[:, 3], pose[:, 4], pose[:, 5])
|
|
orientations = mdp.quat_mul(default[:, 3:7], delta)
|
|
velocities = default[:, 7:13] + _sample_offsets(velocity_range, count, env.device)
|
|
asset.write_root_link_pose_to_sim(
|
|
torch.cat((positions, orientations), dim=-1), env_ids=env_ids)
|
|
asset.write_root_link_velocity_to_sim(velocities, env_ids=env_ids)
|
|
|
|
|
|
def init_upward_state(env):
|
|
"""Allocate the per-env recovery ring and tilt levels once."""
|
|
if getattr(env, "_upward_history", None) is not None:
|
|
return
|
|
n, device = env.num_envs, env.device
|
|
env._upward_history = torch.zeros(
|
|
n, HISTORY_LENGTH, dtype=torch.bool, device=device)
|
|
env._upward_traverse_history = torch.zeros_like(env._upward_history)
|
|
env._upward_history_cursor = torch.zeros(n, dtype=torch.long, device=device)
|
|
env._upward_history_filled = torch.zeros(n, dtype=torch.long, device=device)
|
|
env._upward_tilt_levels = torch.zeros(n, dtype=torch.long, device=device)
|
|
env._upward_tilt_limit_table = torch.tensor(
|
|
[math.radians(d) for d in TILT_STAGES_DEG], device=device)
|
|
env._upward_tilt_limits = env._upward_tilt_limit_table[0].expand(n).clone()
|
|
|
|
|
|
def upward_curriculum(env, env_ids):
|
|
"""Per-env rolling recovery curriculum (IsaacLab terrain-curriculum style).
|
|
|
|
Each environment stores the outcomes of its last ``HISTORY_LENGTH``
|
|
episodes. ``recovered`` means the episode did not end in a bad-orientation
|
|
termination and the final projected gravity is not in the bad set. An env
|
|
advances when at least ``PROMOTE_SUCCESSES`` of the window recovered and
|
|
drops when at most ``DEMOTE_SUCCESSES`` did. Terrain and tilt levels are
|
|
updated independently per environment; no global batch rate is involved.
|
|
"""
|
|
init_upward_state(env)
|
|
env_ids = mdp.resolve_env_ids(env, env_ids)
|
|
terrain = env.scene.terrain
|
|
data = env.scene["robot"].data
|
|
active = env.episode_length_buf[env_ids] > 0
|
|
fallen = env.termination_manager.terminated[env_ids]
|
|
gravity = data.projected_gravity_b[env_ids]
|
|
offset = data.root_link_pos_w[env_ids] - env.scene.env_origins[env_ids]
|
|
recovered = active & ~fallen & ~bad_orientation_mask(gravity)
|
|
traversed = (active & ~fallen & (offset[:, 0].abs() > 5.4)
|
|
& (offset[:, 1].abs() < 6.5))
|
|
ids = env_ids[active]
|
|
if len(ids):
|
|
cursor = env._upward_history_cursor[ids]
|
|
env._upward_history[ids, cursor] = recovered[active]
|
|
env._upward_traverse_history[ids, cursor] = traversed[active]
|
|
env._upward_history_cursor[ids] = (cursor + 1) % HISTORY_LENGTH
|
|
env._upward_history_filled[ids] = (
|
|
env._upward_history_filled[ids] + 1).clamp_max(HISTORY_LENGTH)
|
|
ready = env._upward_history_filled[env_ids] >= HISTORY_LENGTH
|
|
successes = env._upward_history[env_ids].sum(-1)
|
|
promote = ready & (successes >= PROMOTE_SUCCESSES)
|
|
demote = ready & (successes <= DEMOTE_SUCCESSES)
|
|
delta = promote.long() - demote.long()
|
|
old_levels = terrain.terrain_levels[env_ids].clone()
|
|
levels = (old_levels + delta).clamp(0, terrain.terrain_origins.shape[0] - 1)
|
|
old_tilt_levels = env._upward_tilt_levels[env_ids].clone()
|
|
tilt_levels = (old_tilt_levels + delta).clamp(0, len(TILT_STAGES_DEG) - 1)
|
|
# A changed environment must prove itself for a fresh 20-episode window at
|
|
# the new level; otherwise a long success streak could chain promotions in
|
|
# consecutive episodes. A clamped promote/demote at the min/max level is not
|
|
# a change and must keep its window so the logged rate stays meaningful.
|
|
changed = (levels != old_levels) | (tilt_levels != old_tilt_levels)
|
|
if changed.any():
|
|
changed_ids = env_ids[changed]
|
|
env._upward_history[changed_ids] = False
|
|
env._upward_traverse_history[changed_ids] = False
|
|
env._upward_history_filled[changed_ids] = 0
|
|
env._upward_history_cursor[changed_ids] = 0
|
|
terrain.terrain_levels[env_ids] = levels
|
|
terrain.env_origins[env_ids] = terrain.terrain_origins[
|
|
levels, terrain.terrain_types[env_ids]]
|
|
env._upward_tilt_levels[env_ids] = tilt_levels
|
|
env._upward_tilt_limits[env_ids] = env._upward_tilt_limit_table[tilt_levels]
|
|
return terrain.terrain_levels.float().mean()
|
|
|
|
|
|
def upward_curriculum_stats(env):
|
|
"""Summary for logging and for choosing the training-video tilt level."""
|
|
init_upward_state(env)
|
|
levels = env._upward_tilt_levels
|
|
terrain_levels = env.scene.terrain.terrain_levels
|
|
ready = env._upward_history_filled >= HISTORY_LENGTH
|
|
recovery = env._upward_history.sum(-1)
|
|
traverse = env._upward_traverse_history.sum(-1)
|
|
ready_count = int(ready.sum())
|
|
recovery_rate = (float(recovery[ready].float().mean().item()) / HISTORY_LENGTH
|
|
if ready_count else 0.0)
|
|
traverse_rate = (float(traverse[ready].float().mean().item()) / HISTORY_LENGTH
|
|
if ready_count else 0.0)
|
|
def fractions(values, count):
|
|
return [float((values == i).float().mean().item()) for i in range(count)]
|
|
q75 = int(torch.quantile(levels.float(), 0.75).round().item())
|
|
return {
|
|
"tilt_level_mean": float(levels.float().mean().item()),
|
|
"tilt_level_max": int(levels.max().item()),
|
|
"tilt_level_q75": q75,
|
|
"tilt_level_frac": fractions(levels, len(TILT_STAGES_DEG)),
|
|
"tilt_limit_mean_deg": float(
|
|
env._upward_tilt_limits.mean().item() * 180.0 / math.pi),
|
|
"terrain_level_frac": fractions(
|
|
terrain_levels, int(env.scene.terrain.terrain_origins.shape[0])),
|
|
"recovery_window_rate": recovery_rate,
|
|
"traverse_window_rate": traverse_rate,
|
|
"recovery_window_ready": float(ready.float().mean().item()),
|
|
}
|
|
|
|
|
|
def upward_video_tilt_limit(env):
|
|
"""Tilt limit reached by at least 75% of environments, for the video scene."""
|
|
q75 = upward_curriculum_stats(env)["tilt_level_q75"]
|
|
return math.radians(TILT_STAGES_DEG[q75])
|
|
|
|
|
|
def _cap(value: torch.Tensor, max_value: float) -> torch.Tensor:
|
|
return value.clamp(max=float(max_value))
|
|
|
|
|
|
def clamped_leg_posture(env, asset_cfg: SceneEntityCfg, max_value: float) -> torch.Tensor:
|
|
from .terrain_posture import env_posture_cost
|
|
return _cap(env_posture_cost(env, asset_cfg), max_value)
|
|
|
|
|
|
def clamped_standstill(env, wheel_cfg: SceneEntityCfg, max_value: float) -> torch.Tensor:
|
|
from s10_supermimic.env.flat import standstill_cost
|
|
return _cap(standstill_cost(env, wheel_cfg), max_value)
|
|
|
|
|
|
def clamped_action_rate(env, max_value: float) -> torch.Tensor:
|
|
return _cap(mdp.action_rate_l2(env), max_value)
|
|
|
|
|
|
def clamped_joint_torques(env, max_value: float) -> torch.Tensor:
|
|
return _cap(mdp.joint_torques_l2(env), max_value)
|
|
|
|
|
|
def clamped_dof_pos_limits(env, asset_cfg: SceneEntityCfg, max_value: float) -> torch.Tensor:
|
|
return _cap(mdp.joint_pos_limits(env, asset_cfg), max_value)
|
|
|
|
|
|
def clamped_rolling_cost(env, term: str, max_value: float) -> torch.Tensor:
|
|
from .rolling_rewards import env_rolling_cost
|
|
return _cap(env_rolling_cost(env, term), max_value)
|
|
|
|
|
|
def apply_reward_clamps(terms: dict[str, torch.Tensor], constants: dict) -> None:
|
|
"""Apply the same raw caps to imagined rewards used by the env task."""
|
|
for name, max_value in constants.get("reward_clamps", {}).items():
|
|
if name in terms:
|
|
terms[name] = terms[name].clamp(max=float(max_value))
|
|
|
|
|
|
class BadOrientationSustained(ManagerTermBase):
|
|
"""Terminate only after bad orientation persists for ``duration_s``.
|
|
|
|
This is the recovery-task state machine: a random roll/pitch spawn is allowed
|
|
to be bad for a few seconds, but the episode ends if the robot never rights
|
|
itself. Functionally it replaces v4's immediate 70-degree ``fell_over``.
|
|
"""
|
|
|
|
def __init__(self, cfg, env):
|
|
super().__init__(env)
|
|
self.steps = torch.zeros(env.num_envs, dtype=torch.long, device=env.device)
|
|
|
|
def __call__(self, env, duration_s: float, asset_cfg: SceneEntityCfg) -> torch.Tensor:
|
|
gravity = env.scene[asset_cfg.name].data.projected_gravity_b
|
|
# Termination keeps the binary IsaacLab-style bad set; the reward severity
|
|
# above is only a smooth shaping term.
|
|
bad = bad_orientation_mask(gravity)
|
|
self.steps = torch.where(bad, self.steps + 1, torch.zeros_like(self.steps))
|
|
duration_steps = math.ceil(duration_s / env.step_dt)
|
|
return self.steps >= duration_steps
|
|
|
|
def reset(self, env_ids=None) -> None:
|
|
if env_ids is None:
|
|
self.steps.zero_()
|
|
else:
|
|
self.steps[env_ids] = 0
|