cablepilot / tests /test_twin.py
Ethgar's picture
First upload (private preview)
168e232 verified
Raw History Blame Contribute Delete
5.51 kB
"""The twin and the oracle against the numbers they were built from (configs/stringman.yaml, docs/MACHINE.md)."""
import sys
from pathlib import Path
import mujoco
import numpy as np
import torch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from cablepilot.model.oracle import OracleWorldModel # noqa: E402
from cablepilot.sim.kin import line_speeds, static_tensions, worst_hang_tensions # noqa: E402
from cablepilot.sim.plant import Plant # noqa: E402
from cablepilot.sim.scene import Indices, build_model, load_config, mounts, settle_spools, total_mass # noqa: E402
from cablepilot.sim.state import set_from_state, to_state # noqa: E402
CFG = load_config()
def _hang(world=False, timestep=None, p=(0.0, 0.0, 1.2), seconds=3.0):
m = build_model(CFG, world=world, timestep=timestep, start=p)
d = mujoco.MjData(m)
idx = Indices(m)
settle_spools(m, d, idx, CFG)
for _ in range(int(seconds / m.opt.timestep)):
mujoco.mj_step(m, d)
return m, d, idx
def test_hangs_still_with_static_tensions():
m, d, idx = _hang()
x = to_state(m, d, idx)
assert np.allclose(x[:3], [0, 0, 1.2], atol=0.003)
T = -d.actuator_force[idx.spool]
assert np.allclose(T, static_tensions(CFG, x[:3], total_mass(m)), atol=0.2) # symmetric pose: 4 equal shares
assert T.max() < CFG["lines"]["max_safe_tension_N"]
def test_swing_period_close_to_firmware():
"""fw definitions.pole_length_carbon270 = 0.3089 m -> 1.115 s; the twin must be within 5 %."""
m, d, idx = _hang()
d.qvel[idx.gv + 3] = 0.3
ys = []
for _ in range(int(6 / m.opt.timestep)):
mujoco.mj_step(m, d)
ys.append(to_state(m, d, idx)[7])
ys = np.array(ys) - np.mean(ys)
up = np.where(np.diff(np.sign(ys)) > 0)[0]
period = np.diff(up).mean() * m.opt.timestep
assert abs(period / (2 * np.pi * np.sqrt(0.3089 / 9.81)) - 1) < 0.05
def test_timestep_convergence():
"""Plant step 0.004 s vs the firmware twin's 0.002 s: same hang within 1 mm."""
m1, d1, i1 = _hang(timestep=0.004, p=(0.8, -0.5, 1.0))
m2, d2, i2 = _hang(timestep=0.002, p=(0.8, -0.5, 1.0))
assert np.linalg.norm(to_state(m1, d1, i1)[:3] - to_state(m2, d2, i2)[:3]) < 0.001
def test_state_roundtrip():
m = build_model(CFG, world=False)
d = mujoco.MjData(m)
idx = Indices(m)
x = np.array([0.3, -0.2, 1.1, 0.05, -0.02, 0.01, 0.04, -0.03, 0.1, 0.05, 0.7, 0.2, 0.4, 0.1])
set_from_state(m, d, idx, x, CFG)
mujoco.mj_forward(m, d)
assert np.allclose(to_state(m, d, idx), x, atol=1e-6)
def test_kinematics_helpers():
M = mounts(CFG)
p = np.array([0.4, 0.3, 1.0])
T = static_tensions(CFG, p, 1.0)
u = (M - p) / np.linalg.norm(M - p, axis=1, keepdims=True)
assert np.allclose(u.T @ T, [0, 0, 9.81], atol=1e-6)
v = np.array([0.0, 0.0, 0.1]) # going up: every line shortens
assert (line_speeds(M, p, v) < 0).all()
Tw = worst_hang_tensions(torch.tensor(M, dtype=torch.float32), torch.tensor(p, dtype=torch.float32), 1.0).numpy()
assert (Tw >= T - 1e-4).all()
def test_oracle_matches_plant_for_a_smooth_move():
dt, Tn = 0.2, 5
pl = Plant(CFG)
pl.reset([0.2, 0.1, 1.1])
x = pl.state()
acts = np.zeros((Tn, 5))
acts[:, 0], acts[:, 2] = 0.08, -0.05
pred = OracleWorldModel(CFG, dt, timestep=0.01).rollout(x[None], acts[:1], acts[None])[0].numpy()
for k in range(Tn):
pl.command(pl.state()[:3], acts[k])
pl.step(dt)
assert np.linalg.norm(pred[-1, :3] - pl.state()[:3]) < 0.01 # within 1 cm after 1 s
# ----------------------------------------------------------------------------------------------- the SO-101 hand (M3)
SO = load_config(hand="so101")
def test_so101_mass_from_its_parts():
"""Every part's mass counts (MuJoCo drops geom masses in a body with an <inertial>: they ride on welded bodies)."""
m = build_model(SO, world=False)
h, b = SO["so101"], SO["body"]
parts = (b["gantry_mass_kg"] + b["pole_mass_kg"] + h["roll_servo_mass_kg"] + h["gripper_mass_kg"] + h["jaw_mass_kg"]
+ h["camera_mass_kg"])
assert abs(total_mass(m) - parts) < 1e-6
def test_so101_hangs_still_and_roundtrips():
m = build_model(SO, world=False)
d = mujoco.MjData(m)
idx = Indices(m)
settle_spools(m, d, idx, SO)
for _ in range(int(3 / m.opt.timestep)):
mujoco.mj_step(m, d)
assert np.allclose(to_state(m, d, idx)[:3], [0, 0, 1.2], atol=0.003)
x = np.array([0.3, -0.2, 1.1, 0.05, -0.02, 0.01, 0.04, -0.03, 0.1, 0.05, 0.7, 0.2, 0.4, 0.1])
set_from_state(m, d, idx, x, SO)
mujoco.mj_forward(m, d)
assert np.allclose(to_state(m, d, idx), x, atol=1e-6)
def test_so101_jaw_convention():
"""closure 1 = the pads meet (SO-101 -10 deg), closure 0 = jaw_open_deg; the plant reports the SO-101's scale."""
from cablepilot.sim.plant import Plant
pl = Plant(SO)
m, d, idx = pl.model, pl.data, pl.idx
assert abs(np.degrees(idx.travel) - (SO["so101"]["jaw_open_deg"] - SO["so101"]["jaw_closed_deg"])) < 1e-3 # written with 6 decimals
fl, fr = m.geom("finger_l_pad").id, m.geom("finger_r_pad").id
for close, gap_ok in ((1.0, lambda g: abs(g) < 0.001), (0.0, lambda g: g > 0.05)):
pl.reset([0.0, 0.0, 1.0], close=close)
assert gap_ok(mujoco.mj_geomDistance(m, d, fl, fr, 0.2, None))
want = SO["so101"]["jaw_closed_deg"] if close == 1.0 else SO["so101"]["jaw_open_deg"]
assert abs(pl.jaw_angle_deg() - want) < 0.5