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
+151
View File
@@ -0,0 +1,151 @@
"""Standalone S10 MJCF/FK checks that do not require mjlab or torch.
Run with the system Python:
python3 tests/test_s10_model.py
The test validates:
1. The S10 model in ``s10_supermimic/robot/model/s10.xml`` loads.
2. Joint names match the training/deployment contract.
3. The default pose puts the wheel bottom at ~0.434 m above the ground.
4. The analytic wheel-bottom formula in ``core/rolling_rewards.py`` matches
MuJoCo forward kinematics for random joint angles and arbitrary base tilt.
"""
from __future__ import annotations
import math
from pathlib import Path
import mujoco
import numpy as np
ROOT = Path(__file__).resolve().parents[1]
MODEL = ROOT / "s10_supermimic" / "robot" / "model" / "s10.xml"
LEGS = ("fl", "fr", "hl", "hr")
LEG_JOINT_NAMES = tuple(
f"{leg}_{joint}_joint" for leg in LEGS for joint in ("hipx", "hipy", "knee")
)
WHEEL_JOINT_NAMES = tuple(f"{leg}_wheel_joint" for leg in LEGS)
DEFAULT_Q = {
"fl": (0.1, -0.35, 0.65),
"fr": (-0.1, -0.35, 0.65),
"hl": (0.1, 0.35, -0.65),
"hr": (-0.1, 0.35, -0.65),
}
# Geometry from S10.xml.
X0 = {"fl": 0.2277, "fr": 0.2277, "hl": -0.2277, "hr": -0.2277}
Y0 = {"fl": 0.0574, "fr": -0.0574, "hl": 0.0574, "hr": -0.0574}
SIDE = {"fl": 1.0, "fr": -1.0, "hl": 1.0, "hr": -1.0}
HIPY_Y = 0.0779
WHEEL_Y = 0.045891
LINK = 0.18
WHEEL_RADIUS = 0.081
WHEEL_HALF_WIDTH = 0.0185
def analytic_wheel_bottom(q, leg, base_z=0.5, up=(0.0, 0.0, 1.0)) -> float:
"""NumPy translation of the torch implementation in rolling_rewards.py."""
hipx, hipy, knee = (float(v) for v in q)
side = SIDE[leg]
x = LINK * math.sin(knee)
z = -LINK - LINK * math.cos(knee)
x3 = math.cos(hipy) * x - math.sin(hipy) * z
z3 = math.sin(hipy) * x + math.cos(hipy) * z
y3 = side * (HIPY_Y + WHEEL_Y)
y4 = math.cos(hipx) * y3 + math.sin(hipx) * z3
z4 = -math.sin(hipx) * y3 + math.cos(hipx) * z3
point = np.array([X0[leg] + x3, Y0[leg] + y4, z4])
up = np.asarray(up, dtype=float)
up /= np.linalg.norm(up)
axle_z = -up[1] * math.cos(hipx) + up[2] * math.sin(hipx)
radius_z = WHEEL_RADIUS * math.sqrt(max(1.0 - axle_z * axle_z, 0.0))
radius_z += WHEEL_HALF_WIDTH * abs(axle_z)
return base_z + float(point @ up) - radius_z
def wheel_geom_id(model: mujoco.MjModel, leg: str) -> int:
body_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, f"{leg}_wheel")
for geom_id in range(model.ngeom):
if model.geom_bodyid[geom_id] != body_id:
continue
if model.geom_type[geom_id] == mujoco.mjtGeom.mjGEOM_CYLINDER:
return geom_id
raise AssertionError(f"No cylinder collision geom for {leg}_wheel")
def measured_wheel_bottom(model, data, geom_id: int) -> float:
axis = data.geom_xmat[geom_id].reshape(3, 3)[:, 2]
axle_z = abs(float(axis[2]))
radius_z = WHEEL_RADIUS * math.sqrt(max(1.0 - axle_z * axle_z, 0.0))
radius_z += WHEEL_HALF_WIDTH * axle_z
return float(data.geom_xpos[geom_id][2] - radius_z)
def set_pose(model, data, q_map, base_z=0.5, quat=(1.0, 0.0, 0.0, 0.0)):
data.qpos[:] = 0.0
data.qpos[2] = base_z
data.qpos[3:7] = quat
for joint_name, value in q_map.items():
joint_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, joint_name)
data.qpos[model.jnt_qposadr[joint_id]] = value
mujoco.mj_forward(model, data)
def main() -> None:
assert MODEL.is_file(), MODEL
model = mujoco.MjModel.from_xml_path(str(MODEL))
data = mujoco.MjData(model)
joint_names = [model.joint(i).name for i in range(model.njnt)]
expected = ("floating_base",) + LEG_JOINT_NAMES + WHEEL_JOINT_NAMES
assert set(joint_names) == set(expected), joint_names
assert model.nq == 23 and model.nv == 22 and model.njnt == 17
assert model.nu == 0, "Robot XML must not contain actuators; mjlab adds them."
default_q = {
f"{leg}_{joint}_joint": value
for leg in LEGS
for joint, value in zip(("hipx", "hipy", "knee"), DEFAULT_Q[leg])
}
set_pose(model, data, default_q)
for leg in LEGS:
geom_id = wheel_geom_id(model, leg)
bottom = measured_wheel_bottom(model, data, geom_id)
pred = analytic_wheel_bottom(DEFAULT_Q[leg], leg)
assert abs(bottom - pred) < 1e-9, (leg, bottom, pred)
assert abs((0.5 - bottom) - 0.434145) < 1e-4, (leg, bottom)
rng = np.random.default_rng(0)
max_error = 0.0
for _ in range(200):
quat = rng.normal(size=4)
quat /= np.linalg.norm(quat)
q_map = {}
q_by_leg = {}
for leg in LEGS:
q = np.array([
rng.uniform(-0.6109, 0.6109),
rng.uniform(-2.5307, 2.5307),
rng.uniform(-2.7227, 2.7227),
])
q_by_leg[leg] = q
for joint, value in zip(("hipx", "hipy", "knee"), q):
q_map[f"{leg}_{joint}_joint"] = value
set_pose(model, data, q_map, base_z=0.5, quat=quat)
rotation = data.xmat[
mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, "base_link")
].reshape(3, 3)
up = rotation.T @ np.array([0.0, 0.0, 1.0])
for leg in LEGS:
geom_id = wheel_geom_id(model, leg)
measured = measured_wheel_bottom(model, data, geom_id)
pred = analytic_wheel_bottom(q_by_leg[leg], leg, base_z=0.5, up=up)
max_error = max(max_error, abs(measured - pred))
assert max_error < 1e-9, max_error
print(f"S10 model/FK checks passed (max error {max_error:.2e} m)")
if __name__ == "__main__":
main()
+91
View File
@@ -0,0 +1,91 @@
"""Task registry and dual Actor observation schema checks (no mjlab needed)."""
import math
import torch
from s10_supermimic.core.command_curriculum import stages_for
from s10_supermimic.core.policy import Actor, LEGACY_FRAME_DIM, FRAME_DIM, frame, push_history
from s10_supermimic.core.wheel_intrusion import pair_body_intrusion, wheel_body_intrusion
from s10_supermimic.tasks import get_task, task_names
def main():
assert task_names() == ("s10-v4", "s10-v4-upward", "s10-v7"), task_names()
v4, v7, upward = get_task("s10-v4"), get_task("s10-v7"), get_task("s10-v4-upward")
assert (v4.terrain_revision, v7.terrain_revision) == (4, 7)
assert upward.terrain_revision == 4
assert upward.actor.frame_dim == LEGACY_FRAME_DIM
assert upward.actor.wheel_vel
assert upward.courses is False and upward.collisions is False
assert upward.defaults["iterations"] == 30000
assert upward.defaults["action_noise"] == 0.22
assert upward.defaults["horizon"] == 64
assert (v4.actor.frame_dim, v7.actor.frame_dim) == (LEGACY_FRAME_DIM, LEGACY_FRAME_DIM)
assert (v4.actor.history_dim(5), v7.actor.history_dim(5)) == (265, 265)
assert v4.actor.wheel_vel and v7.actor.wheel_vel
assert v7.actor.name == "s10-v7-53"
assert v4.courses is False and v4.collisions is False
assert v7.courses and v7.collisions
assert v7.defaults["imag_batch"] == 2048, "v7 keeps the 16 GB batch-size exception"
assert v7.defaults["iterations"] == 40000
assert v7.defaults["command_curriculum"] is True, "v7 uses stair-gated stages capped at 1.5/0.8/1.2"
assert stages_for(7)[-1][1:] == ((-1.5, 1.5), (-.8, .8), (-1.2, 1.2))
assert stages_for(4)[-1][1:] == ((-2., 2.), (-1., 1.), (-3., 3.))
assert v4.defaults["iterations"] == 20000
state = torch.zeros(2, 38)
action = torch.zeros(2, 16)
command = torch.zeros(2, 3)
bias = torch.zeros(2, 12)
for history, terms, expected in ((5, v4.actor.terms, 265), (5, v7.actor.terms, 265)):
actor = Actor(history, actor_wheel_vel=True)
latest = frame(state, action, command, bias,
wheel_vel=actor.actor_wheel_vel)
assert latest.shape == (2, actor.frame_dim)
assert push_history(torch.zeros(2, expected), latest, history, terms).shape == (2, expected)
legacy = Actor(5, actor_wheel_vel=False)
assert legacy.frame_dim == FRAME_DIM, "legacy 49-value checkpoints remain loadable"
# v7 safe stance: default hipx 0.05 / hipy 25 deg / knee 1.0 has zero
# intrusion; a wheel tucked under the body crosses the 1/4 boundary.
state = torch.zeros(1, 38)
state[0, 0], state[0, 3] = 0.44, -1.0
def default_q(hipy, knee=1.0):
return torch.tensor([0.05, -hipy, knee, -0.05, -hipy, knee,
0.05, hipy, -knee, -0.05, hipy, -knee])
assert wheel_body_intrusion(state, default_q(math.radians(25.0))).item() == 0.0
# 0.8 rad splay puts the wheel near x=0.134 m: inside the new outer-quarter
# boundary (0.15 m), outside the old outer-third boundary (0.10 m).
assert wheel_body_intrusion(state, default_q(.8)).item() > 0.0
normal = wheel_body_intrusion(state, default_q(.5)).item()
tucked = wheel_body_intrusion(state, default_q(1.0)).item()
assert normal == 0.0 < tucked, (normal, tucked)
# Pair 5/6 restriction: zero during the 0.5 s grace, then active only when
# both same-side legs are inside. A single leg never triggers it.
timers = torch.zeros(1, 4)
for _ in range(24):
cost, timers = pair_body_intrusion(state, default_q(1.0), timers, .02)
assert cost.item() == 0.0
cost, timers = pair_body_intrusion(state, default_q(1.0), timers, .02)
assert cost.item() > 0.0, cost
# Front-left alone stays inside; the pair timer stays unarmed.
one_leg = default_q(math.radians(25.0))
one_leg[1] = -1.0
timers = torch.zeros(1, 4)
for _ in range(40):
cost, timers = pair_body_intrusion(state, one_leg, timers, .02)
assert cost.item() == 0.0
assert timers[0, 1].item() == 0.0, timers
# Leaving the region resets that wheel's timer.
timers = torch.zeros(1, 4)
for _ in range(30):
_, timers = pair_body_intrusion(state, default_q(1.0), timers, .02)
cost, timers = pair_body_intrusion(state, default_q(math.radians(25.0)), timers, .02)
assert cost.item() == 0.0 and timers[0, 0].item() == 0.0, (cost, timers)
print("Task registry / Actor schema / v7 safety checks passed")
if __name__ == "__main__":
main()
+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()