Initial commit

This commit is contained in:
2026-11-02 17:10:39 +08:00
commit 102fdbaf47
79 changed files with 56509 additions and 0 deletions
+336
View File
@@ -0,0 +1,336 @@
"""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