Initial commit
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user