Initial commit
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
"""Standalone S10 MJCF/FK checks that do not require mjlab or torch.
|
||||
|
||||
Run with the system Python:
|
||||
python3 tests/test_s10_model.py
|
||||
|
||||
The test validates:
|
||||
1. The S10 model in ``s10_supermimic/robot/model/s10.xml`` loads.
|
||||
2. Joint names match the training/deployment contract.
|
||||
3. The default pose puts the wheel bottom at ~0.434 m above the ground.
|
||||
4. The analytic wheel-bottom formula in ``core/rolling_rewards.py`` matches
|
||||
MuJoCo forward kinematics for random joint angles and arbitrary base tilt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import mujoco
|
||||
import numpy as np
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MODEL = ROOT / "s10_supermimic" / "robot" / "model" / "s10.xml"
|
||||
LEGS = ("fl", "fr", "hl", "hr")
|
||||
LEG_JOINT_NAMES = tuple(
|
||||
f"{leg}_{joint}_joint" for leg in LEGS for joint in ("hipx", "hipy", "knee")
|
||||
)
|
||||
WHEEL_JOINT_NAMES = tuple(f"{leg}_wheel_joint" for leg in LEGS)
|
||||
DEFAULT_Q = {
|
||||
"fl": (0.1, -0.35, 0.65),
|
||||
"fr": (-0.1, -0.35, 0.65),
|
||||
"hl": (0.1, 0.35, -0.65),
|
||||
"hr": (-0.1, 0.35, -0.65),
|
||||
}
|
||||
# Geometry from S10.xml.
|
||||
X0 = {"fl": 0.2277, "fr": 0.2277, "hl": -0.2277, "hr": -0.2277}
|
||||
Y0 = {"fl": 0.0574, "fr": -0.0574, "hl": 0.0574, "hr": -0.0574}
|
||||
SIDE = {"fl": 1.0, "fr": -1.0, "hl": 1.0, "hr": -1.0}
|
||||
HIPY_Y = 0.0779
|
||||
WHEEL_Y = 0.045891
|
||||
LINK = 0.18
|
||||
WHEEL_RADIUS = 0.081
|
||||
WHEEL_HALF_WIDTH = 0.0185
|
||||
|
||||
|
||||
def analytic_wheel_bottom(q, leg, base_z=0.5, up=(0.0, 0.0, 1.0)) -> float:
|
||||
"""NumPy translation of the torch implementation in rolling_rewards.py."""
|
||||
hipx, hipy, knee = (float(v) for v in q)
|
||||
side = SIDE[leg]
|
||||
x = LINK * math.sin(knee)
|
||||
z = -LINK - LINK * math.cos(knee)
|
||||
x3 = math.cos(hipy) * x - math.sin(hipy) * z
|
||||
z3 = math.sin(hipy) * x + math.cos(hipy) * z
|
||||
y3 = side * (HIPY_Y + WHEEL_Y)
|
||||
y4 = math.cos(hipx) * y3 + math.sin(hipx) * z3
|
||||
z4 = -math.sin(hipx) * y3 + math.cos(hipx) * z3
|
||||
point = np.array([X0[leg] + x3, Y0[leg] + y4, z4])
|
||||
up = np.asarray(up, dtype=float)
|
||||
up /= np.linalg.norm(up)
|
||||
axle_z = -up[1] * math.cos(hipx) + up[2] * math.sin(hipx)
|
||||
radius_z = WHEEL_RADIUS * math.sqrt(max(1.0 - axle_z * axle_z, 0.0))
|
||||
radius_z += WHEEL_HALF_WIDTH * abs(axle_z)
|
||||
return base_z + float(point @ up) - radius_z
|
||||
|
||||
|
||||
def wheel_geom_id(model: mujoco.MjModel, leg: str) -> int:
|
||||
body_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, f"{leg}_wheel")
|
||||
for geom_id in range(model.ngeom):
|
||||
if model.geom_bodyid[geom_id] != body_id:
|
||||
continue
|
||||
if model.geom_type[geom_id] == mujoco.mjtGeom.mjGEOM_CYLINDER:
|
||||
return geom_id
|
||||
raise AssertionError(f"No cylinder collision geom for {leg}_wheel")
|
||||
|
||||
|
||||
def measured_wheel_bottom(model, data, geom_id: int) -> float:
|
||||
axis = data.geom_xmat[geom_id].reshape(3, 3)[:, 2]
|
||||
axle_z = abs(float(axis[2]))
|
||||
radius_z = WHEEL_RADIUS * math.sqrt(max(1.0 - axle_z * axle_z, 0.0))
|
||||
radius_z += WHEEL_HALF_WIDTH * axle_z
|
||||
return float(data.geom_xpos[geom_id][2] - radius_z)
|
||||
|
||||
|
||||
def set_pose(model, data, q_map, base_z=0.5, quat=(1.0, 0.0, 0.0, 0.0)):
|
||||
data.qpos[:] = 0.0
|
||||
data.qpos[2] = base_z
|
||||
data.qpos[3:7] = quat
|
||||
for joint_name, value in q_map.items():
|
||||
joint_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, joint_name)
|
||||
data.qpos[model.jnt_qposadr[joint_id]] = value
|
||||
mujoco.mj_forward(model, data)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
assert MODEL.is_file(), MODEL
|
||||
model = mujoco.MjModel.from_xml_path(str(MODEL))
|
||||
data = mujoco.MjData(model)
|
||||
|
||||
joint_names = [model.joint(i).name for i in range(model.njnt)]
|
||||
expected = ("floating_base",) + LEG_JOINT_NAMES + WHEEL_JOINT_NAMES
|
||||
assert set(joint_names) == set(expected), joint_names
|
||||
assert model.nq == 23 and model.nv == 22 and model.njnt == 17
|
||||
assert model.nu == 0, "Robot XML must not contain actuators; mjlab adds them."
|
||||
|
||||
default_q = {
|
||||
f"{leg}_{joint}_joint": value
|
||||
for leg in LEGS
|
||||
for joint, value in zip(("hipx", "hipy", "knee"), DEFAULT_Q[leg])
|
||||
}
|
||||
set_pose(model, data, default_q)
|
||||
for leg in LEGS:
|
||||
geom_id = wheel_geom_id(model, leg)
|
||||
bottom = measured_wheel_bottom(model, data, geom_id)
|
||||
pred = analytic_wheel_bottom(DEFAULT_Q[leg], leg)
|
||||
assert abs(bottom - pred) < 1e-9, (leg, bottom, pred)
|
||||
assert abs((0.5 - bottom) - 0.434145) < 1e-4, (leg, bottom)
|
||||
|
||||
rng = np.random.default_rng(0)
|
||||
max_error = 0.0
|
||||
for _ in range(200):
|
||||
quat = rng.normal(size=4)
|
||||
quat /= np.linalg.norm(quat)
|
||||
q_map = {}
|
||||
q_by_leg = {}
|
||||
for leg in LEGS:
|
||||
q = np.array([
|
||||
rng.uniform(-0.6109, 0.6109),
|
||||
rng.uniform(-2.5307, 2.5307),
|
||||
rng.uniform(-2.7227, 2.7227),
|
||||
])
|
||||
q_by_leg[leg] = q
|
||||
for joint, value in zip(("hipx", "hipy", "knee"), q):
|
||||
q_map[f"{leg}_{joint}_joint"] = value
|
||||
set_pose(model, data, q_map, base_z=0.5, quat=quat)
|
||||
|
||||
rotation = data.xmat[
|
||||
mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, "base_link")
|
||||
].reshape(3, 3)
|
||||
up = rotation.T @ np.array([0.0, 0.0, 1.0])
|
||||
for leg in LEGS:
|
||||
geom_id = wheel_geom_id(model, leg)
|
||||
measured = measured_wheel_bottom(model, data, geom_id)
|
||||
pred = analytic_wheel_bottom(q_by_leg[leg], leg, base_z=0.5, up=up)
|
||||
max_error = max(max_error, abs(measured - pred))
|
||||
assert max_error < 1e-9, max_error
|
||||
print(f"S10 model/FK checks passed (max error {max_error:.2e} m)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user