microduck_rl/scripts/validate_bam_testbench.py
Upstream Snapshot 47372443ff Import upstream snapshot d424a0c899f6b33cbd3daeb279913134349c0b63
Upstream: https://github.com/pollen-robotics/microduck_rl
Upstream-Commit: d424a0c899f6b33cbd3daeb279913134349c0b63
Upstream-Branch: develop
2026-08-28 15:41:56 +08:00

278 lines
8.9 KiB
Python

"""Validate the BAM M6 actuator kernel against real testbench data.
Loads real testbench recordings, replays them in MuJoCo with the BAM M6 actuator,
and compares simulated vs real position traces. Also runs BAM's own Python simulator
as a reference.
Usage:
uv run python3 scripts/validate_bam_testbench.py [--plot] [--max-files N]
"""
import argparse
import json
import os
import sys
from copy import copy
from pathlib import Path
import mujoco
import numpy as np
# ── Paths ──
BAM_DIR = Path(os.path.expanduser("~/Rhoban/bam"))
DATA_DIR = BAM_DIR / "bam" / "data" / "processed"
PARAMS_FILE = BAM_DIR / "params" / "xl330" / "m6_new.json"
TESTBENCH_XML = (
Path(__file__).resolve().parent.parent
/ "src"
/ "mjlab_microduck"
/ "robot"
/ "xl330_test_bench"
/ "scene.xml"
)
# ── Load M6 params ──
with open(PARAMS_FILE) as f:
M6 = json.load(f)
# XL330 firmware constants
ERROR_GAIN = (4096 / (2 * np.pi)) / (256 * 885)
VIN = 7.4
MAX_PWM = 1.0
def bam_python_rollout(log: dict) -> list[float]:
"""Reference: BAM's own Python simulator."""
sys.path.insert(0, str(BAM_DIR))
from bam.model import load_model
from bam.simulate import Simulator
# BAM expects arm_mass in the log (mass of the arm itself, not the payload)
if "arm_mass" not in log:
log = dict(log)
log["arm_mass"] = 0.0
model = load_model(str(PARAMS_FILE))
sim = Simulator(model)
result = sim.rollout_log(log, simulate_control=True)
return result[0] # positions
def compute_m6_friction(motor_torque, external_torque, dq):
"""M6 friction computation matching our kernel (and BAM's model.py)."""
p = M6
stribeck_coeff = np.exp(-(np.abs(dq / p["dtheta_stribeck"]) ** p["alpha"]))
gearbox_torque = np.abs(
external_torque * p["load_friction_external"]
- motor_torque * p["load_friction_motor"]
)
gearbox_torque_stribeck = np.abs(
external_torque * p["load_friction_external_stribeck"]
- motor_torque * p["load_friction_motor_stribeck"]
)
frictionloss = p["friction_base"]
frictionloss += gearbox_torque
frictionloss += stribeck_coeff * p["friction_stribeck"]
frictionloss += gearbox_torque_stribeck * stribeck_coeff
# quadratic (tiny, skip for clarity)
damping = p["friction_viscous"]
friction_budget = frictionloss + damping * np.abs(dq)
return friction_budget
def mujoco_rollout(log: dict) -> list[float]:
"""Run the testbench in MuJoCo with our BAM M6 actuator logic."""
mass = log["mass"]
kp_fw = log["kp"]
dt = log["dt"]
entries = log["entries"]
# Load and modify the testbench model
spec = mujoco.MjSpec.from_file(str(TESTBENCH_XML))
# Convert actuator to motor (same as our kernel's edit_spec)
for act in spec.actuators:
act.set_to_motor()
act.forcelimited = True
force_limit = VIN * M6["kt"] / M6["R"]
act.forcerange = (-force_limit, force_limit)
act.gear = [1.0, 0, 0, 0, 0, 0]
# Zero out MuJoCo joint friction (we handle it)
for joint in spec.joints:
if joint.type == mujoco.mjtJoint.mjJNT_HINGE:
joint.damping = 0.0
joint.frictionloss = 0.0
joint.armature = M6["armature"]
# Set the arm mass to match the BAM recording
for body in spec.bodies:
if body.name == "arm":
# Scale mass and inertia proportionally
original_mass = body.mass
scale = mass / original_mass if original_mass > 0 else 1.0
body.mass = mass
# Scale inertia proportionally to mass
body.fullinertia = [x * scale for x in body.fullinertia]
break
model = spec.compile()
data = mujoco.MjData(model)
model.opt.timestep = dt
# Find joint and actuator IDs
joint_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, "1")
dof_id = model.jnt_dofadr[joint_id]
# Initialize state
data.qpos[dof_id] = entries[0]["position"]
data.qvel[dof_id] = entries[0].get("speed", 0.0)
mujoco.mj_forward(model, data)
positions = []
for entry in entries:
positions.append(float(data.qpos[dof_id]))
if not entry["torque_enable"]:
data.ctrl[0] = 0.0
mujoco.mj_step(model, data)
continue
goal = entry["goal_position"]
q = data.qpos[dof_id]
dq = data.qvel[dof_id]
# ── BAM M6 actuator logic (same as our kernel) ──
# 1. Firmware control law
duty = (goal - q) * kp_fw * ERROR_GAIN
duty = np.clip(duty, -MAX_PWM, MAX_PWM)
voltage = VIN * duty
# 2. DC motor torque
motor_torque = M6["kt"] * voltage / M6["R"] - M6["kt"] ** 2 * dq / M6["R"]
# 3. External torque (from MuJoCo bias forces)
# BAM convention: bias_torque = m*g*l*sin(q) with g=-9.81 (gravity negative)
# MuJoCo convention: qfrc_bias has opposite sign
external_torque = -data.qfrc_bias[dof_id]
# 4. M6 friction
friction_budget = compute_m6_friction(motor_torque, external_torque, dq)
# 5. Static friction clipping
eff_inertia = 1.0 / model.dof_invweight0[dof_id] if model.dof_invweight0[dof_id] > 0 else 1e6
net_no_friction = motor_torque + external_torque
tau_stop = (eff_inertia / dt) * dq + net_no_friction
friction_mag = min(abs(tau_stop), friction_budget)
friction_torque = -np.sign(tau_stop) * friction_mag
# 6. Set ctrl = motor + friction (MuJoCo adds qfrc_bias)
data.ctrl[0] = motor_torque + friction_torque
mujoco.mj_step(model, data)
return positions
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--plot", action="store_true", help="Show plots")
parser.add_argument("--max-files", type=int, default=5)
args = parser.parse_args()
data_files = sorted(DATA_DIR.glob("*.json"))
if args.max_files:
data_files = data_files[: args.max_files]
print(f"Validating BAM M6 kernel against {len(data_files)} testbench recordings")
print(f"M6 params: kt={M6['kt']:.4f} R={M6['R']:.4f}")
print(f"Testbench XML: {TESTBENCH_XML}")
print()
results = []
for fpath in data_files:
log = json.load(open(fpath))
name = f"{log['trajectory']}_m{log['mass']}_kp{log['kp']}"
print(f" {name}...", end=" ", flush=True)
real_pos = [e["position"] for e in log["entries"]]
# BAM Python reference
bam_pos = bam_python_rollout(log)
# Our MuJoCo M6 kernel
mj_pos = mujoco_rollout(log)
# Compute MAE
real_np = np.array(real_pos)
bam_np = np.array(bam_pos)
mj_np = np.array(mj_pos[: len(real_np)])
mae_bam = np.mean(np.abs(bam_np - real_np))
mae_mj = np.mean(np.abs(mj_np - real_np))
mae_bam_vs_mj = np.mean(np.abs(bam_np - mj_np))
print(
f"MAE bam_vs_real={mae_bam:.5f} mj_vs_real={mae_mj:.5f} bam_vs_mj={mae_bam_vs_mj:.5f}"
)
results.append(
{
"name": name,
"real": real_np,
"bam": bam_np,
"mj": mj_np,
"mae_bam": mae_bam,
"mae_mj": mae_mj,
"mae_bam_vs_mj": mae_bam_vs_mj,
}
)
print()
avg_bam = np.mean([r["mae_bam"] for r in results])
avg_mj = np.mean([r["mae_mj"] for r in results])
avg_diff = np.mean([r["mae_bam_vs_mj"] for r in results])
print(f"Average MAE bam_vs_real={avg_bam:.5f} mj_vs_real={avg_mj:.5f} bam_vs_mj={avg_diff:.5f}")
if avg_diff > 0.01:
print("\n⚠ BAM and MuJoCo diverge significantly — likely a kernel bug!")
elif avg_mj > avg_bam * 1.5:
print("\n⚠ MuJoCo worse than BAM — MuJoCo dynamics differ from BAM's simple integrator")
else:
print("\n✓ BAM and MuJoCo agree — kernel is correct")
if args.plot:
try:
import matplotlib.pyplot as plt
n = len(results)
fig, axes = plt.subplots(n, 1, figsize=(12, 3 * n), sharex=False)
if n == 1:
axes = [axes]
for ax, r in zip(axes, results):
t = np.arange(len(r["real"])) * 0.005
ax.plot(t, r["real"], "k-", lw=1.5, label="Real")
ax.plot(t, r["bam"], "b--", lw=1.2, label=f'BAM (MAE={r["mae_bam"]:.4f})')
ax.plot(t, r["mj"], "r:", lw=1.2, label=f'MuJoCo M6 (MAE={r["mae_mj"]:.4f})')
ax.set_title(r["name"])
ax.set_ylabel("Position (rad)")
ax.legend(fontsize=8)
ax.grid(alpha=0.3)
axes[-1].set_xlabel("Time (s)")
plt.tight_layout()
plt.savefig("bam_validation.png", dpi=150)
print("Saved bam_validation.png")
plt.show()
except ImportError:
print("matplotlib not available, skipping plots")
if __name__ == "__main__":
main()