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
+1
View File
@@ -0,0 +1 @@
"""S10 policies, world models, replay, imagined rollouts and control UI."""
+88
View File
@@ -0,0 +1,88 @@
"""Explicit name-based mapping between mjlab and canonical S10 model tensors."""
import torch
from s10_supermimic.robot.config import (
LEG_JOINT_NAMES, WHEEL_BODY_NAMES, WHEEL_GEOM_NAMES, WHEEL_JOINT_NAMES,
)
from .world_model import STATE_DIM, ACTION_DIM
class S10Bridge:
def __init__(self, env):
self.env = env
self.robot = env.scene["robot"]
self.legs, _ = self.robot.find_joints(LEG_JOINT_NAMES, preserve_order=True)
self.wheels, _ = self.robot.find_joints(WHEEL_JOINT_NAMES, preserve_order=True)
if len(self.legs) != 12 or len(self.wheels) != 4:
raise ValueError("S10 requires 12 leg joints and 4 wheel joints")
pos = env.action_manager.get_term("joint_pos")
vel = env.action_manager.get_term("wheel_vel")
if list(env.cfg.actions) != ["joint_pos", "wheel_vel"]:
raise ValueError("Expected joint_pos then wheel_vel action terms")
names = list(pos.target_names) + list(vel.target_names)
canonical = LEG_JOINT_NAMES + WHEEL_JOINT_NAMES
if len(names) != ACTION_DIM or set(names) != set(canonical):
raise ValueError("Unexpected S10 action targets")
self.to_canonical = [names.index(n) for n in canonical]
self.to_env = [canonical.index(n) for n in names]
geoms, _ = self.robot.find_geoms(WHEEL_GEOM_NAMES, preserve_order=True)
self.geoms = self.robot.indexing.geom_ids[geoms]
body, _ = self.robot.find_bodies("base_link")
self.base = self.robot.indexing.body_ids[body[0]]
# XML reference, not an already-randomized simulator value after reset.
from s10_supermimic.robot.config import get_spec
self.nominal_com = torch.as_tensor(get_spec().body("base_link").ipos.copy(),
device=env.device, dtype=torch.float32)
def canonical_action(self, action):
return action[:, self.to_canonical]
def env_action(self, action):
return action[:, self.to_env]
def state(self):
d = self.robot.data
s = torch.cat((d.root_link_pos_w[:, 2:3], d.projected_gravity_b,
d.root_link_lin_vel_b, d.root_link_ang_vel_b,
(d.joint_pos - d.default_joint_pos)[:, self.legs],
d.joint_vel[:, self.legs], d.joint_vel[:, self.wheels]), -1)
assert s.shape[-1] == STATE_DIM
return s
def pose(self):
d = self.robot.data
return torch.cat((d.root_link_pos_w[:, :2], d.heading_w[:, None]), -1)
def context(self):
model = self.env.sim.model
friction = model.geom_friction
com = model.body_ipos
mu = friction[:, self.geoms, 0] if friction.ndim == 3 else friction[self.geoms, 0].expand(self.env.num_envs, -1)
offset = com[:, self.base] if com.ndim == 3 else com[self.base].expand(self.env.num_envs, -1)
return torch.cat((mu, offset - self.nominal_com), -1)
def contacts(self):
"""Endpoint targets: contact flags4, net wheel forces12 in base frame (N).
Explicit names prevent silent FL/FR swaps in sensor expansion order.
Terminal/reset endpoints are excluded by replay validity in the learner.
"""
from mjlab.utils.lab_api.math import quat_apply_inverse
sensor = self.env.scene["wheel_ground_contact"]
names = [name.split('/')[-1] for name in sensor.primary_names]
order = [names.index(name) for name in WHEEL_BODY_NAMES]
found = (sensor.data.found[:,order] > 0).float().reshape(-1,4)
force = sensor.data.force[:,order].reshape(-1,4,3)
quat = self.robot.data.root_link_quat_w[:,None,:].expand(-1,4,-1)
force = quat_apply_inverse(quat.reshape(-1,4),force.reshape(-1,3)).reshape(-1,12)
return torch.cat((found,force),-1)
def pose_delta(before, after):
dxy = after[:, :2] - before[:, :2]
c, s = before[:, 2].cos(), before[:, 2].sin()
yaw = after[:, 2] - before[:, 2]
return torch.stack((c * dxy[:, 0] + s * dxy[:, 1],
-s * dxy[:, 0] + c * dxy[:, 1],
torch.atan2(yaw.sin(), yaw.cos())), -1)
+24
View File
@@ -0,0 +1,24 @@
"""Non-wheel collision termination and pre-reset labels for imagined survival."""
import torch
def illegal_nonwheel_contact(env, force_threshold=10.):
data = env.scene['nonwheel_ground_contact'].data
force = data.force_history if data.force_history is not None else data.force
finite = torch.isfinite(force).flatten(1).all(-1)
magnitude = torch.nan_to_num(force, nan=0., posinf=0., neginf=0.).norm(dim=-1)
peak = magnitude.flatten(1).amax(-1)
hit = (peak > force_threshold) & finite
# Scene/termination buffers are cleared by automatic reset. These copies
# survive it, so the learner retains the transition that caused termination.
env._nonwheel_hit = hit.detach().clone()
env._nonwheel_label_valid = finite.detach().clone()
env._nonwheel_peak = peak.detach().clone()
return hit
def collision_events(batch):
valid = batch['valid'].squeeze(-1).bool()
prefix = torch.cat((torch.ones_like(valid[:,:1]), valid.cumprod(1).bool()[:,:-1]),1)
return ((batch['collision'].squeeze(-1) > .5)
& batch['collision_valid'].squeeze(-1).bool() & prefix).any(-1)
+32
View File
@@ -0,0 +1,32 @@
"""Velocity range presets. V4 is time-based; v5/v6/v7 are stair-gated.
S10 v7 uses ``V7_STAGES`` as the 1.5/0.8/1.2 cap, but stage advancement comes
from ``stair_curriculum.next_stage`` (actual up-stair pass rate). Only v4
advances by iteration count alone.
"""
STAGES = (
(0, (-.3,.8), (-.2,.2), (-.8,.8)),
(1000, (-.5,1.), (-.4,.4), (-1.,1.)),
(2500, (-.8,1.5), (-.6,.6), (-1.5,1.5)),
(5000, (-1.5,1.8), (-.8,.8), (-2.,2.)),
(7500, (-2.,2.), (-1.,1.), (-3.,3.)),
)
V7_STAGES = (
(0, (-.3,.8), (-.2,.2), (-.5,.5)),
(1000, (-.5,1.), (-.4,.4), (-.7,.7)),
(2500, (-.8,1.2), (-.6,.6), (-.9,.9)),
(5000, (-1.2,1.4), (-.7,.7), (-1.1,1.1)),
(7500, (-1.5,1.5), (-.8,.8), (-1.2,1.2)),
)
def stages_for(revision):
return V7_STAGES if revision >= 7 else STAGES
def set_command_stage(cfg, iteration, stage=None, stages=STAGES):
index = stage if stage is not None else max(
i for i, item in enumerate(stages) if max(0, iteration) >= item[0])
_, x, y, yaw = stages[index]
cfg.ranges.lin_vel_x, cfg.ranges.lin_vel_y, cfg.ranges.ang_vel_z = x, y, yaw
return index
+114
View File
@@ -0,0 +1,114 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>S10 · 运行控制</title>
<style>
:root{color-scheme:dark;font-family:system-ui,-apple-system,sans-serif;background:#10141b;color:#e4eaf3}*{box-sizing:border-box}body{margin:0;padding:28px}header{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px}h1{font-size:25px;margin:0}header small{color:#93a1b5;font-size:13px}.status{background:#202a36;padding:8px 14px;border-radius:20px;color:#a2b7cb;font-size:13px}main{display:grid;grid-template-columns:minmax(400px,1fr) 350px;gap:22px;max-width:1600px;margin:auto}.card{background:#19212d;border:1px solid #2c3748;border-radius:16px;overflow:hidden}.viewer{background:#080b10;display:flex;align-items:center;justify-content:center;min-height:400px}.viewer img{display:block;width:100%;aspect-ratio:4/3;object-fit:contain}.caption{padding:16px;color:#9cabc0;font-size:13px}.panel{padding:24px}h2{font-size:18px;margin:0 0 8px}.muted{color:#9cabc0;font-size:13px;line-height:1.7}.slider{margin:26px 0}.slider label{display:flex;justify-content:space-between;align-items:center;font-size:14px}output{font:600 21px ui-monospace,monospace;color:#64debf}input[type=range]{width:100%;accent-color:#58cfb1;margin:17px 0 5px;cursor:pointer}.bounds{display:flex;justify-content:space-between;color:#8292a7;font-size:12px}.actions{display:flex;gap:10px;margin:12px 0}button{cursor:pointer;border:1px solid #415067;border-radius:9px;background:#273448;color:#edf3fa;padding:11px 14px;font:inherit;font-size:14px;flex:1}button:hover{filter:brightness(1.2)}button.stop{background:#94404c;border-color:#c05b69;width:100%;font-weight:600}.telemetry{border-top:1px solid #344054;padding-top:20px;margin-top:24px}.metric{display:flex;justify-content:space-between;padding:8px 0;font-size:14px}.metric b{font-family:ui-monospace,monospace}#wm{font-size:11px;max-width:210px;text-align:right;white-space:normal;line-height:1.35}.dot{color:#64debf}.note{margin-top:20px;font-size:12px;color:#8a9ab0;line-height:1.7}@media(max-width:900px){body{padding:16px}main{grid-template-columns:1fr}.viewer{min-height:200px}}
select{background:#273448;color:#edf3fa;padding:8px;border:1px solid #415067;border-radius:6px;margin:5px}.viewer img{cursor:grab;touch-action:none;user-select:none}.viewer img:active{cursor:grabbing}
</style>
</head>
<body>
<header><div><h1>S10 <span style="font-weight:400;color:#9cabc0">/ 运行控制</span></h1><small id="model">正在加载策略…</small></div><div class="status" id="connection">连接中</div></header>
<main>
<section class="card"><div class="viewer"><img id="frame" src="/frame.jpg" alt="S10 实时仿真画面"></div><div class="caption" id="caption">单环境 · CPU · 实时画面</div></section>
<section class="card panel">
<h2>地形与视角</h2>
<div><select id="terrainKind" aria-label="地形"><option value="flat">平地</option><option value="up">上楼梯</option><option value="down">下楼梯</option><option value="platform">障碍平台</option><option value="slope">斜坡</option><option value="rubble">碎石地</option></select>
<select id="terrainLevel" aria-label="难度"><option value="0">等级 0 · 2 cm / 3°</option><option value="1">等级 1 · 4 cm / 5.4°</option><option value="2">等级 2 · 8 cm / 7.8°</option><option value="3">等级 3 · 12 cm / 10.2°</option><option value="4">等级 4 · 16 cm / 12.6°</option><option value="5">等级 5 · 20 cm / 15°</option></select></div>
<div class="actions"><button id="changeScene">切换地形</button><button id="resetCamera">恢复视角</button></div>
<div class="muted">在画面上按住左键拖动旋转,滚轮缩放,双击恢复视角。镜头始终跟随机器人。切换地形会重置机器人。</div>
<h2>键盘控制 · 速度大小</h2><div class="muted">↑↓ 前进/后退 · ←→ 横移 · Q/E 转向<br>按住移动,松开停止;滑条只调速度大小。</div><div class="muted" id="requested">当前指令:0 / 0 / 0</div>
<div class="slider"><label for="vx">前进 / 后退 · vx <output id="vxout">0.00</output></label><input id="vx" type="range" min="0" max="2" step="0.01" value="0.3"><div class="bounds"><span>0 m/s</span><span>+2.00 m/s</span></div></div>
<div class="slider"><label for="vy">左移 / 右移 · vy <output id="vyout">0.00</output></label><input id="vy" type="range" min="0" max="1" step="0.01" value="0.2"><div class="bounds"><span>0 m/s</span><span>+1.00 m/s</span></div></div>
<div class="slider"><label for="yaw">左转 / 右转 · yaw rate <output id="yawout">0.00</output></label><input id="yaw" type="range" min="0" max="3" step="0.01" value="0.4"><div class="bounds"><span>0 rad/s</span><span>+3.00 rad/s</span></div></div>
<button class="stop" id="stop">停止 · 空格</button><div class="actions"><button id="pause">暂停仿真</button><button id="reset">重置机器人</button></div>
<div class="telemetry"><h2>实际速度</h2><div class="metric"><span>vx</span><b id="actual0">—</b></div><div class="metric"><span>vy</span><b id="actual1">—</b></div><div class="metric"><span>yaw rate</span><b id="actual2">—</b></div><div class="metric"><span>WM 预测误差</span><b id="wm">—</b></div></div>
<div class="note" id="note">黄→橙→红渐变残影是世界模型从当前状态想象的未来 32 步(默认每 4 步画一个);WM 预测误差会随仿真推进与真实状态对齐后更新。切换标签页或关闭页面会归零。连接中断超过 2 秒也会归零。暂停会清空指令。</div>
</section>
</main>
<script>
const sliders=['vx','vy','yaw'].map(id=>document.getElementById(id));
const connection=document.getElementById('connection');
let dirty=false,busy=false,actionQueue=[],dragging=false;
let cameraDelta=[0,0,0],pointer=null,sceneInitialized=false;
const keys=new Set(),motionKeys=new Set(["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","KeyQ","KeyE"]);
function command(){
const x=Number(keys.has("ArrowUp"))-Number(keys.has("ArrowDown"));
const y=Number(keys.has("ArrowLeft"))-Number(keys.has("ArrowRight"));
const z=Number(keys.has("KeyQ"))-Number(keys.has("KeyE"));
const scale=Math.max(1,Math.hypot(x,y));
return [Number(sliders[0].value)*x/scale,Number(sliders[1].value)*y/scale,Number(sliders[2].value)*z];
}
function labels(){sliders.forEach(s=>document.getElementById(s.id+'out').textContent=Number(s.value).toFixed(2))}
function zero(){keys.clear();dirty=true}
function action(name){if(name!=='heartbeat'){zero();actionQueue.push(name)}}
sliders.forEach(s=>{s.addEventListener('input',()=>{labels();dirty=true});s.addEventListener('pointerdown',()=>dragging=true)});
window.addEventListener('pointerup',()=>dragging=false);
document.getElementById('stop').onclick=()=>action('stop');
document.getElementById('pause').onclick=()=>action('pause');
document.getElementById('reset').onclick=()=>action('reset');
window.addEventListener('keydown',e=>{
if(e.code==='Space'){e.preventDefault();action('stop');return}
if(motionKeys.has(e.code)){e.preventDefault();if(!e.repeat){keys.add(e.code);dirty=true}}
});
window.addEventListener('keyup',e=>{
if(motionKeys.has(e.code)){e.preventDefault();keys.delete(e.code);dirty=true}
});
window.addEventListener('blur',()=>{endDrag();action('stop');leave()});
labels();
function leave(){zero();navigator.sendBeacon('/api/control',JSON.stringify({action:'stop'}))}
document.addEventListener('visibilitychange',()=>{if(document.hidden)leave()});
window.addEventListener('pagehide',leave);
function show(s){
connection.textContent=s.loading?'正在切换地形…':s.ready?(s.paused?'已暂停':'● 已连接'):'仿真初始化中';
if(!sceneInitialized && s.terrain_kind){
document.getElementById('terrainKind').value=s.terrain_kind;
document.getElementById('terrainLevel').value=String(s.terrain_level);sceneInitialized=true;
}
document.getElementById('changeScene').disabled=!!s.loading||!s.terrain;
document.getElementById('pause').textContent=s.paused?'继续仿真':'暂停仿真';
document.getElementById('requested').textContent='当前指令:vx '+s.command[0].toFixed(2)+' / vy '+s.command[1].toFixed(2)+' / yaw '+s.command[2].toFixed(2);
(s.actual||[]).forEach((v,i)=>document.getElementById('actual'+i).textContent=Number(v).toFixed(3)+(i===2?' rad/s':' m/s'));
document.getElementById('wm').textContent=s.wm_error||'—';
if(s.checkpoint)document.getElementById('model').textContent=s.checkpoint+' · 训练步数 '+s.iteration;
if(s.scene)document.getElementById('caption').textContent=s.scene+' · 单环境 · '+s.device+' · 渲染 '+Number(s.render_fps||0).toFixed(1)+' FPS · 实时倍率 '+Number(s.realtime_factor||0).toFixed(2)+'×';
if(s.terrain)document.getElementById('note').textContent=(s.terrain_revision>=3?'新版训练指令范围:vx ±2 m/s,vy ±1 m/s,yaw ±3 rad/s。实际能力以评估为准。':'当前为前向地形策略。训练范围:vx 0.2–0.45 m/s,vy 0,yaw ±0.05 rad/s;反向和大幅转向尚未验证。')+' 黄→橙→红渐变残影是世界模型预测的未来 32 步;WM 误差在与真实状态对齐后更新。切换标签页或断开连接后指令归零。';
}
async function tick(){
if(busy||document.hidden)return;busy=true;
try{
let body={action:'heartbeat'};
if(actionQueue.length){const item=actionQueue.shift();body=typeof item==='string'?{action:item}:item;}
else if(dirty){body={action:'command',values:command()};dirty=false}
if(cameraDelta.some(v=>v!==0)){body.camera_delta=cameraDelta;cameraDelta=[0,0,0]}
const response=await fetch('/api/control',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body),signal:AbortSignal.timeout(1500)});
if(!response.ok)throw Error(response.status);show(await response.json());
}catch(e){connection.textContent='连接已断开';zero()}finally{busy=false}
}
setInterval(tick,33);tick();
const image=document.getElementById('frame');
image.draggable=false;
image.addEventListener('pointerdown',e=>{
if(e.button!==0)return;e.preventDefault();pointer={id:e.pointerId,x:e.clientX,y:e.clientY};image.setPointerCapture(e.pointerId);
});
image.addEventListener('pointermove',e=>{
if(!pointer||pointer.id!==e.pointerId)return;
cameraDelta[0]-=(e.clientX-pointer.x)*.4;cameraDelta[1]-=(e.clientY-pointer.y)*.3;
pointer.x=e.clientX;pointer.y=e.clientY;
});
function endDrag(){pointer=null}
image.addEventListener('pointerup',endDrag);image.addEventListener('pointercancel',endDrag);image.addEventListener('lostpointercapture',endDrag);
image.addEventListener('wheel',e=>{e.preventDefault();cameraDelta[2]+=Math.max(-1,Math.min(1,e.deltaY*(e.deltaMode===1?.03:e.deltaMode===2?.5:.001)));},{passive:false});
function resetCamera(){cameraDelta=[0,0,0];actionQueue.push('camera_reset')}
image.addEventListener('dblclick',resetCamera);
document.getElementById('resetCamera').onclick=resetCamera;
document.getElementById('changeScene').onclick=()=>{
zero();actionQueue.push({action:'scene',kind:document.getElementById('terrainKind').value,level:Number(document.getElementById('terrainLevel').value)});
};
function nextFrame(){setTimeout(()=>{if(!document.hidden)image.src='/frame.jpg?t='+Date.now();else nextFrame()},16)}
image.onload=nextFrame;image.onerror=nextFrame;if(image.complete)nextFrame();
</script>
</body></html>
+171
View File
@@ -0,0 +1,171 @@
"""Local browser controls. Latest-frame storage only; simulation owns MuJoCo."""
import errno
import json
import math
from pathlib import Path
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
class ControlState:
def __init__(self, timeout=2.):
self.speed, self.lateral_speed, self.turn_speed = 2., 1., 3.
self.lock = threading.RLock()
self.values = (0., 0., 0.)
self.last_seen = time.monotonic()
self.timeout = timeout
self.paused = False
self.reset_requested = False
self.camera = [140., -20., 2.5]
self.scene_requested = None
self.loading = False
self.jpeg = b""
self.status = {"ready": False, "actual": [0., 0., 0.]}
def clear(self):
with self.lock:
self.values = (0., 0., 0.)
def command(self):
with self.lock:
if time.monotonic() - self.last_seen > self.timeout:
self.clear()
return (0., 0., 0.) if self.paused or self.loading else self.values
def update(self, request):
if not isinstance(request, dict):
raise ValueError("Expected an object")
action = request.get("action", "command")
if action not in ("command", "heartbeat", "stop", "pause", "reset", "camera_reset", "scene"):
raise ValueError("Unknown action")
values = None
if action == "command":
values = request.get("values")
if not isinstance(values, list) or len(values) != 3:
raise ValueError("Expected vx, vy, yaw_rate")
if any(isinstance(v, bool) or not isinstance(v, (int, float)) or not math.isfinite(v) for v in values):
raise ValueError("Commands must be finite numbers")
values = tuple(max(-limit, min(limit, value)) for value, limit in
zip(values, (self.speed, self.lateral_speed, self.turn_speed)))
camera_delta = request.get("camera_delta", [0., 0., 0.])
if (not isinstance(camera_delta, list) or len(camera_delta) != 3
or any(isinstance(v, bool) or not isinstance(v, (int, float)) or not math.isfinite(v) for v in camera_delta)):
raise ValueError("Invalid camera delta")
scene = None
if action == "scene":
kind, level = request.get("kind"), request.get("level")
if kind not in ("flat", "up", "down", "platform", "slope", "rubble") or type(level) is not int or level not in range(6):
raise ValueError("Invalid terrain selection")
if not self.status.get("terrain", False):
raise ValueError("Terrain selection requires a terrain policy")
scene = (kind, level)
with self.lock:
self.camera[0] = (self.camera[0] + camera_delta[0]) % 360
self.camera[1] = max(-89., min(0., self.camera[1] + camera_delta[1]))
self.camera[2] = max(.5, min(12., self.camera[2] * math.exp(max(-2., min(2., camera_delta[2])))))
if action == "camera_reset":
self.camera = [140., -20., 2.5]
if scene is not None:
self.scene_requested = scene
self.loading = True
self.clear()
# Expire stale commands before a late heartbeat can revive them.
self.command()
self.last_seen = time.monotonic()
if values is not None and not self.loading:
self.values = values
elif action in ("stop", "reset", "pause"):
self.clear()
if action == "pause":
self.paused = not self.paused
elif action == "reset":
self.reset_requested = True
return self.snapshot()
def snapshot(self):
with self.lock:
return dict(self.status, command=list(self.command()), paused=self.paused, loading=self.loading, camera=list(self.camera))
def take_scene(self):
with self.lock:
scene, self.scene_requested = self.scene_requested, None
return scene
def finish_scene(self):
with self.lock:
self.loading = self.scene_requested is not None
self.clear()
def take_reset(self):
with self.lock:
reset, self.reset_requested = self.reset_requested, False
return reset
def publish(self, actual, step, **metadata):
with self.lock:
self.status.update(ready=True, actual=actual, step=step, **metadata)
def make_server(state, port=8765):
html = Path(__file__).with_name("gui.html").read_bytes()
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def send(self, status, data, kind):
self.send_response(status)
self.send_header("Content-Type", kind)
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
try:
self.wfile.write(data)
except (BrokenPipeError, ConnectionResetError):
pass
def do_GET(self):
route = self.path.split("?", 1)[0]
if route == "/":
self.send(200, html, "text/html; charset=utf-8")
elif route == "/api/state":
self.send(200, json.dumps(state.snapshot()).encode(), "application/json")
elif route == "/frame.jpg":
with state.lock:
jpeg = state.jpeg
self.send(200 if jpeg else 503, jpeg, "image/jpeg")
else:
self.send(404, b"Not found", "text/plain")
def do_POST(self):
if self.path != "/api/control":
self.send(404, b"Not found", "text/plain")
return
# Local-only API: browser requests must come from this page's origin.
origin = self.headers.get("Origin")
if origin and origin != "http://" + self.headers.get("Host", ""):
self.send(403, b"Wrong origin", "text/plain")
return
try:
length = int(self.headers.get("Content-Length", "0"))
if not 0 < length <= 2048:
raise ValueError("Invalid body length")
result = state.update(json.loads(self.rfile.read(length)))
self.send(200, json.dumps(result).encode(), "application/json")
except (ValueError, TypeError) as error:
self.send(400, json.dumps({"error": str(error)}).encode(), "application/json")
requested_port = port
while True:
try:
server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
break
except OSError as error:
if error.errno != errno.EADDRINUSE or port >= 65535:
raise
port += 1
if port != requested_port:
print(f"Port {requested_port} is occupied; using http://127.0.0.1:{server.server_port}/", flush=True)
server.daemon_threads = True
return server
+198
View File
@@ -0,0 +1,198 @@
"""Differentiable rewards, full-return actor objective and TD(lambda) critic targets."""
import math
from contextlib import contextmanager
import torch
from .world_model import GRAVITY, LIN_VEL, ANG_VEL, LEG_POS, LEG_VEL, WHEEL_VEL
from .policy import frame, push_history, critic_input
@contextmanager
def frozen(*modules):
"""Freeze weights without disabling gradients through inputs/actions."""
parameters = [(p, p.requires_grad) for m in modules for p in m.parameters()]
try:
for p, _ in parameters:
p.requires_grad_(False)
yield
finally:
for p, enabled in parameters:
p.requires_grad_(enabled)
def survival(state):
gravity = torch.nn.functional.normalize(state[:, GRAVITY], dim=-1, eps=1e-6)
# Smooth proxy for the environment's 70-degree fall cutoff, width ~5 deg.
return torch.sigmoid((-gravity[:, 2] - math.cos(math.radians(70))) / 0.08)
def continuation_value(state, constants):
"""Imagined continuation probability for the current task.
The v4 proxy treats a 70-degree tilt as an immediate fall. The upward task
instead terminates only after 4 s of bad orientation, which is 200 control
steps, far beyond the 32/64-step imagination horizon, and the counter is not
part of the state. Applying the v4 proxy there truncates the return after
the first step whenever the robot is inverted, so the actor cannot learn to
flip back.
"""
if constants.get("upright_mode") == "upward":
return torch.ones(state.shape[0], dtype=state.dtype, device=state.device)
return survival(state)
def reward_terms(state, action, previous, command, constants, survival_prob, scan=None):
v, w = state[:, LIN_VEL], state[:, ANG_VEL]
q, qd, wd = state[:, LEG_POS], state[:, LEG_VEL], state[:, WHEEL_VEL]
standing = (command.square().sum(-1) < 1e-6).float()
# Endpoint PD torque surrogate: actual MuJoCo forces also reflect implicit
# integration over substeps. The low-weight effort term is approximate.
leg_torque = (constants["kp"] * (constants.get("leg_action_scale", 0.25) * action[:, :12] - q)
- constants["kd"] * qd).clamp(-constants["effort"], constants["effort"])
wheel_limit = constants.get("wheel_velocity_limit", 35.)
wheel_effort = constants.get("wheel_effort", 45.)
wheel_torque = (constants.get("wheel_kd", 2.) * ((constants.get("wheel_action_scale", 15.) * action[:, 12:]).clamp(-wheel_limit, wheel_limit) - wd)).clamp(-wheel_effort, wheel_effort)
absolute_q = q + constants["default_q"]
limits = ((constants["limits"][..., 0] - absolute_q).clamp_min(0)
+ (absolute_q - constants["limits"][..., 1]).clamp_min(0)).sum(-1)
posture = q.square().sum(-1)
if constants.get("terrain_posture_v2", False) and not constants.get("wheel_local_rewards", False):
if scan is None:
raise ValueError("Terrain v2 posture reward requires the privileged scan")
from .terrain_posture import posture_cost
posture = posture_cost(q, scan, constants.get("posture_relief_floor", 0.125))
terrain = constants.get("terrain_rewards", False)
gravity = state[:, GRAVITY]
if constants.get("upright_mode") == "upward":
# Sign-aware recovery reward: 1 upright, 0.5 sideways, 0 upside down.
upright = (0.5 * (1. - gravity[:, 2])).clamp(0., 1.)
else:
upright = torch.exp(
-gravity[:, :2].square().sum(-1) / constants.get("upright_std", 0.45)**2)
terms = {
"track_linear_velocity": torch.exp(-((v[:, :2] - command[:, :2]).square().sum(-1)
+ (0. if terrain else v[:, 2].square())) / 0.25),
"track_angular_velocity": torch.exp(-((w[:, 2] - command[:, 2]).square()
+ (0. if terrain else w[:, :2].square().sum(-1))) / 0.5),
"upright": upright,
"leg_posture": posture,
"standstill": standing * (v.square().sum(-1) + w.square().sum(-1)
+ 0.01 * wd.square().sum(-1)),
"action_rate_l2": (action - previous).square().sum(-1),
"joint_torques_l2": leg_torque.square().sum(-1) + wheel_torque.square().sum(-1),
"dof_pos_limits": limits,
"terminated": 1. - survival_prob,
}
if constants.get("rolling_rewards", False) and not constants.get("wheel_local_rewards", False):
if scan is None:
raise ValueError("Rolling rewards require a terrain scan")
from .rolling_rewards import rolling_costs
terms.update(rolling_costs(state, command, scan, constants["default_q"]))
if constants.get("obstacle_progress", False):
from .obstacle_rewards import obstacle_progress
terms["obstacle_progress"] = obstacle_progress(state, command, scan)
if constants.get("wheel_body_intrusion", False):
from .wheel_intrusion import wheel_body_intrusion
terms["wheel_body_intrusion"] = wheel_body_intrusion(
state, constants["default_q"])
if constants.get("wheel_local_rewards", False):
from .wheel_terrain_rewards import wheel_terms
terms.update(wheel_terms(state, command, scan, constants["default_q"]))
if constants.get("bad_orientation_penalty", False):
from .upward import bad_orientation_value
terms["bad_orientation_penalty"] = bad_orientation_value(gravity)
from .upward import apply_reward_clamps
apply_reward_clamps(terms, constants)
return terms
def lambda_returns(rewards, continuation, next_values, gamma=0.99, lam=0.95):
carry = next_values[-1]
result = []
for t in reversed(range(len(rewards))):
carry = rewards[t] + gamma * continuation[t] * ((1 - lam) * next_values[t] + lam * carry)
result.append(carry)
return torch.stack(result[::-1])
def discounted_objective(rewards, continuation, terminal_value, gamma=.99):
alive = torch.ones_like(terminal_value)
total = torch.zeros_like(terminal_value)
discount = 1.
for reward, cont in zip(rewards, continuation):
total = total + discount * alive * reward
alive = alive * cont
discount *= gamma
return total + discount * alive * terminal_value
def make_imagined_step(model, actor, constants, weights, env_action_order,
dt=.02, noise=True, field=None):
"""Pure single-step graph, shared by eager and compiled execution."""
def step(state, previous, history, pose, scan, command, context, bias, timers):
action = actor(history)
model_context = context if field is None else torch.cat((context, scan), -1)
collision_prob = None
if constants.get('nonwheel_collision', False):
nxt, delta, _, collision_logit = model.forward_all(state, action, previous, model_context)
collision_prob = collision_logit.squeeze(-1).sigmoid()
else:
nxt, delta = model(state, action, previous, model_context)
if field is not None:
from .terrain import advance_pose
pose = advance_pose(pose, delta)
next_scan = None if field is None else field.scan(nxt, pose)
cont = continuation_value(nxt, constants)
if collision_prob is not None:
cont = cont * (1. - collision_prob)
terms = reward_terms(nxt, action, previous, command, constants, cont,
scan=next_scan)
if constants.get('pair_body_intrusion', False):
from .wheel_intrusion import pair_body_intrusion
terms['pair_body_intrusion'], timers = pair_body_intrusion(
nxt, constants['default_q'], timers, dt)
reward = dt * sum(weights[name] * value for name, value in terms.items())
latest = frame(nxt, action[:, env_action_order], command, bias, noise=noise,
wheel_vel=actor.actor_wheel_vel)
history = push_history(history, latest, actor.history, actor.terms)
return nxt, action, history, pose, next_scan, reward, cont, timers
return step
def rollout(model, actor, target_critic, seed, constants, weights, env_action_order,
horizon=16, dt=0.02, gamma=0.99, lam=0.95, noise=True, field=None,
step_fn=None):
"""Actor: full discounted return + terminal value. Critic: detached TD(lambda).
Caller freezes dynamics and target critic weights, preserving state gradients.
Reuse one terrain scan at each state, including the rollout's initial state.
"""
state, previous, history = seed["state"], seed["previous"], seed["history"]
command, context, bias = seed["command"], seed["context"], seed["bias"]
pose = seed.get("world_pose")
timers = seed.get("pair_timer")
if timers is None:
timers = torch.zeros(state.shape[0], 4, device=state.device)
def value_input(s, prev, scan):
base = critic_input(s, command, context, prev)
return base if field is None else torch.cat((base, scan), -1)
if step_fn is None:
step_fn = make_imagined_step(model, actor, constants, weights, env_action_order,
dt, noise, field)
rewards, continuations, inputs, next_inputs = [], [], [], []
scan = None if field is None else field.scan(state, pose)
for _ in range(horizon):
inputs.append(value_input(state, previous, scan).detach())
state, previous, history, pose, scan, reward, cont, timers = step_fn(
state, previous, history, pose, scan, command, context, bias, timers)
rewards.append(reward)
continuations.append(cont)
next_inputs.append(value_input(state, previous, scan).detach())
final_value = target_critic(value_input(state, previous, scan)).squeeze(-1)
r, c = torch.stack(rewards), torch.stack(continuations)
objective = discounted_objective(r, c, final_value, gamma)
with torch.no_grad():
next_values = target_critic(torch.stack(next_inputs)).squeeze(-1)
targets = lambda_returns(r.detach(), c.detach(), next_values, gamma, lam)
return -objective.mean(), torch.stack(inputs), targets
+52
View File
@@ -0,0 +1,52 @@
"""Explicit straight/side/yaw command modes, with more stair traversal samples.
``deadzone`` zeroes per-axis commands below the threshold; v7 uses 0.05 so tiny
creep commands become standing instead of ambiguous near-zero motion.
"""
from dataclasses import dataclass
import torch
from mjlab.tasks.velocity.mdp.velocity_command import UniformVelocityCommand, UniformVelocityCommandCfg
def mix_commands(values, stairs):
result = values.clone()
mode = torch.rand(len(values),device=values.device)
# General terrain: 40% pure vx, 20% pure vy, 20% pure yaw, 20% combined.
result[mode<.4,1:] = 0.
result[(mode>=.4)&(mode<.6),0] = 0.
result[(mode>=.4)&(mode<.6),2] = 0.
result[(mode>=.6)&(mode<.8),:2] = 0.
# Stairs: 80% low-speed pure forward/backward, 20% full-range commands.
result[stairs] = values[stairs]
traverse = stairs & (mode<.8)
magnitude = .2 + .4*torch.rand(len(values),device=values.device)
result[traverse,0] = torch.where(values[traverse,0]<0,-magnitude[traverse],magnitude[traverse])
result[traverse,1:] = 0.
return result
class MixedVelocityCommand(UniformVelocityCommand):
def _resample_command(self, env_ids):
super()._resample_command(env_ids)
gen = self._env.cfg.scene.terrain.terrain_generator
stair_columns = torch.tensor([c.kind in ('up','down') for c in gen.sub_terrains.values()],device=self.device)
stairs = stair_columns[self._env.scene.terrain.terrain_types[env_ids]]
mixed = mix_commands(self.vel_command_b[env_ids],stairs)
# Stair magnitudes must obey the current curriculum bounds, including reverse.
for i, bounds in enumerate((self.cfg.ranges.lin_vel_x, self.cfg.ranges.lin_vel_y, self.cfg.ranges.ang_vel_z)):
mixed[:,i].clamp_(*bounds)
# Per-axis deadband: tiny commands are standing, not an ambiguous creep.
if self.cfg.deadzone > 0.:
mixed = torch.where(mixed.abs() < self.cfg.deadzone,
torch.zeros_like(mixed), mixed)
self.vel_command_b[env_ids] = mixed
if self.cfg.hold_stair_command:
self.time_left[env_ids[stairs]] = self._env.cfg.episode_length_s + 1.
@dataclass(kw_only=True)
class MixedVelocityCommandCfg(UniformVelocityCommandCfg):
hold_stair_command: bool = False
deadzone: float = 0.
def build(self, env):
return MixedVelocityCommand(self,env)
+34
View File
@@ -0,0 +1,34 @@
"""Bounded directional motion near uneven ground, shared by real and imagined steps."""
import torch
from .world_model import LIN_VEL
def obstacle_gate(scan):
local = scan.reshape(-1, 21, 13)[:, 5:16, 3:10].flatten(1)
return ((local.amax(-1) - local.amin(-1) - .02) / .08).clamp(0., 1.)
def obstacle_progress(state, command, scan):
speed = command[:, :2].norm(dim=-1)
along = (state[:, LIN_VEL][:, :2] * command[:, :2]).sum(-1) / speed.clamp_min(.1)
# Signed and capped: no benefit for wheel spin, pure yaw, lifting or overspeed.
return obstacle_gate(scan) * (speed > .1) * (along / speed.clamp_min(.2)).clamp(-1., 1.)
def env_obstacle_progress(env):
from .terrain import TerrainField, TerrainBridge
from .stair_curriculum import buffers
bridge = getattr(env, '_s10_posture_bridge', None)
if bridge is None:
bridge = TerrainBridge(env, TerrainField.from_env(env))
env._s10_posture_bridge = bridge
state = bridge.state()
scan = bridge.field.scan(state, bridge.pose())
command = env.command_manager.get_command('twist')
buffers(env)
stalled = ((command[:, :2].norm(dim=-1) > .1)
& (state[:, LIN_VEL][:, :2].norm(dim=-1) < .05)
& (obstacle_gate(scan) > .5))
env._stair_stall_time = torch.where(stalled, env._stair_stall_time + env.step_dt, 0.)
env._stair_stuck |= env._stair_stall_time >= 3.
return obstacle_progress(state, command, scan)
+149
View File
@@ -0,0 +1,149 @@
"""S10 actor history and actor/critic networks, independent of the quadruped learner.
Two Actor observation schemas coexist:
* v4 / v4-upward / new v7: 53 values/frame, including measured wheel speed;
* legacy v7: 49 values/frame, no measured wheel speed (the last action still
contains the four wheel commands).
The learner, replay and deployment exporter select the schema explicitly. Old
checkpoints are recognized from their first-layer width.
"""
import torch
from torch import nn
from .world_model import RunningNorm, ANG_VEL, GRAVITY, LEG_POS, LEG_VEL, WHEEL_VEL
LEGACY_TERMS = (3, 3, 12, 12, 4, 16, 3)
TERMS = (3, 3, 12, 12, 16, 3)
LEGACY_FRAME_DIM = sum(LEGACY_TERMS)
FRAME_DIM = sum(TERMS)
CRITIC_DIM = 38 + 3 + 7 + 16
POLICY_FORMAT = "s10-flat-policy-v1"
def frame(state, last_env_action, command, bias, noise=False, wheel_vel=True):
"""Exactly the term order/scaling in env.flat (noise before scaling)."""
values = [state[:, ANG_VEL], state[:, GRAVITY], state[:, LEG_POS] + bias,
state[:, LEG_VEL]]
widths = [0.2, 0.05, 0.01, 1.5]
if wheel_vel:
values.append(state[:, WHEEL_VEL])
widths.append(1.5)
values.extend((last_env_action, command))
widths.extend((0., 0.))
if noise:
values = [v + (torch.rand_like(v) * 2 - 1) * w if w else v
for v, w in zip(values, widths)]
# Leg velocity is always index 3; wheel velocity, when present, is index 4.
velocity_indices = (3, 4) if wheel_vel else (3,)
return torch.cat([v * (0.05 if i in velocity_indices else 1.)
for i, v in enumerate(values)], -1)
def push_history(history, latest, length, terms):
"""Term-wise histories, oldest to newest, matching mjlab flattening."""
result, hidx, fidx = [], 0, 0
for dim in terms:
result.append(torch.cat((history[:, hidx + dim:hidx + dim * length],
latest[:, fidx:fidx + dim]), -1))
hidx += dim * length
fidx += dim
return torch.cat(result, -1)
def critic_input(state, command, context, previous):
return torch.cat((state, command, context, previous), -1)
class Network(nn.Module):
def __init__(self, inputs, outputs, hidden):
super().__init__()
self.norm = RunningNorm(inputs)
layers = []
for n in hidden:
layers.extend((nn.Linear(inputs, n), nn.ELU()))
inputs = n
layers.append(nn.Linear(inputs, outputs))
self.net = nn.Sequential(*layers)
def forward(self, x):
return self.net(self.norm(x))
class Actor(Network):
def __init__(self, history=5, hidden=(256, 128, 64), actor_wheel_vel=True):
self.actor_wheel_vel = bool(actor_wheel_vel)
self.terms = LEGACY_TERMS if self.actor_wheel_vel else TERMS
self.frame_dim = sum(self.terms)
super().__init__(self.frame_dim * history, 16, hidden)
self.history, self.hidden = history, tuple(hidden)
nn.init.uniform_(self.net[-1].weight, -0.01, 0.01)
nn.init.zeros_(self.net[-1].bias)
def forward(self, obs):
# Same bounded action distribution during collection and imagination.
return super().forward(obs).tanh()
def checkpoint_wheel_vel(checkpoint):
"""Infer legacy observation schema from weights; validate explicit metadata."""
flag = checkpoint.get("actor_wheel_vel")
if "actor" in checkpoint:
dim = checkpoint["actor"]["net.0.weight"].shape[1]
history = checkpoint["history"]
if dim not in (FRAME_DIM * history, LEGACY_FRAME_DIM * history):
raise ValueError("Unsupported actor observation dimension")
inferred = dim == LEGACY_FRAME_DIM * history
if flag is not None and bool(flag) != inferred:
raise ValueError("Actor observation metadata disagrees with weights")
return inferred
return bool(flag) if flag is not None else False
def load_actor(checkpoint, device="cpu"):
if checkpoint.get("format") not in (POLICY_FORMAT, "s10-terrain-policy-v1"):
raise ValueError("Expected a S10 policy checkpoint, not other-robot or dynamics-only weights")
actor = Actor(checkpoint["history"], checkpoint["actor_hidden"],
checkpoint_wheel_vel(checkpoint)).to(device)
actor.load_state_dict(checkpoint["actor"])
return actor.eval()
def evaluation_env_cfg(checkpoint, level=0, kind=None):
"""Select the matching task; terrain remains privileged at deployment."""
from s10_supermimic.tasks import get_task
actor_wheel_vel = checkpoint_wheel_vel(checkpoint)
if checkpoint.get("format") == "s10-terrain-policy-v1":
checkpoint_task = checkpoint.get("config", {}).get("task")
task = get_task(checkpoint_task) if checkpoint_task else None
if task is not None:
if task.terrain_revision != checkpoint["config"].get("terrain_revision", 1):
raise ValueError("Checkpoint task revision differs from its config")
if task.actor.wheel_vel != actor_wheel_vel:
raise ValueError("Checkpoint task Actor schema differs from its weights")
cfg = task.build_env_cfg(play=True, history=checkpoint["history"],
seed=checkpoint["config"]["seed"])
else:
from s10_supermimic.env.terrain import s10_velocity_terrain_env_cfg
cfg = s10_velocity_terrain_env_cfg(
play=True, history=checkpoint["history"],
seed=checkpoint["config"]["seed"],
revision=checkpoint["config"].get("terrain_revision", 1),
actor_wheel_vel=actor_wheel_vel)
# A single staircase can overlap the robot's pre-reset origin at compile
# time. Reserve contacts for that transient pose as well as normal rolling.
cfg.sim.nconmax = max(cfg.sim.nconmax or 0, 512)
cfg.sim.njmax = max(cfg.sim.njmax or 0, 2048)
if level not in range(6):
raise ValueError("Terrain level must be 0..5")
gen = cfg.scene.terrain.terrain_generator
# Evaluation fixes difficulty; one row, same seeded generator.
gen.num_rows = 1
gen.difficulty_range = (level/5,level/5)
if kind is not None:
gen.sub_terrains = {kind: gen.sub_terrains[kind]}
return cfg
from s10_supermimic.env.flat import s10_velocity_flat_env_cfg
return s10_velocity_flat_env_cfg(play=True, history=checkpoint["history"],
actor_wheel_vel=actor_wheel_vel)
+21
View File
@@ -0,0 +1,21 @@
"""Isolate invalid simulation rows before terrain lookup or replay insertion."""
import torch
def invalid_rows(env, obs):
d = env.scene["robot"].data
tensors = (d.root_link_pos_w, d.projected_gravity_b, d.root_link_lin_vel_b,
d.root_link_ang_vel_b, d.joint_pos, d.joint_vel, obs["actor"])
return ~torch.stack([torch.isfinite(t).flatten(1).all(-1) for t in tensors]).all(0)
def recover_invalid_envs(env, obs, reward):
bad = invalid_rows(env, obs) | ~torch.isfinite(reward)
ids = bad.nonzero(as_tuple=False).flatten()
if ids.numel():
print(f"RECOVERY reset {ids.numel()} invalid environments; first IDs={ids[:16].tolist()}", flush=True)
obs, _ = env.reset(env_ids=ids)
# A failed reset must be handled by the supervisor, never added to replay.
if invalid_rows(env, obs).any():
raise RuntimeError("Environment reset failed to restore finite state")
return obs, bad
+157
View File
@@ -0,0 +1,157 @@
"""Bounded S10 transition storage with chronological multi-step sampling."""
import torch
from .world_model import STATE_DIM, ACTION_DIM, CONTEXT_DIM
class Replay:
def __init__(self, length, num_envs, device, history_dim=0, terrain=False,
contacts=False, courses=False, collisions=False, pairs=False):
if min(length, num_envs) < 1:
raise ValueError("Replay length and environment count must be positive")
self.length, self.size, self.cursor = length, 0, 0
self.archive = None
self.success_archive = None
dims = dict(state=STATE_DIM, action=ACTION_DIM, previous=ACTION_DIM,
context=CONTEXT_DIM, next=STATE_DIM, pose=3, valid=1)
if terrain:
dims["world_pose"] = 3
if contacts:
dims["next_contacts"] = 16
if collisions:
dims.update(collision=1, collision_valid=1)
if courses:
dims["course"] = 4 # type, level, uphill ground transition, top crossing
if pairs:
dims["pair_timer"] = 4 # per-wheel continuous 5/6-region intrusion time
if history_dim:
dims.update(history=history_dim, command=3, bias=12)
self.data = {k: torch.zeros(length, num_envs, dim, device=device) for k, dim in dims.items()}
def add(self, **items):
if items.keys() != self.data.keys():
raise ValueError("Replay transition fields do not match configured fields")
for k, value in items.items():
self.data[k][self.cursor].copy_(value.detach())
self.cursor = (self.cursor + 1) % self.length
self.size = min(self.size + 1, self.length)
def sample(self, batch, window, keys=None):
if self.size < window or window < 1:
raise ValueError("Not enough transitions for a window")
device = self.data["state"].device
start = torch.randint(self.size - window + 1, (batch,), device=device)
oldest = self.cursor if self.size == self.length else 0
times = (oldest + start[:, None] + torch.arange(window, device=device)) % self.length
envs = torch.randint(self.data["state"].shape[1], (batch, 1), device=device)
return {k: v[times, envs] for k, v in self.data.items() if keys is None or k in keys}
def sample_world_model(self, batch, window, prioritized=.25, archived=.25,
capacity=100000, keep_prob=.02):
keys = ('state','next','action','previous','context','pose','valid',
'world_pose','next_contacts')
if 'next_contacts' not in self.data:
return self.sample(batch, window, keys)
if self.archive is None:
self.archive = WindowArchive(capacity)
if 'course' in self.data:
return self._sample_courses(batch, window, capacity, keep_prob)
n_priority = int(batch * prioritized)
n_archive = int(batch * archived) if self.archive.size else 0
parts = [self.sample(batch-n_priority-n_archive, window, keys)]
candidates = self.sample(4*max(n_priority,1), window, keys)
score, rare = event_scores(candidates)
selected = rare & (torch.rand_like(score) < keep_prob)
self.archive.add({k:v[selected] for k,v in candidates.items()})
if n_priority:
indices = (score + .001*torch.rand_like(score)).topk(n_priority).indices
parts.append({k:v[indices] for k,v in candidates.items()})
if n_archive:
parts.append(self.archive.sample(n_archive))
return {k:torch.cat([p[k] for p in parts]) for k in parts[0]}
def _sample_courses(self, batch, window, capacity, keep_prob):
# Total archive capacity remains bounded by --archive-size, split evenly.
if self.success_archive is None:
self.archive = WindowArchive(max(1, capacity // 2))
self.success_archive = WindowArchive(max(1, capacity - capacity // 2))
keys = tuple(k for k in self.data if k not in ('history', 'command', 'bias'))
n_priority = batch // 4
candidates = self.sample(max(1, n_priority * 4), window, keys)
score, rare = event_scores(candidates)
valid = candidates['valid'].squeeze(-1).bool().cumprod(1).bool()
tag = candidates['course']
moving = (candidates['pose'][:, :, :2].norm(dim=-1) > .001) & valid
up = tag[:, :, 0] == 1
useful = ((tag[:, :, 2:] > .5).any(-1) & valid & up).any(-1)
# Do not fill the general archive with repeated stationary stair impacts.
rare &= (~up.any(-1) | moving.any(-1))
if 'collision' in candidates:
from .collision import collision_events
rare |= collision_events(candidates)
for archive, chosen in ((self.archive, rare), (self.success_archive, useful)):
chosen = chosen & (torch.rand_like(score) < keep_prob)
archive.add({k:v[chosen] for k,v in candidates.items()})
n_success = batch // 8 if self.success_archive.size else 0
n_rare = batch // 8 if self.archive.size else 0
# At least half the batch is always uniform replay, including when sparse.
parts = [self.sample(batch - n_priority - n_success - n_rare, window, keys)]
if n_priority:
rank = score + 8 * useful.float() + 2 * (moving & up).any(-1).float()
selected = (rank + .001 * torch.rand_like(rank)).topk(n_priority).indices
parts.append({k:v[selected] for k,v in candidates.items()})
if n_success: parts.append(self.success_archive.sample(n_success))
if n_rare: parts.append(self.archive.sample(n_rare))
return {k:torch.cat([part[k] for part in parts]) for k in keys}
def event_scores(w):
"""Measured wheel contact transitions; ignore all labels after a reset.
Force impacts, sustained flight, hard vertical motion and episode ends enter
the archive. Contact changes also include normal wheel touchdown/liftoff.
"""
valid = w['valid'].squeeze(-1).bool()
mask = valid.cumprod(1).bool()
contacts = w['next_contacts'][:,:,:4] > .5
pairs = mask[:,1:] & mask[:,:-1]
changes = ((contacts[:,1:] != contacts[:,:-1]).sum(-1)*pairs).sum(-1)
flight = ((~contacts.any(-1)) & mask).sum(-1)
force = torch.nan_to_num(w['next_contacts'][:,:,4:],nan=0.).reshape(*mask.shape,4,3).norm(dim=-1)
impact = ((force.amax(-1)>200.) & mask).any(-1)
hard_vz = ((w['state'][:,:,6].abs()>.6) & mask).any(-1)
# A terminal endpoint is not a valid dynamics target, but its preceding
# window is still useful. Windows starting after termination are excluded.
ended = ((~valid) & torch.cat((torch.ones_like(mask[:,:1]),mask[:,:-1]),1)).any(-1)
score = changes.float() + flight.float() + 4.*ended + 2.*impact
rare = ((flight>=2) | hard_vz | impact | ended) & mask[:,0]
score = score * mask[:,0]
if 'collision' in w:
from .collision import collision_events
collision = collision_events(w)
score = score + 8.*collision
rare |= collision
return score, rare
class WindowArchive:
"""Bounded GPU ring storing only world-model fields, not actor histories."""
def __init__(self, capacity):
self.capacity, self.size, self.cursor = capacity, 0, 0
self.data = None
def add(self, windows):
n = min(len(windows['state']),self.capacity)
if not n:return
if self.data is None:
self.data = {k:v.new_empty((self.capacity,*v.shape[1:])) for k,v in windows.items()}
ids = (torch.arange(n,device=windows['state'].device)+self.cursor)%self.capacity
for k,v in windows.items():self.data[k][ids] = v[-n:].detach()
self.cursor = (self.cursor+n)%self.capacity
self.size = min(self.capacity,self.size+n)
def sample(self, batch):
ids = torch.randint(self.size,(batch,),device=self.data['state'].device)
return {k:v[ids] for k,v in self.data.items()}
+77
View File
@@ -0,0 +1,77 @@
"""Terrain-gated rolling preference, shared by real and imagined rewards."""
import torch
from .world_model import GRAVITY, LEG_POS, LEG_VEL
def rolling_gate(scan, command):
local = scan.reshape(-1, 21, 13)[:, 5:16, 3:10].flatten(1)
relief = local.amax(-1) - local.amin(-1)
# Flat (<2 cm): favor rolling; near obstacles (>=6 cm): allow lifting.
flat = (1. - (relief - .02) / .04).clamp(0., 1.)
# Non-steerable wheels may require stepping for lateral/yaw maneuvers.
straight = torch.exp(-(command[:,1]/.25).square() - (command[:,2]/1.).square())
return flat * straight
def wheel_geometry(state, default_q):
"""S10 wheel centres in body coordinates and vertical cylinder half-extents.
Joint order is hipx (abduction), hipy (flexion), knee. Geometry follows
``S10_description/s10_mjcf/S10.xml``: hipx at x=±0.2277/y=±0.0574, hipy
offset y=±0.0779, knee at z=-0.18, wheel at y=±0.045891/z=-0.18,
wheel radius 0.081 m and half-width 0.0185 m. The analytic result was
checked against MuJoCo ``mj_forward`` to machine precision.
The cylinder is symmetric about its axle, so wheel phase is unnecessary.
"""
q = (state[:,LEG_POS] + default_q).reshape(-1,4,3)
hipx, hipy, knee = q.unbind(-1)
side = state.new_tensor([1.,-1.,1.,-1.])
# Leg sagittal plane: knee link (0.18 m) + wheel link (0.18 m).
x = .18*knee.sin()
z = -.18 - .18*knee.cos()
x3 = hipy.cos()*x - hipy.sin()*z
z3 = hipy.sin()*x + hipy.cos()*z
y3 = side*(.0779+.045891)
y4 = hipx.cos()*y3 + hipx.sin()*z3
z4 = -hipx.sin()*y3 + hipx.cos()*z3
point = torch.stack((state.new_tensor([.2277,.2277,-.2277,-.2277]) + x3,
state.new_tensor([.0574,-.0574,.0574,-.0574]) + y4,
z4),-1)
up = -torch.nn.functional.normalize(state[:,GRAVITY],dim=-1,eps=1e-6)
# Wheel axle is local -y, rotated only by hipx; sign is irrelevant after abs.
axle_z = -up[:,1:2]*hipx.cos()+up[:,2:3]*hipx.sin()
radius_z = .081*(1.-axle_z.square()).clamp_min(1e-8).sqrt()+.0185*axle_z.abs()
return point, radius_z
def wheel_bottom_height(state, default_q):
"""Wheel bottoms relative to the ground below the base.
Only used on near-flat ground by rolling_gate, not as obstacle clearance.
"""
point, radius_z = wheel_geometry(state, default_q)
up = -torch.nn.functional.normalize(state[:,GRAVITY],dim=-1,eps=1e-6)
return state[:,:1] + (point*up[:,None,:]).sum(-1) - radius_z
def rolling_costs(state, command, scan, default_q):
gate = rolling_gate(scan, command)
# Tolerate 5 mm of clearance; no positive reward for lifting anywhere.
lift = ((wheel_bottom_height(state,default_q)-.005).clamp_min(0)/.05).square().sum(-1)
# Reference mjlab task uses mean squared speed and relaxes during recovery.
tilt = (state[:,GRAVITY][:,:2].norm(dim=-1)-.08).div(.22).clamp(0.,1.)
motion = state[:,LEG_VEL].square().mean(-1) * (1.-.8*tilt)
return {'unnecessary_wheel_lift':gate*lift, 'rolling_leg_motion':gate*motion}
def env_rolling_cost(env, term):
from .terrain import TerrainField, TerrainBridge
bridge = getattr(env,'_s10_posture_bridge',None)
if bridge is None:
bridge = TerrainBridge(env,TerrainField.from_env(env))
env._s10_posture_bridge = bridge
state = bridge.state()
return rolling_costs(state, env.command_manager.get_command('twist'),
bridge.field.scan(state,bridge.pose()),
bridge.robot.data.default_joint_pos[0,bridge.legs])[term]
+75
View File
@@ -0,0 +1,75 @@
"""V5 episode outcomes and performance-gated velocity curriculum.
All buffers are training-only metadata, never actor observations.
"""
import torch
KINDS = ('flat', 'up', 'down', 'platform', 'slope', 'rubble')
MIN_ITERATIONS = (0, 3000, 8000, 15000, 22000)
def buffers(env):
if not hasattr(env, '_stair_stats'):
env._stair_stats = torch.zeros(6, 6, 4, device=env.device) # trials, pass, fall, stuck
env._stair_stall_time = torch.zeros(env.num_envs, device=env.device)
env._stair_stuck = torch.zeros(env.num_envs, dtype=torch.bool, device=env.device)
return env._stair_stats
def stair_pass(offset, gravity, level, up):
"""Require body/rear clearance of the last riser, correct landing height and stability."""
heights = offset.new_tensor([.02, .04, .08, .12, .16, .20])[level]
expected = torch.where(up, 5 * heights, -5 * heights)
return ((offset[:, 0].abs() > 2.8) & (offset[:, 1].abs() < 2.5)
& (offset[:, 2] > expected + .20) & (offset[:, 2] < expected + .60)
& (gravity[:, :2].norm(dim=-1) < .4))
def curriculum(env, env_ids):
stats = buffers(env)
terrain = env.scene.terrain
d = env.scene['robot'].data
offset = d.root_link_pos_w[env_ids] - env.scene.env_origins[env_ids]
levels = terrain.terrain_levels[env_ids].clone()
types = terrain.terrain_types[env_ids]
# Six fixed columns, generated in KINDS order.
stairs = (types == 1) | (types == 2)
active = env.episode_length_buf[env_ids] > 0
fallen = env.termination_manager.terminated[env_ids]
success = (offset[:, 0].abs() > 5.4) & (offset[:, 1].abs() < 6.5)
top = stair_pass(offset, d.projected_gravity_b[env_ids], levels, types == 1)
# Landing contacts rule out simply jumping through the clearance test.
contact = env.scene['wheel_ground_contact'].data.found[env_ids].reshape(len(env_ids), -1).gt(0).sum(-1) >= 2
success = torch.where(stairs, top & contact, success) & active & ~fallen
command = env.command_manager.get_command('twist')[env_ids]
traversal = (command[:, 0].abs() > .1) & (command[:, 1:].abs().amax(-1) < .05)
trials = (~stairs | traversal) & active & ((env.episode_length_buf[env_ids] * env.step_dt >= 5.) | fallen)
values = torch.stack((trials, success & trials, fallen & trials,
env._stair_stuck[env_ids] & trials), -1).float()
stats.view(-1, 4).index_add_(0, types * 6 + levels, values)
terrain.terrain_levels[env_ids] = (levels + success.long() - (active & fallen).long()).clamp(0, 5)
terrain.env_origins[env_ids] = terrain.terrain_origins[terrain.terrain_levels[env_ids], types]
env._stair_stall_time[env_ids] = 0
env._stair_stuck[env_ids] = False
return terrain.terrain_levels.float().mean()
def next_stage(stage, iteration, stats, velocity_mae):
"""Minimum age AND sufficient actual stair passes, never time alone."""
if stage >= 4 or iteration < MIN_ITERATIONS[stage + 1]:
return stage
# Stage increases require success at 4, 8, 12, then 16 cm respectively.
trials, passed = stats[1, stage + 1, :2]
accurate = max(velocity_mae[:2]) < .18 and velocity_mae[2] < .30
return stage + int(trials >= 64 and passed / max(trials, 1) >= .60 and accurate)
def summary(env):
stats = buffers(env).detach().cpu().tolist()
levels, types = env.scene.terrain.terrain_levels, env.scene.terrain.terrain_types
result = {}
for i, name in enumerate(KINDS):
selected = levels[types == i]
result[name] = dict(level_counts=torch.bincount(selected, minlength=6).cpu().tolist(),
episodes_by_level=stats[i])
return result
+95
View File
@@ -0,0 +1,95 @@
"""Shared static height atlas and privileged, differentiable yaw-frame scans."""
import numpy as np
import torch
from .bridge import S10Bridge
SCAN_DIM = 21 * 13
class TerrainField:
def __init__(self, heights, origin, resolution=.04):
self.h, self.origin, self.res = heights, origin, resolution
x,y = torch.meshgrid(torch.linspace(-1,1,21,device=heights.device),
torch.linspace(-.6,.6,13,device=heights.device), indexing="ij")
self.offsets = torch.stack((x.flatten(),y.flatten()),-1)
def sample(self, xy):
# Clamp *integer* neighbour indices too. NaN survives float clamp and
# casts to INT64_MIN; on CUDA that would poison the entire device context.
finite = torch.isfinite(xy).all(-1)
clean = torch.where(torch.isfinite(xy), xy, torch.zeros_like(xy))
gx = ((clean[...,0]-self.origin[0])/self.res).clamp(0,self.h.shape[1]-1)
gy = ((clean[...,1]-self.origin[1])/self.res).clamp(0,self.h.shape[0]-1)
x = gx.floor().long().clamp(0,self.h.shape[1]-2)
y = gy.floor().long().clamp(0,self.h.shape[0]-2)
a,b = gx-x,gy-y
value = ((1-a)*self.h[y,x]+a*self.h[y,x+1])*(1-b)+((1-a)*self.h[y+1,x]+a*self.h[y+1,x+1])*b
# Do not disguise corrupt states as valid ground: the learner will discard
# this update, while real invalid environments are reset before querying.
return torch.where(finite, value, torch.full_like(value, float("nan")))
def scan(self, state, pose):
c,s = pose[:,2:3].cos(),pose[:,2:3].sin()
dx,dy = self.offsets[:,0],self.offsets[:,1]
xy = pose[:,None,:2]+torch.stack((c*dx-s*dy,s*dx+c*dy),-1)
return (state[:,:1]+self.sample(pose[:,:2])[:,None]-self.sample(xy)).clamp(-1.5,1.5)
@classmethod
def from_env(cls, env, resolution=.04):
"""Rasterize exact oriented boxes in small bounding rectangles, no per-pixel rays.
Shared CPU build is ~6 MB at 4 cm for a 48 x 48 m atlas. No robot geometry.
Only these static box courses are supported; fail if new shapes are introduced.
"""
import mujoco
model = env.sim.mj_model
data = mujoco.MjData(model)
mujoco.mj_forward(model,data)
robot_ids = set(env.scene["robot"].indexing.geom_ids.cpu().tolist())
ids = [i for i in range(model.ngeom) if i not in robot_ids and model.geom_contype[i]]
corners = np.array([[a,b,c] for a in (-1,1) for b in (-1,1) for c in (-1,1)])
boxes=[]
for i in ids:
if model.geom_type[i] != mujoco.mjtGeom.mjGEOM_BOX:
raise ValueError(f"Unsupported terrain geometry: {model.geom(i).name}")
center, rot, size = data.geom_xpos[i],data.geom_xmat[i].reshape(3,3),model.geom_size[i]
points = (corners*size) @ rot.T+center
boxes.append((center.copy(),rot.copy(),size.copy(),points.min(0),points.max(0)))
lo=np.min([b[3][:2] for b in boxes],axis=0)-resolution
hi=np.max([b[4][:2] for b in boxes],axis=0)+resolution
shape=np.ceil((hi-lo)/resolution).astype(int)+1
heights=np.zeros((shape[1],shape[0]),dtype=np.float32)
for center,rot,size,low,high in boxes:
start=np.maximum(0,np.floor((low[:2]-lo)/resolution).astype(int))
end=np.minimum(shape,np.ceil((high[:2]-lo)/resolution).astype(int)+1)
xs=lo[0]+np.arange(start[0],end[0])*resolution
ys=lo[1]+np.arange(start[1],end[1])*resolution
x,y=np.meshgrid(xs,ys)
# Intersect vertical line p=(x,y,z) with all three oriented-box slabs.
lower=np.full_like(x,-np.inf); upper=np.full_like(x,np.inf); valid=np.ones_like(x,dtype=bool)
for k in range(3):
base=rot[0,k]*(x-center[0])+rot[1,k]*(y-center[1])-rot[2,k]*center[2]
slope=rot[2,k]
if abs(slope)<1e-10:
valid &= np.abs(base)<=size[k]+1e-9
else:
z1=(-size[k]-base)/slope; z2=(size[k]-base)/slope
lower=np.maximum(lower,np.minimum(z1,z2)); upper=np.minimum(upper,np.maximum(z1,z2))
surface=np.where(valid & (lower<=upper),upper,0)
patch=heights[start[1]:end[1],start[0]:end[0]]
np.maximum(patch,surface,out=patch)
return cls(torch.as_tensor(heights,device=env.device),tuple(lo),resolution)
def advance_pose(pose, delta):
c,s=pose[:,2].cos(),pose[:,2].sin()
return pose+torch.stack((c*delta[:,0]-s*delta[:,1],s*delta[:,0]+c*delta[:,1],delta[:,2]),-1)
class TerrainBridge(S10Bridge):
def __init__(self, env, field):
super().__init__(env)
self.field=field
def state(self):
state=super().state()
state[:,0]-=self.field.sample(self.robot.data.root_link_pos_w[:,:2])
return state
+33
View File
@@ -0,0 +1,33 @@
"""Shared real/imagined posture objective for the opt-in terrain v2 task.
S10 joint order is hipx (abduction), hipy (flexion), knee. Hipx keeps a 2x
weight while hipy/knee relax on rough terrain. v7 sets
``relief_floor=0.02`` so stairs leave knee/hipy deviation almost unpenalised
and the policy can flex instead of holding a stiff leg.
"""
import torch
LEG_SCALES = (0.25, 0.6, 0.8) * 4
def posture_cost(q, scan, relief_floor=0.125):
# Only the supporting/approaching ground near the body should relax posture.
# scan is body height minus ground; its range is independent of body height.
local = scan.reshape(-1, 21, 13)[:, 5:16, 3:10].flatten(1)
relief = ((local.amax(-1) - local.amin(-1)) / 0.12).clamp(0., 1.)
multiplier = (1. - relief).clamp_min(relief_floor)
legs = q.reshape(-1, 4, 3).square()
# Hip abduction stays constrained even on steps; flexion is freer there.
return 2. * legs[:, :, 0].sum(-1) + multiplier * legs[:, :, 1:].sum((1, 2))
def env_posture_cost(env, asset_cfg, relief_floor=0.125):
from .terrain import TerrainField, TerrainBridge
bridge = getattr(env, "_s10_posture_bridge", None)
if bridge is None:
bridge = TerrainBridge(env, TerrainField.from_env(env))
env._s10_posture_bridge = bridge
d = env.scene[asset_cfg.name].data
q = d.joint_pos[:, asset_cfg.joint_ids] - d.default_joint_pos[:, asset_cfg.joint_ids]
return posture_cost(q, bridge.field.scan(bridge.state(), bridge.pose()),
relief_floor)
+120
View File
@@ -0,0 +1,120 @@
"""S10 terrain dynamics. Flat model checkpoints remain a separate format."""
import torch
from torch import nn
from .world_model import S10WorldModel, RunningNorm
from .terrain import SCAN_DIM, advance_pose
FORMAT = "s10-terrain-dynamics-v1"
POLICY_FORMAT = "s10-terrain-policy-v1"
class TerrainWorldModel(S10WorldModel):
def __init__(self, hidden=(512,512,512), contacts=False, collisions=False):
super().__init__(hidden)
self.net[0] = nn.Linear(77+SCAN_DIM,hidden[0])
self.input_norm = RunningNorm(77+SCAN_DIM)
self.predict_contacts = contacts
if contacts:
self.contact_head = nn.Linear(hidden[-1],16)
self.predict_collisions = collisions
if collisions:
self.collision_head = nn.Linear(hidden[-1],1)
nn.init.zeros_(self.collision_head.weight)
nn.init.constant_(self.collision_head.bias, -4.6)
self.last_contact_metrics = {}
def forward_all(self, state, action, previous_action, context):
"""Predict endpoint state, pose, optional contacts and non-wheel collision logit."""
x = self.input_norm(self.inputs(state,action,previous_action,context))
for layer in list(self.net.children())[:-1]:
x = layer(x)
delta = self.net[-1](x)*self.output_norm.scale+self.output_norm.mean
contacts = self.contact_head(x) if self.predict_contacts else None
collision = self.collision_head(x) if self.predict_collisions else None
return state+delta[:,:38],delta[:,38:],contacts,collision
def forward_with_contacts(self, state, action, previous_action, context):
"""Predict endpoint state, pose, and optional [logits4, asinh(force/100)12]."""
pred,delta,contacts,_ = self.forward_all(state,action,previous_action,context)
return pred,delta,contacts
def forward(self, state, action, previous_action, context):
pred,delta,_ = self.forward_with_contacts(state,action,previous_action,context)
return pred,delta
@staticmethod
def decode_contacts(prediction):
"""Contact probabilities and signed base-frame force vectors in newtons."""
return prediction[:,:4].sigmoid(),100.*prediction[:,4:].clamp(-10,10).sinh().reshape(-1,4,3)
@staticmethod
def inputs(state, action, previous_action, context):
if context.shape[-1] != 7+SCAN_DIM:
raise ValueError("Terrain dynamics require context7 plus height scan273")
return torch.cat((S10WorldModel.inputs(state,action,previous_action,context[:,:7]),context[:,7:]),-1)
def terrain_loss(self, b, field):
state,pose=b["state"][:,0],b["world_pose"][:,0]
mask=b["valid"].squeeze(-1).bool().cumprod(1).bool()
# Include the first terminal transition for collision classification only.
prefix=torch.cat((torch.ones_like(mask[:,:1]),mask[:,:-1]),1)
total=next(self.parameters()).sum()*0.
contact_total=total.detach().clone(); force_total=total.detach().clone()
collision_total=total.clone(); collision_count=total.detach().clone()
positives=total.detach().clone(); predicted=total.detach().clone()
for t in range(b["action"].shape[1]):
keep=prefix[:,t] if self.predict_collisions else mask[:,t]
if not keep.any(): break
context=torch.cat((b["context"][keep,0],field.scan(state[keep],pose[keep])),-1)
pred,delta,contact,collision=self.forward_all(state[keep],b["action"][keep,t],b["previous"][keep,t],context)
current=mask[keep,t]
# Never regress the reset state as the endpoint of a collision.
if current.any():
error=torch.cat((pred[current]-b["next"][keep,t][current],delta[current]-b["pose"][keep,t][current]),-1)
total=total+(error/self.output_norm.scale).square().mean(-1).sum()
if self.predict_contacts:
target=b["next_contacts"][keep,t][current]
finite=torch.isfinite(target).all(-1)
# Missing/non-finite sensor labels must not poison dynamics gradients.
prediction=contact[current][finite]; target=target[finite]
classification=torch.nn.functional.binary_cross_entropy_with_logits(
prediction[:,:4],target[:,:4],reduction='none').mean(-1)
force=torch.nn.functional.smooth_l1_loss(prediction[:,4:],
torch.asinh(target[:,4:]/100.),reduction='none').mean(-1)
total=total+.1*(classification+force).sum()
contact_total=contact_total+classification.detach().sum()
force_total=force_total+force.detach().sum()
if self.predict_collisions:
label=b['collision'][keep,t]
usable=b['collision_valid'][keep,t].squeeze(-1).bool() & torch.isfinite(label).all(-1)
logits=collision[usable]; labels=label[usable]
loss=torch.nn.functional.binary_cross_entropy_with_logits(logits,labels,reduction='sum')
collision_total=collision_total+loss
collision_count=collision_count+usable.sum()
positives=positives+labels.detach().sum()
predicted=predicted+logits.detach().sigmoid().sum()
next_pose=advance_pose(pose[keep],delta)
state=state.clone(); pose=pose.clone()
state[keep]=pred; pose[keep]=next_pose
count=mask.sum().clamp_min(1)
if self.predict_contacts:
self.last_contact_metrics={'contact_bce':contact_total/count,'contact_force_loss':force_total/count}
result=total/count
if self.predict_collisions:
denom=collision_count.clamp_min(1)
result=result+collision_total/denom
self.last_contact_metrics.update(collision_bce=collision_total.detach()/denom,
collision_label_rate=positives/denom,collision_prediction_mean=predicted/denom,
collision_label_count=collision_count)
return result
def checkpoint(self):
return {"format":FORMAT,"hidden":self.hidden,"contacts":self.predict_contacts,
"collisions":self.predict_collisions,"model":self.state_dict()}
@classmethod
def from_checkpoint(cls, checkpoint, device="cpu"):
if checkpoint.get("format") != FORMAT: raise ValueError("Expected terrain dynamics checkpoint")
model=cls(checkpoint["hidden"],contacts=checkpoint.get("contacts",False),
collisions=checkpoint.get("collisions",False)).to(device)
model.load_state_dict(checkpoint["model"])
return model
+782
View File
@@ -0,0 +1,782 @@
"""Online S10 model-based learning: collect, fit dynamics, imagine, optimize."""
import argparse
import copy
import json
import math
import time
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
import torch
from .bridge import S10Bridge, pose_delta
from .world_model import S10WorldModel
from .policy import (Actor, Network, CRITIC_DIM, POLICY_FORMAT,
checkpoint_wheel_vel, critic_input)
from .replay import Replay
from .imagine import frozen, rollout, make_imagined_step
from .recovery import recover_invalid_envs
from .upward import (TILT_STAGES_DEG, init_upward_state,
upward_curriculum_stats, upward_video_tilt_limit)
from s10_supermimic.tasks import get_task, list_tasks, task_names
def _format_value(value):
if value is None:
return "n/a"
if isinstance(value, float):
return f"{value:+.4f}" if abs(value) >= 1e-3 else f"{value:.3e}"
if isinstance(value, (list, tuple)):
return "[" + ", ".join(_format_value(v) for v in value) + "]"
return str(value)
def format_training_log(row, args, reward_terms, termination_rates):
"""IsaacLab-style multi-line training summary; the JSONL stays machine-readable."""
def line(label, value):
return f"{label:>42s}: {value}"
total = row.get("total_transitions", 0)
elapsed = max(row.get("elapsed_s", 0.0), 1e-9)
lines = [
"",
"=" * 78,
f"Learning iteration {row.get('step', 0)}/{args.iterations}",
line("Total steps", f"{total:,}"),
line("Steps per second", f"{total / elapsed:,.0f}"),
line("Iteration time", f"{row.get('iteration_time_s', 0.0):.3f}s"),
line("Collection time", f"{row.get('collection_time_s', 0.0):.3f}s"),
line("Learning time", f"{row.get('learning_time_s', 0.0):.3f}s"),
line("Mean reward", f"{row.get('reward', 0.0):+.4f}"),
line("Mean episode length", f"{row.get('mean_episode_length', 0.0):.1f}"),
line("Actor loss", _format_value(row.get("actor_loss"))),
line("Critic loss", _format_value(row.get("critic_loss"))),
line("World model loss", _format_value(row.get("wm_loss"))),
line("Mean action noise", f"{args.action_noise:.3f}"),
line("Skipped updates", row.get("skipped_updates", 0)),
"-" * 78,
]
for name, value in reward_terms.items():
lines.append(line(f"Episode_Reward/{name}", f"{value:+.4f}"))
for key in ("terrain_level_mean", "terrain_level_frac",
"tilt_level_mean", "tilt_level_q75", "tilt_level_frac",
"tilt_limit_mean_deg", "recovery_window_rate",
"traverse_window_rate", "recovery_window_ready"):
if key in row:
lines.append(line(f"Curriculum/{key}", _format_value(row[key])))
for key in ("command_stage", "command_mode", "command_ranges"):
if key in row:
lines.append(line(f"Command/{key}", _format_value(row[key])))
vmae = row.get("velocity_mae")
if vmae:
lines.append(line("Metrics/base_velocity/error_vel_xy",
f"{(vmae[0] + vmae[1]) / 2:.4f}"))
lines.append(line("Metrics/base_velocity/error_vel_yaw", f"{vmae[2]:.4f}"))
for name, value in termination_rates.items():
lines.append(line(f"Episode_Termination/{name}", f"{value:.4f}"))
lines.append("=" * 78)
return "\n".join(lines)
def constants_from_bridge(bridge):
"""Fixed gains/limits of this S10 task; no per-environment gain randomization."""
d, dev = bridge.robot.data, bridge.env.device
from .terrain_posture import env_posture_cost, LEG_SCALES
from .upward import UPWARD_REWARD_CLAMPS, is_upward_task
from s10_supermimic.robot.config import LEG_JOINT_NAMES
upward = is_upward_task(bridge.env)
revised = (bridge.env.cfg.rewards["leg_posture"].func is env_posture_cost
or "wheel_obstacle_clearance" in bridge.env.cfg.rewards
or upward)
# Read the actual leg actuator gains so the imagined torque surrogate matches
# v4 (kp=80) and the softer v7 (kp=20) without duplicating constants.
robot_cfg = bridge.env.cfg.scene.entities["robot"]
leg_act = next(
(a for a in robot_cfg.articulation.actuators
if set(LEG_JOINT_NAMES).issubset(set(getattr(a, "target_names_expr", ())))),
None)
leg_kp = float(getattr(leg_act, "stiffness", 80.0))
leg_kd = float(getattr(leg_act, "damping", 2.0))
leg_effort = float(getattr(leg_act, "effort_limit", 50.0))
return {
"terrain_posture_v2": revised,
"nonwheel_collision": "illegal_nonwheel_contact" in bridge.env.cfg.terminations,
"wheel_local_rewards": "wheel_obstacle_clearance" in bridge.env.cfg.rewards,
"rolling_rewards": "unnecessary_wheel_lift" in bridge.env.cfg.rewards,
"obstacle_progress": "obstacle_progress" in bridge.env.cfg.rewards,
"wheel_body_intrusion": "wheel_body_intrusion" in bridge.env.cfg.rewards,
"pair_body_intrusion": "pair_body_intrusion" in bridge.env.cfg.rewards,
"posture_relief_floor": bridge.env.cfg.rewards["leg_posture"].params.get(
"relief_floor", 0.125),
"upright_mode": "upward" if upward else "exp",
"bad_orientation_penalty": upward,
"reward_clamps": dict(UPWARD_REWARD_CLAMPS) if upward else {},
"wheel_action_scale": bridge.env.cfg.actions["wheel_vel"].scale,
"leg_action_scale": torch.tensor(LEG_SCALES if revised else (.25,) * 12, device=dev),
"terrain_rewards": hasattr(bridge, "field"),
"upright_std": bridge.env.cfg.rewards["upright"].params.get("std", .6),
"default_q": d.default_joint_pos[0, bridge.legs].detach().clone(),
"limits": d.soft_joint_pos_limits[0, bridge.legs].detach().clone(),
# S10 leg actuators: v4 kp=80/kd=2, v7 kp=20/kd=2; effort=50 N·m.
# TODO(s10): the -2e-5 joint_torques_l2 weight was inherited from the
# initial recipe; retune if S10 training shows torque suppression or chatter.
"kp": torch.full((12,), leg_kp, device=dev),
"kd": torch.full((12,), leg_kd, device=dev),
"effort": torch.full((12,), leg_effort, device=dev),
# S10 wheel actuator: velocity damping 0.8, effort 14 N·m, 35 rad/s clip.
"wheel_kd": 0.8,
"wheel_effort": 14.0,
"wheel_velocity_limit": 35.0,
}
def actor_learning_rate(args, iteration):
age = max(0, iteration - args.actor_schedule_start)
progress = min(1., max(0, age - args.actor_hold_iterations) / max(1, args.lr_decay_iterations))
decay = args.lr_final_frac + (1.-args.lr_final_frac)*.5*(1.+math.cos(math.pi*progress))
ramp = min(1., (age+1) / max(1, args.actor_ramp_iterations))
return args.lr * args.actor_lr_scale * decay * ramp
def update(optimizer, loss, parameters, element_clip=None):
parameters = list(parameters)
optimizer.zero_grad(set_to_none=True)
if not torch.isfinite(loss):
print("RECOVERY skipped non-finite loss; optimizer unchanged", flush=True)
return False
loss.backward()
norm = torch.nn.utils.clip_grad_norm_(parameters, 1., error_if_nonfinite=False)
if not torch.isfinite(norm):
optimizer.zero_grad(set_to_none=True)
print("RECOVERY skipped non-finite gradient; optimizer unchanged", flush=True)
return False
if element_clip is not None:
torch.nn.utils.clip_grad_value_(parameters, element_clip)
optimizer.step()
return True
def record_policy_video(actor, args, output, iteration, env_order,
upward_tilt_limit=None, command_ranges=None):
"""Record a short MP4 with the current actor on a fresh single-env scene.
This mirrors Isaac Lab's ``--video``/``--video_length``/``--video_interval``
workflow. A separate one-env scene is used so recording does not perturb the
training environments. Recording is skipped if the optional mediapy/EGL
dependencies or the extra scene cannot be created.
"""
import random
import numpy as np
state = (
random.getstate(),
np.random.get_state(),
torch.get_rng_state(),
torch.cuda.get_rng_state_all() if torch.cuda.is_available() else None,
)
video_env = None
try:
import mediapy
from mjlab.envs import ManagerBasedRlEnv
from .bridge import S10Bridge
task = get_task(args.task) if getattr(args, "task", None) else None
if task is not None:
if task.actor.wheel_vel != actor.actor_wheel_vel:
raise ValueError("Task Actor schema differs from the training Actor")
cfg = task.build_env_cfg(play=True, history=args.history, seed=args.seed)
elif args.terrain:
from s10_supermimic.env.terrain import s10_velocity_terrain_env_cfg
cfg = s10_velocity_terrain_env_cfg(
play=True, history=args.history, seed=args.seed,
revision=args.terrain_revision, actor_wheel_vel=actor.actor_wheel_vel)
else:
from s10_supermimic.env.flat import s10_velocity_flat_env_cfg
cfg = s10_velocity_flat_env_cfg(
play=True, history=args.history,
actor_wheel_vel=actor.actor_wheel_vel)
cfg.scene.num_envs = 1
cfg.rewards = {}
cfg.metrics = {}
cfg.observations.pop("critic", None)
cfg.episode_length_s = 1e9
cfg.viewer.width = args.video_width
cfg.viewer.height = args.video_height
cfg.viewer.distance = 2.5
cfg.viewer.elevation = -20.
cfg.viewer.azimuth = 140.
video_env = ManagerBasedRlEnv(
cfg=cfg, device=args.device, render_mode="rgb_array")
if command_ranges is not None:
ranges = video_env.command_manager.get_term("twist").cfg.ranges
ranges.lin_vel_x, ranges.lin_vel_y, ranges.ang_vel_z = command_ranges
bridge = S10Bridge(video_env)
if list(bridge.to_env) != list(env_order):
raise ValueError("Video env action order differs from the training env")
if upward_tilt_limit is not None:
video_env._upward_tilt_limits = torch.full(
(video_env.num_envs,), float(upward_tilt_limit), device=video_env.device)
obs, _ = video_env.reset()
path = output / "videos" / f"iteration_{iteration:06d}.mp4"
path.parent.mkdir(parents=True, exist_ok=True)
was_training = actor.training
actor.eval()
with torch.no_grad(), mediapy.VideoWriter(
str(path), shape=(args.video_height, args.video_width),
fps=args.video_fps) as writer:
for _ in range(args.video_length):
action = actor(obs["actor"])
obs, _, terminated, truncated, _ = video_env.step(
bridge.env_action(action))
writer.add_image(video_env.render())
if bool(terminated.any() or truncated.any()):
obs, _ = video_env.reset()
actor.train(was_training)
print(f"S10 video saved: {path}", flush=True)
finally:
if video_env is not None:
video_env.close()
if torch.cuda.is_available():
torch.cuda.empty_cache()
random.setstate(state[0])
np.random.set_state(state[1])
torch.set_rng_state(state[2])
if state[3] is not None:
torch.cuda.set_rng_state_all(state[3])
class Learner:
"""Pure-Torch learner; also usable in small CPU tests without mjlab."""
def __init__(self, args, field=None):
self.args, self.field = args, field
self.format = POLICY_FORMAT
self.model_class = S10WorldModel
extra = 0
if field is not None:
from .terrain_world_model import TerrainWorldModel, POLICY_FORMAT as TERRAIN_FORMAT
from .terrain import SCAN_DIM
self.model_class, self.format, extra = TerrainWorldModel, TERRAIN_FORMAT, SCAN_DIM
self.model = self.model_class(tuple(args.wm_hidden), **(
{"contacts":args.terrain_revision >= 4,
"collisions":args.terrain_revision >= 7} if field is not None else {})).to(args.device)
self.actor = Actor(args.history, tuple(args.actor_hidden),
args.actor_wheel_vel).to(args.device)
self.critic = Network(CRITIC_DIM + extra, 1, tuple(args.critic_hidden)).to(args.device)
self.target = copy.deepcopy(self.critic)
self.optimizers = [torch.optim.Adam(module.parameters(), lr=args.lr)
for module in (self.model, self.actor, self.critic)]
self.policy_updates = 0
self.skipped_updates = 0
self.terrain_state = None
self.curriculum_state = None
self.env_steps = 0
self.imagined_step = None
def critic_input(self, state, command, context, previous, pose):
x = critic_input(state, command, context, previous)
return x if self.field is None else torch.cat((x, self.field.scan(state, pose)), -1)
def fit(self, b):
if self.field is not None:
loss = self.model.terrain_loss(b, self.field)
if not update(self.optimizers[0], loss, self.model.parameters()):
self.skipped_updates += 1
return None
return loss.item()
loss = self.model.loss(b["state"][:, 0], b["action"], b["previous"], b["context"][:, 0],
b["next"], b["pose"], b["valid"].squeeze(-1))
if not update(self.optimizers[0], loss, self.model.parameters()):
self.skipped_updates += 1
return None
return loss.item()
def improve(self, seed, constants, weights, env_order, dt):
args = self.args
if args.compile_imagination and self.imagined_step is None:
step = make_imagined_step(self.model,self.actor,constants,weights,env_order,
dt=dt,field=self.field)
self.imagined_step = torch.compile(step, dynamic=False, fullgraph=True)
with frozen(self.model, self.target):
actor_loss, inputs, targets = rollout(
self.model, self.actor, self.target, seed, constants, weights, env_order,
horizon=args.horizon, dt=dt, gamma=args.gamma, lam=args.lam, field=self.field,
step_fn=self.imagined_step)
if not update(self.optimizers[1], actor_loss, self.actor.parameters(), args.actor_grad_clip_element):
self.skipped_updates += 1
return None, None
x, y = inputs.flatten(0, 1), targets.flatten()
critic_losses = []
for _ in range(args.critic_epochs):
order = torch.randperm(len(x),device=x.device)
for indices in order.tensor_split(min(args.critic_minibatches,len(x))):
value = self.critic(x[indices]).squeeze(-1)
critic_loss = torch.nn.functional.mse_loss(value, y[indices])
if not update(self.optimizers[2], critic_loss, self.critic.parameters()):
self.skipped_updates += 1
return actor_loss.item(), None
critic_losses.append(critic_loss.detach())
with torch.no_grad():
for target, source in zip(self.target.parameters(), self.critic.parameters()):
target.lerp_(source, 0.02)
# Running normalization is a coordinate system, not an EMA weight.
self.target.norm.load_state_dict(self.critic.norm.state_dict())
self.policy_updates += 1
return actor_loss.item(), torch.stack(critic_losses).mean().item()
def save(self, path, step, env_order, dt):
checkpoint = {
"training_algorithm": "s10-full-return-events-v1",
"format": self.format, "step": step, "history": self.actor.history,
"actor_wheel_vel": self.actor.actor_wheel_vel,
"actor_hidden": self.actor.hidden, "actor": self.actor.state_dict(),
"world_model": self.model.checkpoint(), "critic": self.critic.state_dict(),
"target_critic": self.target.state_dict(), "config": vars(self.args),
"optimizers": [o.state_dict() for o in self.optimizers],
"terrain_state": self.terrain_state,
"curriculum_state": self.curriculum_state,
"env_steps": self.env_steps,
"policy_updates": self.policy_updates, "env_action_order": list(env_order), "dt": dt,
}
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
torch.save(checkpoint, tmp)
tmp.replace(path)
def load(self, path, env_order, dt):
ckpt = torch.load(path, map_location=self.args.device, weights_only=True)
if ckpt.get("format") != self.format:
raise ValueError("Resume requires a S10 policy checkpoint")
if ckpt["env_action_order"] != list(env_order) or abs(ckpt["dt"] - dt) > 1e-8:
raise ValueError("Checkpoint action order/control timestep differs from this environment")
if ckpt["history"] != self.args.history:
raise ValueError("Checkpoint observation history differs")
if self.field is not None and ckpt["config"]["seed"] != self.args.seed:
raise ValueError("Terrain resume requires the same terrain seed")
if ckpt["config"].get("terrain_revision", 1) != getattr(self.args, "terrain_revision", 1):
raise ValueError("Terrain revision/action scale differs; start a new run for a different revision")
if checkpoint_wheel_vel(ckpt) != self.actor.actor_wheel_vel:
raise ValueError("Actor observation schema differs; choose the matching task or start a new run")
self.actor.load_state_dict(ckpt["actor"])
self.model.load_state_dict(self.model_class.from_checkpoint(ckpt["world_model"]).state_dict())
self.critic.load_state_dict(ckpt["critic"])
self.target.load_state_dict(ckpt["target_critic"])
for optimizer, data in zip(self.optimizers, ckpt["optimizers"]):
optimizer.load_state_dict(data)
self.policy_updates = ckpt["policy_updates"]
self.terrain_state = ckpt.get("terrain_state")
self.curriculum_state = ckpt.get("curriculum_state")
self.env_steps = ckpt.get("env_steps", ckpt["step"] * ckpt["config"].get("num_steps_per_env", 1))
return ckpt["step"]
def parser():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--task", choices=task_names(), default=None,
help="Select a registered S10 task; its defaults are applied before CLI overrides")
p.add_argument("--list-tasks", action="store_true", help="List registered tasks and exit")
for name, default in (("num-envs", 64), ("iterations", 10000), ("buffer-len", 512),
("history", 5), ("wm-window", 8), ("wm-batch", 128),
("wm-updates", 2), ("imag-batch", 128), ("horizon", 16),
("warmup", 300), ("critic-epochs", 5), ("save-interval", 1000),
("log-interval", 50), ("seed", 0), ("num-steps-per-env", 1), ("critic-minibatches", 4),
("archive-size", 100000), ("actor-ramp-iterations", 1000),
("lr-decay-iterations", 30000), ("actor-hold-iterations", 0),
("actor-schedule-start", 0)):
p.add_argument("--" + name, type=int, default=default)
for name, default in (("lr", 2e-4), ("action-noise", 0.15), ("gamma", 0.99), ("lam", 0.95)):
p.add_argument("--" + name, type=float, default=default)
p.add_argument("--compile-imagination", action="store_true")
p.add_argument("--no-compile-imagination", dest="compile_imagination",
action="store_false", default=False)
p.add_argument("--prioritized-replay", action="store_true")
p.add_argument("--actor-lr-scale", type=float, default=.3)
p.add_argument("--lr-final-frac", type=float, default=.03)
p.add_argument("--actor-grad-clip-element", type=float, default=.1)
p.add_argument("--wm-hidden", type=int, nargs="+", default=[512, 512, 512])
p.add_argument("--actor-hidden", type=int, nargs="+", default=[256, 128, 64])
p.add_argument("--critic-hidden", type=int, nargs="+", default=[256, 128, 64])
p.add_argument("--device", default="cuda:0")
p.add_argument("--terrain", action="store_true", help="Blind static rough-terrain task")
p.add_argument("--terrain-revision", type=int, choices=(1, 2, 3, 4, 5, 6, 7), default=1,
help="2: larger leg actions; 3: bidirectional commands; 4: rolling rewards; 5: performance-gated stair curriculum; 6: wheel-local clearance; 7: safe v7 (53-value Actor, time-ramped 1.5/0.8/1.2 commands, ±45° init, wheel/body intrusion, collision); new run required")
p.add_argument("--command-curriculum", action="store_true", help="Progressively widen velocity ranges")
p.add_argument("--command-curriculum-start", type=int, default=0, help="Learner iteration where the velocity schedule starts; preserve on resume")
p.add_argument("--init-actor", default="", help="Initialize only the actor from a flat policy; dynamics/critic start fresh")
p.add_argument("--resume", default="")
p.add_argument("--output", default="")
p.add_argument("--video", action="store_true", help="Record periodic MP4 videos with the current actor")
p.add_argument("--video-length", type=int, default=500, help="Frames per video")
p.add_argument("--video-interval", type=int, default=15000, help="Record every N learner iterations")
p.add_argument("--video-fps", type=int, default=50, help="Video frame rate")
p.add_argument("--video-width", type=int, default=640)
p.add_argument("--video-height", type=int, default=480)
return p
def main():
p = parser()
pre, _ = p.parse_known_args()
if pre.list_tasks:
for spec in list_tasks():
print(f"{spec.name}: {spec.description}")
return
task = get_task(pre.task) if pre.task else None
if task is not None:
p.set_defaults(**task.defaults)
args = p.parse_args()
if task is not None and args.terrain_revision != task.terrain_revision:
p.error(f"Task {task.name} requires terrain-revision {task.terrain_revision}")
if task is not None:
args.actor_wheel_vel = task.actor.wheel_vel
elif args.resume:
resumed = torch.load(args.resume, map_location="cpu", weights_only=True)
args.actor_wheel_vel = checkpoint_wheel_vel(resumed)
else:
args.actor_wheel_vel = args.terrain_revision <= 4 or args.terrain_revision >= 7
positive = (args.archive_size, args.critic_minibatches, args.lr_decay_iterations, args.num_steps_per_env, args.num_envs, args.iterations, args.buffer_len, args.history, args.wm_window,
args.wm_batch, args.wm_updates, args.imag_batch, args.horizon, args.critic_epochs,
args.save_interval, args.log_interval, *args.wm_hidden, *args.actor_hidden,
*args.critic_hidden)
if min(positive) < 1 or args.warmup < 0 or args.buffer_len < args.wm_window:
p.error("Positive sizes required; buffer-len must be >= wm-window; warmup >= 0")
if not (0 < args.gamma <= 1 and 0 <= args.lam <= 1 and args.lr > 0 and args.action_noise >= 0):
p.error("Invalid optimizer, noise or return parameters")
if args.actor_lr_scale <= 0 or not 0 < args.lr_final_frac <= 1 or args.actor_ramp_iterations < 0 or args.actor_grad_clip_element <= 0:
p.error("Invalid actor learning-rate/gradient protection")
if args.actor_hold_iterations < 0:
p.error("actor-hold-iterations must be nonnegative")
if args.terrain_revision >= 5 and args.archive_size < 2:
p.error("terrain v5+ requires archive-size >=2")
if 5 <= args.terrain_revision < 7 and not args.command_curriculum:
p.error("terrain v5/v6 requires command-curriculum; v7 uses a time-based cap")
if args.video and (args.video_length < 1 or args.video_interval < 1
or not 1 <= args.video_fps <= 50
or args.video_width < 64 or args.video_height < 64
or args.video_width % 2 or args.video_height % 2):
p.error("Video requires length/interval >=1, fps 1..50, even width/height >=64")
if args.prioritized_replay and (not args.terrain or args.terrain_revision < 4):
p.error("prioritized-replay requires measured contacts from terrain revision >=4")
from mjlab.envs import ManagerBasedRlEnv
from s10_supermimic.env.flat import s10_velocity_flat_env_cfg
torch.manual_seed(args.seed)
if args.resume and args.init_actor:
p.error("Choose resume or init-actor")
if args.terrain_revision >= 2 and (not args.terrain or args.init_actor):
p.error("terrain-revision >=2 requires --terrain and cannot initialize a flat actor")
if task is not None:
if task.actor.wheel_vel != args.actor_wheel_vel:
p.error("Task Actor schema differs from the requested Actor schema")
cfg = task.build_env_cfg(play=False, history=args.history, seed=args.seed)
elif args.terrain:
from s10_supermimic.env.terrain import s10_velocity_terrain_env_cfg
cfg = s10_velocity_terrain_env_cfg(
history=args.history, seed=args.seed, revision=args.terrain_revision,
actor_wheel_vel=args.actor_wheel_vel)
else:
cfg = s10_velocity_flat_env_cfg(
history=args.history, actor_wheel_vel=args.actor_wheel_vel)
if args.command_curriculum:
if not args.terrain or args.terrain_revision < 4:
p.error("command-curriculum requires terrain revision >=4")
from .command_curriculum import set_command_stage, stages_for
set_command_stage(cfg.commands["twist"], 0,
stages=stages_for(args.terrain_revision))
cfg.seed = args.seed
cfg.scene.num_envs = args.num_envs
env = ManagerBasedRlEnv(cfg=cfg, device=args.device)
try:
if args.task == "s10-v4-upward":
init_upward_state(env)
obs, _ = env.reset()
field = None
if args.terrain:
from .terrain import TerrainField, TerrainBridge
field = TerrainField.from_env(env)
bridge = TerrainBridge(env, field)
env._s10_posture_bridge = bridge
print(f"Shared terrain atlas: {field.h.numel()*4/2**20:.1f} MiB", flush=True)
else:
bridge = S10Bridge(env)
learner = Learner(args, field)
if args.init_actor:
ckpt = torch.load(args.init_actor, map_location=args.device, weights_only=True)
if ckpt.get("format") != POLICY_FORMAT or ckpt["history"] != args.history:
raise ValueError("init-actor requires a matching flat S10 actor")
if ckpt["env_action_order"] != list(bridge.to_env) or abs(ckpt["dt"]-env.step_dt)>1e-8:
raise ValueError("Warm-start action contract differs")
if checkpoint_wheel_vel(ckpt) != learner.actor.actor_wheel_vel:
raise ValueError("Warm-start actor observation schema differs")
learner.actor.load_state_dict(ckpt["actor"])
constants = constants_from_bridge(bridge)
weights = {name: term.weight for name, term in cfg.rewards.items()}
contact_targets = args.terrain and args.terrain_revision >= 4
replay = Replay(args.buffer_len, args.num_envs, args.device,
learner.actor.frame_dim * args.history,
terrain=args.terrain, contacts=contact_targets,
courses=args.terrain_revision >= 5,
collisions=args.terrain_revision >= 7,
pairs=args.terrain_revision >= 7)
start = learner.load(args.resume, bridge.to_env, env.step_dt) if args.resume else 0
command_stage = 0
if args.command_curriculum:
saved_stage = None
if args.terrain_revision >= 5:
saved_stage = (int(learner.curriculum_state['stage'])
if learner.curriculum_state else 0)
command_stage = set_command_stage(
env.command_manager.get_term("twist").cfg,
start - args.command_curriculum_start,
stage=saved_stage, stages=stages_for(args.terrain_revision))
if args.terrain and learner.terrain_state is not None:
terrain = env.scene.terrain
levels = learner.terrain_state["levels"].to(args.device)
types = learner.terrain_state["types"].to(args.device)
if levels.shape != terrain.terrain_levels.shape:
raise ValueError("Terrain resume requires the same num-envs")
terrain.terrain_levels.copy_(levels)
terrain.terrain_types.copy_(types)
terrain.env_origins.copy_(terrain.terrain_origins[levels, types])
obs, _ = env.reset()
if args.terrain_revision >= 5:
from .stair_curriculum import buffers, next_stage, summary
stats = buffers(env)
saved = learner.curriculum_state
velocity_ema = saved['velocity_ema'] if saved else [1., 1., 1.]
if saved:
stats.copy_(saved['stats'].to(env.device))
if args.terrain_revision >= 5:
command_stage = int(saved['stage']) if saved else 0
if args.command_curriculum:
set_command_stage(env.command_manager.get_term('twist').cfg, 0,
stage=command_stage,
stages=stages_for(args.terrain_revision))
if start >= args.iterations:
raise ValueError("iterations must exceed the resumed checkpoint step")
if obs["actor"].shape[-1] != learner.actor.frame_dim * args.history:
raise ValueError("S10 actor observation dimension mismatch")
output = Path(args.output or "logs/s10/" + datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y-%m-%d_%H-%M-%S"))
output.mkdir(parents=True, exist_ok=True)
previous = torch.zeros(args.num_envs, 16, device=args.device)
ou = torch.zeros_like(previous)
totals = torch.zeros(6, device=args.device)
reward_totals = torch.zeros(len(env.reward_manager.active_terms), device=args.device)
interval_steps = 0
nonwheel_terminations = 0
invalid_env_resets = 0
episode_length_sum = 0.0
episode_count = 0
termination_counts = {name: 0 for name in env.termination_manager.active_terms}
collection_time_sum = 0.0
learning_time_sum = 0.0
began = time.monotonic()
print(f"S10 replay storage: {sum(t.numel() * t.element_size() for t in replay.data.values()) / 2**20:.1f} MiB", flush=True)
if args.video:
try:
ranges = env.command_manager.get_term("twist").cfg.ranges
record_policy_video(
learner.actor, args, output, start, bridge.to_env,
upward_tilt_limit=(upward_video_tilt_limit(env)
if args.task == "s10-v4-upward" else None),
command_ranges=(ranges.lin_vel_x, ranges.lin_vel_y, ranges.ang_vel_z))
except Exception as exc:
print(f"RECOVERY skipped initial video: {exc}", flush=True)
with (output / "metrics.jsonl").open("a") as log:
for step in range(start, args.iterations):
iter_start = time.monotonic()
for group in learner.optimizers[1].param_groups:
group["lr"] = actor_learning_rate(args, step)
if args.command_curriculum and args.terrain_revision < 5:
command_stage = set_command_stage(
env.command_manager.get_term("twist").cfg,
step - args.command_curriculum_start,
stages=stages_for(args.terrain_revision))
for _ in range(args.num_steps_per_env):
with torch.no_grad():
history = obs["actor"].clone()
state, pose, context = bridge.state(), bridge.pose(), bridge.context()
command = env.command_manager.get_command("twist").clone()
bias = bridge.robot.data.encoder_bias[:, bridge.legs].clone()
learner.actor.norm.update(history)
learner.critic.norm.update(learner.critic_input(state, command, context, previous, pose))
learner.target.norm.load_state_dict(learner.critic.norm.state_dict())
ou = 0.95 * ou + (1 - 0.95**2)**0.5 * torch.randn_like(ou)
action = (learner.actor(history) + args.action_noise * ou).clamp(-1., 1.)
if args.terrain_revision >= 5:
course_types = env.scene.terrain.terrain_types.clone()
course_levels = env.scene.terrain.terrain_levels.clone()
course_origins = env.scene.env_origins.clone()
prev_episode_length = env.episode_length_buf.clone()
obs, real_reward, terminated, truncated, _ = env.step(bridge.env_action(action))
terminated, truncated = terminated.clone(), truncated.clone()
obs, bad = recover_invalid_envs(env, obs, real_reward)
invalid_env_resets += int(bad.sum())
valid = ~(terminated | truncated | bad)
done = (terminated | truncated) & ~bad
if done.any():
episode_length_sum += float(prev_episode_length[done].sum().item())
episode_count += int(done.sum())
for name in termination_counts:
termination_counts[name] += int(
(env.termination_manager.get_term(name) & done).sum().item())
real_reward = torch.where(bad, torch.zeros_like(real_reward), real_reward)
nxt, delta = bridge.state(), pose_delta(pose, bridge.pose())
measured = torch.cat((nxt[:, 4:6], nxt[:, 9:10]), -1)
reward_totals += env.reward_manager._step_reward[~bad].sum(0)
totals[0] += real_reward.sum()
totals[1] += terminated.sum()
totals[2] += valid.sum()
totals[3:] += (measured[valid] - command[valid]).abs().sum(0)
interval_steps += 1
course_data = {}
if args.terrain_revision >= 7:
label_valid = env._nonwheel_label_valid & ~bad & torch.isfinite(state).all(-1)
course_data.update(collision=env._nonwheel_hit[:,None].float(),
collision_valid=label_valid[:,None].float())
nonwheel_terminations += int((env._nonwheel_hit & label_valid).sum())
if args.terrain_revision >= 5:
new_pose = bridge.pose()
ground_rise = field.sample(new_pose[:,:2]) - field.sample(pose[:,:2])
forward_motion = (delta[:,:2] * command[:,:2]).sum(-1) > 0
offset = bridge.robot.data.root_link_pos_w - course_origins
from .stair_curriculum import stair_pass
reached = stair_pass(offset, bridge.robot.data.projected_gravity_b,
course_levels, course_types == 1)
crossing = (pose[:,0] - course_origins[:,0]).abs() <= 2.8
useful = (ground_rise > .005) & forward_motion & valid
course_data['course'] = torch.stack((course_types.float(), course_levels.float(),
useful.float(), (reached & crossing & valid).float()), -1)
replay.add(state=state, action=action, previous=previous, context=context,
next=nxt, pose=delta, valid=valid[:, None], history=history,
command=command, bias=bias, **({"world_pose": pose} if args.terrain else {}),
**({"next_contacts":bridge.contacts()} if contact_targets else {}),
**({"pair_timer": env._s10_pair_timer} if args.terrain_revision >= 7 else {}),
**course_data)
if valid.any():
model_context = context if field is None else torch.cat((context, field.scan(state, pose)), -1)
learner.model.update_stats(state[valid], action[valid], previous[valid], model_context[valid],
nxt[valid], delta[valid])
previous = action.clone()
previous[~valid], ou[~valid] = 0., 0.
learner.env_steps += 1
collect_end = time.monotonic()
collection_time_sum += collect_end - iter_start
wm_loss = actor_loss = critic_loss = None
if replay.size >= args.wm_window:
for _ in range(args.wm_updates):
batch = (replay.sample_world_model(args.wm_batch, args.wm_window, capacity=args.archive_size)
if args.prioritized_replay else replay.sample(args.wm_batch, args.wm_window))
wm_loss = learner.fit(batch)
# Replay is not checkpointed; refit it during warmup after every resume.
if step - start >= args.warmup:
seed = {k: v[:, 0] for k, v in replay.sample(args.imag_batch, 1).items()}
actor_loss, critic_loss = learner.improve(seed, constants, weights, bridge.to_env, env.step_dt)
learning_time_sum += time.monotonic() - collect_end
if (step + 1) % args.log_interval == 0 or step + 1 == args.iterations:
mean_episode_length = episode_length_sum / max(episode_count, 1)
# RewardManager._step_reward is raw*weight; the env reward also
# multiplies by dt. Scale before turning rates into episode sums.
weighted_terms = (reward_totals / max(interval_steps * args.num_envs, 1)).cpu()
terms_per_step = weighted_terms * env.step_dt
episode_reward_terms = dict(zip(
env.reward_manager.active_terms,
(terms_per_step * mean_episode_length).tolist()))
termination_rates = {
name: count / max(episode_count, 1)
for name, count in termination_counts.items()}
now = time.monotonic()
row = dict(step=step + 1, actor_lr=learner.optimizers[1].param_groups[0]["lr"],
archive_windows=0 if replay.archive is None else replay.archive.size, env_steps=learner.env_steps,
total_transitions=learner.env_steps * args.num_envs,
num_steps_per_env=args.num_steps_per_env, wm_loss=wm_loss, actor_loss=actor_loss,
critic_loss=critic_loss, reward=(totals[0] / (interval_steps * args.num_envs)).item(),
falls=int(totals[1]), interval_steps=interval_steps,
velocity_mae=(totals[3:] / totals[2].clamp_min(1)).tolist(),
mean_episode_length=mean_episode_length,
episode_reward_terms=episode_reward_terms,
weighted_reward_terms=dict(zip(env.reward_manager.active_terms,
weighted_terms.tolist())),
episode_termination_rates=termination_rates,
collection_time_s=collection_time_sum / max(interval_steps, 1),
learning_time_s=learning_time_sum / max(interval_steps, 1),
iteration_time_s=(now - began) / max(step + 1 - start, 1),
policy_updates=learner.policy_updates,
skipped_updates=learner.skipped_updates, invalid_env_resets=invalid_env_resets,
elapsed_s=now - began)
if args.task == "s10-v4-upward":
stats = upward_curriculum_stats(env)
row.update(stats)
row.update(upward_tilt_stage=stats["tilt_level_q75"],
upward_tilt_deg=TILT_STAGES_DEG[stats["tilt_level_q75"]],
upward_recovery_rate=stats["recovery_window_rate"],
upward_traverse_rate=stats["traverse_window_rate"],
upward_success_rate=stats["traverse_window_rate"])
if args.terrain_revision >= 7:
row['nonwheel_terminations'] = nonwheel_terminations
if args.terrain:
row.update({k:v.item() for k,v in learner.model.last_contact_metrics.items()})
row["terrain_level_mean"] = env.scene.terrain.terrain_levels.float().mean().item()
row["terrain_level_max"] = env.scene.terrain.terrain_levels.max().item()
if args.terrain_revision >= 5:
velocity_ema = [.9*a + .1*b for a,b in zip(velocity_ema, row['velocity_mae'])]
row['terrain_details'] = summary(env)
row['success_archive_windows'] = 0 if replay.success_archive is None else replay.success_archive.size
promoted = command_stage
if args.command_curriculum and args.terrain_revision >= 5:
promoted = next_stage(command_stage, step + 1 - args.command_curriculum_start,
env._stair_stats.cpu().numpy(), velocity_ema)
row['next_command_stage'] = promoted
if args.command_curriculum:
ranges = env.command_manager.get_term("twist").cfg.ranges
row.update(command_stage=command_stage, command_ranges=[ranges.lin_vel_x, ranges.lin_vel_y, ranges.ang_vel_z])
else:
ranges = env.command_manager.get_term("twist").cfg.ranges
row.update(command_stage=0, command_mode="fixed",
command_ranges=[ranges.lin_vel_x, ranges.lin_vel_y, ranges.ang_vel_z])
if args.terrain_revision >= 3:
row["command_negative_vx_fraction"] = (command[:,0] < 0).float().mean().item()
row["command_abs_max"] = command.abs().amax(0).tolist()
log.write(json.dumps(row) + "\n")
log.flush()
print(format_training_log(row, args, episode_reward_terms, termination_rates),
flush=True)
if (args.terrain_revision >= 5 and args.command_curriculum
and promoted != command_stage):
command_stage = promoted
env._stair_stats.zero_() # Fresh performance window for the next stage.
set_command_stage(env.command_manager.get_term('twist').cfg, 0,
stage=command_stage,
stages=stages_for(args.terrain_revision))
reward_totals.zero_()
totals.zero_()
interval_steps = 0
episode_length_sum = 0.0
episode_count = 0
for name in termination_counts:
termination_counts[name] = 0
collection_time_sum = 0.0
learning_time_sum = 0.0
if (step + 1) % args.save_interval == 0 or step + 1 == args.iterations:
if args.terrain:
learner.terrain_state = {"levels": env.scene.terrain.terrain_levels.cpu().clone(),
"types": env.scene.terrain.terrain_types.cpu().clone()}
if args.terrain_revision >= 5:
learner.curriculum_state = dict(stage=command_stage, velocity_ema=velocity_ema,
stats=env._stair_stats.detach().cpu().clone())
learner.save(output / f"model_{step + 1}.pt", step + 1, bridge.to_env, env.step_dt)
if args.video and ((step + 1) % args.video_interval == 0
or step + 1 == args.iterations):
try:
ranges = env.command_manager.get_term("twist").cfg.ranges
record_policy_video(
learner.actor, args, output, step + 1, bridge.to_env,
upward_tilt_limit=(upward_video_tilt_limit(env)
if args.task == "s10-v4-upward" else None),
command_ranges=(ranges.lin_vel_x, ranges.lin_vel_y, ranges.ang_vel_z))
except Exception as exc:
print(f"RECOVERY skipped video at {step + 1}: {exc}", flush=True)
print(f"S10 training complete: {output}")
finally:
env.close()
+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
+101
View File
@@ -0,0 +1,101 @@
"""Always-on wheel/body projection constraints for the safe v7 stance.
The body is 0.60 m long in ``base_link`` (x in [-0.30, 0.30]).
``wheel_body_intrusion`` keeps every individual wheel out of the opposite
three quarters: a front wheel inside the rear three quarters (x < +0.15) or a
rear wheel inside the front three quarters (x > -0.15) shortens the support
polygon and makes the robot tip-prone. Its hinge is exactly zero outside the
outer quarters and grows cubically inside, so a normal extended stance is
untouched while a wheel tucked under the body is penalised quickly.
``pair_body_intrusion`` adds the wider five-sixths restriction requested for
v7: both front wheels may not sit inside the rear five sixths (x < +0.20) at
the same time, and both rear wheels may not sit inside the front five sixths
(x > -0.20). A single leg crossing the line is allowed; a leg only counts as
inside after it has stayed inside continuously for ``PAIR_GRACE_S`` (0.5 s).
The pair penalty is therefore zero during a brief swing-through and applies
immediately once both same-side legs are armed.
Both terms are kinematic and need no terrain scan. Wheels are ordered
fl, fr, hl, hr.
"""
import torch
from .rolling_rewards import wheel_geometry
BODY_HALF_LENGTH = 0.30
# Each wheel must stay in the outer quarter of the body length: x > +0.15 for
# front wheels and x < -0.15 for rear wheels.
WHEEL_STRIP_BOUNDARY = BODY_HALF_LENGTH / 2.0
# Rear/front 5/6 of a 0.60 m body => the boundary sits at x = +-0.20.
PAIR_BOUNDARY = 2.0 * BODY_HALF_LENGTH / 3.0
PAIR_GRACE_S = 0.5
def wheel_body_intrusion(state, default_q, boundary=WHEEL_STRIP_BOUNDARY):
point, _ = wheel_geometry(state, default_q)
x = point[..., 0]
# Front wheels must stay ahead of +L/4, rear wheels behind -L/4.
violation = torch.cat(((boundary - x[:, :2]).clamp_min(0.),
(x[:, 2:] + boundary).clamp_min(0.)), -1)
normalized = violation / boundary
return normalized.pow(3).mean(-1)
def pair_region(state, default_q, boundary=PAIR_BOUNDARY):
"""Per-wheel inside mask and normalised depth for the 5/6 restriction."""
point, _ = wheel_geometry(state, default_q)
x = point[..., 0]
inside = torch.cat((x[:, :2] < boundary, x[:, 2:] > -boundary), -1)
depth = torch.cat(((boundary - x[:, :2]).clamp_min(0.),
(x[:, 2:] + boundary).clamp_min(0.)), -1) / boundary
return inside, depth
def pair_body_intrusion(state, default_q, timers, dt, grace=PAIR_GRACE_S):
"""Penalise both front or both rear wheels staying inside the 5/6 region.
``timers`` holds the continuous inside time of each wheel. Returning it lets
the caller keep the 0.5 s grace across environment steps. A wheel outside
the boundary resets its own timer, so a single-leg pass-through never
contributes.
"""
inside, depth = pair_region(state, default_q)
timers = torch.where(inside, timers + dt, torch.zeros_like(timers))
armed = timers >= grace - 1e-6
front = armed[:, 0] & armed[:, 1]
rear = armed[:, 2] & armed[:, 3]
cost = (front.float() * depth[:, :2].mean(-1)
+ rear.float() * depth[:, 2:].mean(-1))
return cost, timers
def _bridge(env):
from .terrain import TerrainBridge, TerrainField
bridge = getattr(env, '_s10_posture_bridge', None)
if bridge is None:
bridge = TerrainBridge(env, TerrainField.from_env(env))
env._s10_posture_bridge = bridge
return bridge
def env_wheel_body_intrusion(env):
bridge = _bridge(env)
return wheel_body_intrusion(
bridge.state(), bridge.robot.data.default_joint_pos[0, bridge.legs])
def env_pair_body_intrusion(env):
bridge = _bridge(env)
timers = getattr(env, '_s10_pair_timer', None)
if timers is None or timers.shape[0] != env.num_envs:
timers = torch.zeros(env.num_envs, 4, device=env.device)
# episode_length_buf is 1 on the first reward step after an auto-reset.
timers = timers.clone()
timers[env.episode_length_buf <= 1] = 0.
cost, timers = pair_body_intrusion(
bridge.state(), bridge.robot.data.default_joint_pos[0, bridge.legs],
timers, env.step_dt)
env._s10_pair_timer = timers
return cost
@@ -0,0 +1,81 @@
"""V6 wheel-local terrain constraints; privileged scans never enter the actor.
Clearance is a geometric surrogate, not a measured persistent-contact detector.
S10 joint geometry comes from ``rolling_rewards.wheel_geometry``.
"""
import torch
from .world_model import GRAVITY, LEG_POS, LEG_VEL
from .rolling_rewards import wheel_geometry
def yaw_frame_points(state, points):
# Remove roll/pitch, retaining the heading frame used by TerrainField.scan.
up = -torch.nn.functional.normalize(state[:, GRAVITY], dim=-1, eps=1e-6)
cp = (up[:, 1:].square().sum(-1)+1e-12).sqrt()
sr, cr, sp = up[:, 1] / cp, up[:, 2] / cp, -up[:, 0]
x, y, z = points.unbind(-1)
yr = cr[:, None]*y - sr[:, None]*z
zr = sr[:, None]*y + cr[:, None]*z
return torch.stack((cp[:, None]*x + sp[:, None]*zr, yr,
-sp[:, None]*x + cp[:, None]*zr), -1)
def sample_scan(scan, xy):
"""Bilinear samples on the existing 21x13, 10cm heading-frame grid."""
shape = xy.shape[:-1]
x = ((xy[..., 0]+1.)*10).clamp(0,20).reshape(len(scan),-1)
y = ((xy[..., 1]+.6)*10).clamp(0,12).reshape(len(scan),-1)
ix, iy = x.floor().long().clamp(0,19), y.floor().long().clamp(0,11)
ax, ay = x-ix, y-iy
def at(dx,dy): return scan.gather(1,(ix+dx)*13+iy+dy)
return (((1-ax)*at(0,0)+ax*at(1,0))*(1-ay)
+ ((1-ax)*at(0,1)+ax*at(1,1))*ay).reshape(shape)
def wheel_terms(state, command, scan, default_q):
point, radius = wheel_geometry(state, default_q)
point = yaw_frame_points(state, point)
xy = point[..., :2]
offsets = state.new_tensor([[x,y] for x in (-.15,0.,.15) for y in (-.10,0.,.10)])
patch_xy = xy[:,:,None,:]+offsets
samples = sample_scan(scan, patch_xy)
# Index-based extrema avoid division by the number of equal extrema in
# compiled backward through interpolated, nearly-flat patches.
relief = samples.max(dim=-1).values-samples.min(dim=-1).values
# Outside the scan, never assume flatness and penalize a recovering leg.
inside = ((patch_xy[...,0].abs()<=1.) & (patch_xy[...,1].abs()<=.6)).all(-1)
flat = (1.-(relief-.02)/.04).clamp(0,1)*inside
straight = torch.exp(-(command[:,1]/.25).square()-(command[:,2]).square())
gate = flat*straight[:,None]
below = sample_scan(scan,xy)
bottom = point[...,2]+below-radius
lift = ((bottom-.005).clamp_min(0)/.05).square()
tilt = ((state[:,GRAVITY][:,:2].square().sum(-1)+1e-12).sqrt()-.08).div(.22).clamp(0,1)
motion = state[:,LEG_VEL].reshape(-1,4,3).square().mean(-1)*(1.-.8*tilt[:,None])
q = state[:,LEG_POS].reshape(-1,4,3).square()
posture_multiplier = torch.where(inside, 1.-.875*(relief/.12).clamp(0,1), .125)
posture = 2.*q[:,:,0].sum(-1)+(posture_multiplier*q[:,:,1:].sum(-1)).sum(-1)
# Only approaching a higher surface: no reward for lifting on flat ground.
speed = command[:,:2].norm(dim=-1)
heading_velocity = yaw_frame_points(state, torch.cat((command[:,:2], torch.zeros_like(command[:,:1])), -1)[:,None,:])[:,0,:2]
direction = heading_velocity/(heading_velocity.square().sum(-1,keepdim=True)+1e-12).sqrt().clamp_min(.1)
ahead_xy = xy+.16*direction[:,None,:]
ahead = sample_scan(scan,ahead_xy)
rise = below-ahead
approaching = ((rise-.04)/.08).clamp(0,1)*(speed>.1)[:,None]*inside
deficit = (radius+.015-point[...,2]-ahead).clamp_min(0)/.20
clearance = (approaching*deficit/(1.+deficit)).mean(-1)
return dict(unnecessary_wheel_lift=(gate*lift).sum(-1),
rolling_leg_motion=(gate*motion).mean(-1), leg_posture=posture,
wheel_obstacle_clearance=clearance)
def env_wheel_term(env, term):
from .terrain import TerrainBridge,TerrainField
bridge=getattr(env,'_s10_posture_bridge',None)
if bridge is None:
bridge=TerrainBridge(env,TerrainField.from_env(env));env._s10_posture_bridge=bridge
state=bridge.state()
return wheel_terms(state,env.command_manager.get_command('twist'),
bridge.field.scan(state,bridge.pose()),
bridge.robot.data.default_joint_pos[0,bridge.legs])[term]
+118
View File
@@ -0,0 +1,118 @@
"""S10 residual dynamics with position legs and velocity wheels.
Canonical order: fl, fr, hl, hr; hipx/hipy/knee per leg, then four wheels.
Actions are normalized policy outputs: leg targets = default + scale*a[:12],
wheel targets = clamp(35*a[12:], -35, 35) rad/s. Wheel phase is excluded.
This module is self-contained and depends only on torch.
"""
import torch
from torch import nn
STATE_DIM = 38
ACTION_DIM = 16
CONTEXT_DIM = 7 # four wheel sliding frictions, base COM xyz offset (metres)
POSE_DIM = 3
Z, GRAVITY, LIN_VEL, ANG_VEL = slice(0, 1), slice(1, 4), slice(4, 7), slice(7, 10)
LEG_POS, LEG_VEL, WHEEL_VEL = slice(10, 22), slice(22, 34), slice(34, 38)
FORMAT = "s10-flat-dynamics-v1"
class RunningNorm(nn.Module):
def __init__(self, dim):
super().__init__()
self.register_buffer("mean", torch.zeros(dim))
self.register_buffer("var", torch.ones(dim))
self.register_buffer("count", torch.tensor(1e-4))
@torch.no_grad()
def update(self, x):
x = x.reshape(-1, self.mean.numel())
if not len(x):
return
delta = x.mean(0) - self.mean
total = self.count + len(x)
self.var.copy_((self.var * self.count + x.var(0, unbiased=False) * len(x)
+ delta.square() * self.count * len(x) / total) / total)
self.mean.add_(delta * len(x) / total)
self.count.copy_(total)
@property
def scale(self):
return self.var.clamp_min(1e-6).sqrt()
def forward(self, x):
return ((x - self.mean) / self.scale).clamp(-10, 10)
class S10WorldModel(nn.Module):
"""(state38, action16, previous16, context7) -> (next state38, pose delta3).
Flat ground only. Linear and angular velocity use the base frame. Planar
displacement uses the previous yaw frame. No target/chase or terrain scan.
Policy history is separate from physical state and is not fed to this model.
"""
def __init__(self, hidden=(512, 512, 512)):
super().__init__()
self.hidden = tuple(hidden)
dims = (STATE_DIM + 2 * ACTION_DIM + CONTEXT_DIM, *hidden, STATE_DIM + POSE_DIM)
layers = []
for i, (a, b) in enumerate(zip(dims[:-1], dims[1:])):
layers.append(nn.Linear(a, b))
if i < len(dims) - 2:
layers.append(nn.SiLU())
self.net = nn.Sequential(*layers)
self.input_norm = RunningNorm(dims[0])
self.output_norm = RunningNorm(dims[-1])
@staticmethod
def inputs(state, action, previous_action, context):
for name, x, dim in (("state", state, STATE_DIM), ("action", action, ACTION_DIM),
("previous_action", previous_action, ACTION_DIM),
("context", context, CONTEXT_DIM)):
if x.ndim != 2 or x.shape != (state.shape[0], dim):
raise ValueError(f"{name}: expected (batch, {dim}), got {tuple(x.shape)}")
return torch.cat((state, action, previous_action, context), -1)
@torch.no_grad()
def update_stats(self, state, action, previous_action, context, next_state, pose_delta):
self.input_norm.update(self.inputs(state, action, previous_action, context))
self.output_norm.update(torch.cat((next_state - state, pose_delta), -1))
def forward(self, state, action, previous_action, context):
x = self.inputs(state, action, previous_action, context)
delta = self.net(self.input_norm(x)) * self.output_norm.scale + self.output_norm.mean
return state + delta[:, :STATE_DIM], delta[:, STATE_DIM:]
def loss(self, state, actions, previous_actions, context, targets, poses, valid):
"""Autoregressive window loss; validity must be false at/reset after done.
Tensor shapes: state[B,38], actions/previous[B,K,16], context[B,7],
targets[B,K,38], poses[B,K,3], valid[B,K]. A reset invalidates the rest
of that window, even if later transitions are individually valid.
"""
mask = valid.bool().cumprod(1).bool()
total = next(self.parameters()).sum() * 0.0
for t in range(actions.shape[1]):
# Do not execute invalid rows: NaN terminal/reset data must not leak
# into either the loss or gradients through multiplication by zero.
keep = mask[:, t]
if not keep.any():
break
pred, pose = self(state[keep], actions[keep, t], previous_actions[keep, t], context[keep])
error = torch.cat((pred - targets[keep, t], pose - poses[keep, t]), -1)
total = total + (error / self.output_norm.scale).square().mean(-1).sum()
state = state.clone()
state[keep] = pred
return total / mask.sum().clamp_min(1)
def checkpoint(self):
return {"format": FORMAT, "hidden": self.hidden, "model": self.state_dict()}
@classmethod
def from_checkpoint(cls, checkpoint, device="cpu"):
if checkpoint.get("format") != FORMAT:
raise ValueError("Expected a S10 dynamics checkpoint; other-robot checkpoints are incompatible")
model = cls(checkpoint["hidden"]).to(device)
model.load_state_dict(checkpoint["model"])
return model
+296
View File
@@ -0,0 +1,296 @@
"""World-model future preview for the play scripts.
The actor checkpoint already contains the world model (``world_model``) and the
training config. We use it directly; no separate ONNX export is required.
The preview has two independent outputs:
* a kinematic ghost trail: every predicted state is mapped back to MJCF qpos
and transparently added to the offscreen renderer;
* a prediction-error ledger: predictions are stored with their target step and
compared with the real state when that step arrives (xy/z/yaw/gravity/leg q).
The ghost is only a visualization. It never writes into ``env.sim.data`` or the
physics model.
"""
from __future__ import annotations
from dataclasses import dataclass
import math
import mujoco
import numpy as np
import torch
from .imagine import frame, push_history
from .policy import Actor
from .terrain import advance_pose
from .terrain_world_model import TerrainWorldModel
from .world_model import GRAVITY, LEG_POS, S10WorldModel
DEFAULT_HORIZON = 32
DEFAULT_STRIDE = 4
GHOST_NEAR = (1.0, 0.86, 0.08, 0.20)
GHOST_FAR = (1.0, 0.24, 0.02, 0.055)
@dataclass
class GhostPrediction:
horizon: int
state: np.ndarray # 38
pose: np.ndarray # x, y, yaw
qpos: np.ndarray # MJCF qpos
def load_world_model(checkpoint, device="cpu"):
payload = checkpoint.get("world_model")
if not isinstance(payload, dict):
raise ValueError("Checkpoint has no world_model payload")
fmt = payload.get("format")
if fmt == "s10-terrain-dynamics-v1":
model = TerrainWorldModel.from_checkpoint(payload, device)
elif fmt == "s10-flat-dynamics-v1":
model = S10WorldModel.from_checkpoint(payload, device)
else:
raise ValueError(f"Unsupported world-model format: {fmt}")
return model.to(device).eval()
def _quat_mul(a, b):
w1, x1, y1, z1 = a
w2, x2, y2, z2 = b
return np.array([
w1*w2 - x1*x2 - y1*y2 - z1*z2,
w1*x2 + x1*w2 + y1*z2 - z1*y2,
w1*y2 - x1*z2 + y1*w2 + z1*x2,
w1*z2 + x1*y2 - y1*x2 + z1*w2,
], dtype=np.float64)
def _quat_from_up(up_b, yaw):
"""Rotation mapping body-frame up to world +z, followed by world yaw."""
up = np.asarray(up_b, dtype=np.float64)
up /= max(np.linalg.norm(up), 1e-9)
target = np.array([0., 0., 1.])
dot = float(np.clip(up @ target, -1., 1.))
if dot < -0.999999:
align = np.array([0., 1., 0., 0.])
else:
axis = np.cross(up, target)
align = np.concatenate(([1. + dot], axis))
align /= max(np.linalg.norm(align), 1e-9)
yaw_q = np.array([math.cos(yaw / 2), 0., 0., math.sin(yaw / 2)])
return _quat_mul(yaw_q, align)
class WorldModelPreview:
def __init__(self, checkpoint, env, bridge, field, actor, horizon=DEFAULT_HORIZON,
device="cpu", env_action_order=None):
self.env = env
self.bridge = bridge
self.field = field
self.actor = actor
self.model = load_world_model(checkpoint, device)
self.horizon = int(horizon)
self.device = device
self.env_action_order = list(env_action_order or checkpoint["env_action_order"])
self.default_q = bridge.robot.data.default_joint_pos[0, bridge.legs].detach().cpu().numpy()
model = env.sim.mj_model
free = np.flatnonzero(model.jnt_type == mujoco.mjtJoint.mjJNT_FREE)
if len(free) != 1:
raise ValueError(f"Expected one free root joint, found {len(free)}")
self.root_qadr = int(model.jnt_qposadr[int(free[0])])
# ``bridge.legs``/``bridge.wheels`` are entity joint indices, not MuJoCo
# joint ids (the free joint is not in the entity list). Use the entity's
# own qpos-address table so the ghost writes the intended joints.
qadr = bridge.robot.indexing.joint_q_adr.cpu().numpy()
self.leg_qadr = [int(qadr[int(j)]) for j in bridge.legs]
self.wheel_qadr = [int(qadr[int(j)]) for j in bridge.wheels]
self.qpos_template = env.sim.data.qpos[0].detach().cpu().numpy().copy()
# root_link_pos_w may be env-local while qpos is world. Store the constant
# offset so predicted future positions land in the renderer's world frame.
self.world_offset = (self.qpos_template[self.root_qadr:self.root_qadr + 2]
- bridge.pose()[0, :2].detach().cpu().numpy())
self.tracker = PredictionTracker(self.horizon)
def _ghost(self, state, pose, horizon):
s = state[0].detach().cpu().numpy().astype(np.float64)
p = pose[0].detach().cpu().numpy().astype(np.float64)
qpos = self.qpos_template.copy()
z = float(s[0])
if self.field is not None:
z += float(self.field.sample(pose[:, :2])[0].detach().cpu())
quat = _quat_from_up(-s[1:4], float(p[2]))
qpos[self.root_qadr:self.root_qadr + 3] = (
p[0] + self.world_offset[0], p[1] + self.world_offset[1], z)
qpos[self.root_qadr + 3:self.root_qadr + 7] = quat
qpos[self.leg_qadr] = s[LEG_POS] + self.default_q
qpos[self.wheel_qadr] = self.qpos_template[self.wheel_qadr]
return GhostPrediction(horizon, s, p, qpos)
@torch.no_grad()
def predict(self, history, previous, command, context, bias, state, pose):
"""Roll the actor + world model forward from the current real state."""
ghosts = []
history = history.clone()
previous = previous.clone()
state = state.clone()
pose = pose.clone()
scan = None if self.field is None else self.field.scan(state, pose)
for i in range(self.horizon):
action = self.actor(history)
model_context = context if self.field is None else torch.cat((context, scan), -1)
if self.field is not None:
nxt, delta, _, _ = self.model.forward_all(state, action, previous, model_context)
else:
nxt, delta = self.model(state, action, previous, model_context)
pose = advance_pose(pose, delta)
if self.field is not None:
scan = self.field.scan(nxt, pose)
latest = frame(nxt, action[:, self.env_action_order], command, bias,
noise=False, wheel_vel=self.actor.actor_wheel_vel)
history = push_history(history, latest, self.actor.history, self.actor.terms)
previous = action
state = nxt
ghosts.append(self._ghost(state, pose, i + 1))
return ghosts
def update_errors(self, step, state, pose):
self.tracker.update(step, state[0].detach().cpu().numpy(),
pose[0].detach().cpu().numpy())
def push_errors(self, step, ghosts):
self.tracker.push(step, ghosts)
def summary(self):
return self.tracker.summary()
def clear(self):
self.tracker.clear()
def render(self, ghosts, stride=DEFAULT_STRIDE, rgba=None):
"""Actual frame plus transparent ghost geoms, using the env's renderer."""
return render_with_ghosts(self.env, ghosts, stride=stride, rgba=rgba)
class PredictionTracker:
"""Compare predictions with the state that actually arrives later."""
def __init__(self, horizon, keep=200):
self.horizon = int(horizon)
self.keep = int(keep)
self.pending = []
self.errors = {h: [] for h in (1, 4, 8, 16, 32) if h <= self.horizon}
def clear(self):
self.pending.clear()
for values in self.errors.values():
values.clear()
@staticmethod
def _error(pred_state, pred_pose, state, pose):
dxy = float(np.linalg.norm(pred_pose[:2] - pose[:2]))
dz = float(abs(pred_state[0] - state[0]))
dyaw = float(abs(math.atan2(math.sin(pred_pose[2] - pose[2]),
math.cos(pred_pose[2] - pose[2]))))
pg = pred_state[GRAVITY]
ag = state[GRAVITY]
pg = pg / max(np.linalg.norm(pg), 1e-9)
ag = ag / max(np.linalg.norm(ag), 1e-9)
dg = float(math.degrees(math.acos(float(np.clip(pg @ ag, -1., 1.)))))
dq = float(np.sqrt(np.mean((pred_state[LEG_POS] - state[LEG_POS]) ** 2)))
return dxy, dz, dyaw, dg, dq
def push(self, step, ghosts):
for ghost in ghosts:
if ghost.horizon in self.errors:
self.pending.append((step + ghost.horizon, ghost.horizon,
ghost.state, ghost.pose))
def update(self, step, state, pose):
keep = []
for target, horizon, pred_state, pred_pose in self.pending:
if target == step:
values = self.errors[horizon]
values.append(self._error(pred_state, pred_pose, state, pose))
if len(values) > self.keep:
del values[:-self.keep]
elif target > step:
keep.append((target, horizon, pred_state, pred_pose))
self.pending = keep
def summary(self):
parts = []
for horizon in sorted(self.errors):
values = self.errors[horizon]
if not values:
continue
mean = np.mean(values, axis=0)
parts.append(f"h{horizon} xy {mean[0]:.3f}m yaw {math.degrees(mean[2]):.1f}deg "
f"leg {mean[4]:.3f}")
return " | ".join(parts) if parts else "collecting..."
def annotate_rgb(rgb, lines, color=(255, 185, 90)):
"""Small bottom-left text overlay for MP4 frames (Pillow optional)."""
if not lines:
return rgb
try:
from PIL import Image, ImageDraw, ImageFont
except ImportError:
return rgb
image = Image.fromarray(rgb)
draw = ImageDraw.Draw(image)
font = ImageFont.load_default()
y = image.height - 8 - 14 * len(lines)
for line in lines:
draw.rectangle((6, y - 2, 6 + 8 * len(line), y + 13), fill=(8, 10, 14))
draw.text((10, y), line, fill=color, font=font)
y += 14
return np.asarray(image)
def render_with_ghosts(env, ghosts, stride=DEFAULT_STRIDE, rgba=None):
"""Add ghost robot geoms to the existing offscreen scene and render once."""
rgb = env.render()
renderer = env._offline_renderer
if renderer is None or not ghosts:
return rgb
import mujoco
model, data = renderer._model, renderer._data
scene = renderer.renderer.scene
old_qpos, old_qvel = data.qpos.copy(), data.qvel.copy()
old_group = renderer._opt.geomgroup.copy()
# S10 visual meshes are group 2; group 0 is terrain and group 1 collision
# geometry. Adding only group 2 gives robot ghosts without duplicating terrain.
visual = np.flatnonzero(model.geom_group == 2)
old_geom_rgba = model.geom_rgba[visual].copy()
mat_ids = np.unique(model.geom_matid[visual])
mat_ids = mat_ids[mat_ids >= 0]
old_mat_rgba = {int(i): model.mat_rgba[int(i)].copy() for i in mat_ids}
near = np.asarray(rgba if rgba is not None else GHOST_NEAR, dtype=np.float32)
far = np.asarray(GHOST_FAR, dtype=np.float32)
try:
renderer._opt.geomgroup[:] = 0
renderer._opt.geomgroup[2] = 1
for ghost in ghosts[max(0, int(stride) - 1)::max(1, int(stride))]:
# Near future is yellow and stronger; the far tail fades to red.
t = ghost.horizon / max(1, ghosts[-1].horizon)
tint = near * (1. - t) + far * t
for mat_id in mat_ids:
model.mat_rgba[int(mat_id)] = tint
model.geom_rgba[visual] = tint
data.qpos[:] = ghost.qpos
data.qvel[:] = 0.
mujoco.mj_forward(model, data)
mujoco.mjv_addGeoms(model, data, renderer._opt, renderer._pert,
renderer._catmask.value, scene)
finally:
renderer._opt.geomgroup[:] = old_group
model.geom_rgba[visual] = old_geom_rgba
for mat_id, value in old_mat_rgba.items():
model.mat_rgba[mat_id] = value
data.qpos[:] = old_qpos
data.qvel[:] = old_qvel
mujoco.mj_forward(model, data)
return renderer.render()