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
+236
View File
@@ -0,0 +1,236 @@
"""Pure-torch checks for the upward recovery reward and per-env curriculum."""
import math
import torch
from s10_supermimic.core.imagine import continuation_value
from s10_supermimic.core.upward import (
BAD_ORIENTATION_START_DEG,
HISTORY_LENGTH,
PROMOTE_SUCCESSES,
TILT_STAGES_DEG,
bad_orientation_mask,
bad_orientation_value,
init_upward_state,
reset_root_state_upward,
upward_curriculum,
upward_curriculum_stats,
upward_video_tilt_limit,
)
from s10_supermimic.core.world_model import GRAVITY
def gravity_at(deg: float, direction: float = 0.0) -> torch.Tensor:
"""Projected gravity for a tilt ``deg`` in a fixed horizontal direction."""
t = math.radians(deg)
return torch.tensor([[
math.sin(t) * math.cos(direction),
math.sin(t) * math.sin(direction),
-math.cos(t),
]])
def test_severity_is_monotonic() -> None:
values = torch.cat([bad_orientation_value(gravity_at(d)) for d in
torch.arange(0.0, 180.5, 0.5).tolist()])
assert (values[1:] >= values[:-1] - 1e-6).all(), "severity must be monotonic"
assert values.max() <= 1.0 + 1e-5, values.max()
assert values.min() >= 0.0, values.min()
assert bad_orientation_value(gravity_at(BAD_ORIENTATION_START_DEG)).item() < 1e-6
assert bad_orientation_value(gravity_at(180.0)).item() > 0.99
v105 = bad_orientation_value(gravity_at(105.0)).item()
v110 = bad_orientation_value(gravity_at(110.0)).item()
v120 = bad_orientation_value(gravity_at(120.0)).item()
assert v105 < v110 < v120, (v105, v110, v120)
def test_severity_gradient_is_finite() -> None:
for g in (gravity_at(0.0), gravity_at(90.0), gravity_at(180.0)):
x = g.clone().requires_grad_(True)
bad_orientation_value(x).sum().backward()
assert torch.isfinite(x.grad).all(), x.grad
def test_mask_keeps_bad_set() -> None:
assert bad_orientation_mask(gravity_at(60.0)).item()
assert not bad_orientation_mask(gravity_at(30.0)).item()
assert not bad_orientation_mask(gravity_at(50.0, math.pi / 4)).item()
assert bad_orientation_mask(gravity_at(180.0)).item()
def test_upward_continuation_does_not_use_v4_fall_proxy() -> None:
state = torch.zeros(3, 38)
state[0, GRAVITY] = torch.tensor([0.0, 0.0, -1.0])
state[1, GRAVITY] = torch.tensor([0.0, 0.0, 0.0])
state[2, GRAVITY] = torch.tensor([0.0, 0.0, 1.0])
upward = continuation_value(state, {"upright_mode": "upward"})
v4 = continuation_value(state, {})
assert torch.allclose(upward, torch.ones(3)), upward
assert v4[0] > 0.99 and v4[2] < 1e-4, v4
class _Data:
def __init__(self, n: int):
self.root_link_pos_w = torch.zeros(n, 3)
self.projected_gravity_b = torch.tensor([[0.0, 0.0, -1.0]] * n)
class _Robot:
def __init__(self, n: int):
self.data = _Data(n)
class _Terrain:
def __init__(self, n: int):
self.terrain_levels = torch.zeros(n, dtype=torch.long)
self.terrain_types = torch.zeros(n, dtype=torch.long)
self.terrain_origins = torch.zeros(6, 1, 3)
self.env_origins = torch.zeros(n, 3)
class _Scene:
def __init__(self, n: int):
self.robot = _Robot(n)
self.terrain = _Terrain(n)
self.env_origins = torch.zeros(n, 3)
def __getitem__(self, key):
return self.robot
class _Termination:
def __init__(self, n: int):
self.terminated = torch.zeros(n, dtype=torch.bool)
class _Env:
def __init__(self, n: int):
self.num_envs = n
self.device = "cpu"
self.scene = _Scene(n)
self.episode_length_buf = torch.ones(n, dtype=torch.long)
self.termination_manager = _Termination(n)
def _episode(env: _Env, recovered: torch.Tensor) -> None:
"""Advance one finished episode for every env and run the curriculum."""
env.termination_manager.terminated[:] = ~recovered
env.scene.robot.data.projected_gravity_b[:] = torch.where(
recovered[:, None],
torch.tensor([0.0, 0.0, -1.0]),
torch.tensor([0.0, 0.0, 1.0]))
upward_curriculum(env, torch.arange(env.num_envs))
def test_per_env_rolling_window_advances_and_demotes() -> None:
env = _Env(4)
outcomes = torch.zeros(4, dtype=torch.bool)
for episode in range(HISTORY_LENGTH):
outcomes[0] = True # 20/20 -> advance
outcomes[1] = False # 0/20 -> stays at level 0
outcomes[2] = episode < 10 # 10/20 -> neutral
outcomes[3] = episode < PROMOTE_SUCCESSES # exactly 16/20 -> advance
_episode(env, outcomes)
stats = upward_curriculum_stats(env)
assert env._upward_tilt_levels.tolist() == [1, 0, 0, 1]
assert env.scene.terrain.terrain_levels.tolist() == [1, 0, 0, 1]
assert stats["tilt_level_q75"] == 1
# Advanced envs reset their window and need 20 new episodes; only envs 1
# (0/20) and 2 (10/20) are ready after the first block.
assert abs(stats["recovery_window_rate"] - 0.25) < 1e-6
assert abs(stats["recovery_window_ready"] - 0.5) < 1e-6
assert math.isclose(upward_video_tilt_limit(env), math.radians(135.0))
# The history is per-env: env 1 now gets 20 successes and advances alone;
# env 2 stays neutral with 10 successes.
for episode in range(HISTORY_LENGTH):
outcomes[0] = True
outcomes[1] = True
outcomes[2] = episode < 10
outcomes[3] = True
_episode(env, outcomes)
assert env._upward_tilt_levels.tolist() == [2, 1, 0, 2]
assert env.scene.terrain.terrain_levels.tolist() == [2, 1, 0, 2]
# A full 20-episode failure window demotes env 0 from level 2 to level 1.
for _ in range(HISTORY_LENGTH):
outcomes[:] = False
outcomes[3] = True # keep env 3 at level 2
_episode(env, outcomes)
assert env._upward_tilt_levels.tolist() == [1, 0, 0, 2]
# Terrain has six levels and is independent of the three tilt levels.
assert env.scene.terrain.terrain_levels.tolist() == [1, 0, 0, 3]
# Before 20 episodes no env is eligible to move.
fresh = _Env(4)
stats = upward_curriculum_stats(fresh)
assert stats["recovery_window_ready"] == 0.0
assert stats["tilt_level_q75"] == 0
outcomes = torch.ones(4, dtype=torch.bool)
for _ in range(HISTORY_LENGTH - 1):
_episode(fresh, outcomes)
assert fresh._upward_tilt_levels.tolist() == [0, 0, 0, 0]
_episode(fresh, outcomes)
assert fresh._upward_tilt_levels.tolist() == [1, 1, 1, 1]
class _Asset:
def __init__(self, n: int):
self.data = type("Data", (), {})()
self.data.default_root_state = torch.zeros(n, 13)
self.data.default_root_state[:, 3] = 1.0 # identity wxyz quaternion
self.pose = None
self.velocity = None
def write_root_link_pose_to_sim(self, pose, env_ids):
self.pose = pose.clone()
def write_root_link_velocity_to_sim(self, velocity, env_ids):
self.velocity = velocity.clone()
class _ResetScene:
def __init__(self, n: int):
self.asset = _Asset(n)
self.env_origins = torch.zeros(n, 3)
def __getitem__(self, key):
return self.asset
class _ResetEnv:
def __init__(self, n: int):
self.num_envs = n
self.device = "cpu"
self.scene = _ResetScene(n)
def test_reset_supports_per_env_and_play_fallback_limits() -> None:
env = _ResetEnv(2)
pose_range = {"x": (-0.1, 0.1), "y": (-0.1, 0.1), "z": (0.0, 0.2),
"roll": (-math.pi, math.pi), "pitch": (-math.pi, math.pi),
"yaw": (-math.pi, math.pi)}
ids = torch.arange(2)
reset_root_state_upward(env, ids, pose_range)
assert env.scene.asset.pose.shape == (2, 7)
assert env.scene.asset.velocity.shape == (2, 6)
assert torch.isfinite(env.scene.asset.pose).all()
env._upward_tilt_limits = torch.tensor(
[math.radians(60.0), math.radians(180.0)])
reset_root_state_upward(env, ids, pose_range)
assert torch.isfinite(env.scene.asset.pose).all()
def main() -> None:
test_severity_is_monotonic()
test_severity_gradient_is_finite()
test_mask_keeps_bad_set()
test_upward_continuation_does_not_use_v4_fall_proxy()
test_per_env_rolling_window_advances_and_demotes()
test_reset_supports_per_env_and_play_fallback_limits()
print("Upward reward / continuation / per-env curriculum checks passed")
if __name__ == "__main__":
main()