microduck_rl/scripts/testbench_sim2real.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

612 lines
24 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Sim2real validation for the XL330 test bench.
Runs the same ONNX policy on a fixed sequence of target angles, either in
MuJoCo (with the BAM M6 actuator model) or on the real XL330 (via rustypot),
logs the joint trajectory, and plots sim vs real for comparison.
Example workflow
----------------
# 1) Record in sim:
uv run python scripts/testbench_sim2real.py --mode sim --onnx policy.onnx --out sim.npz
# 2) Plug the real bench via USB, then record on hardware:
uv run python scripts/testbench_sim2real.py --mode real --onnx policy.onnx --out real.npz \
--port /dev/ttyUSB0 --motor-id 1
# 3) Compare the two traces:
uv run python scripts/testbench_sim2real.py --compare sim.npz real.npz --out-plot comparison.png
Observation layout (must match the training env): [joint_pos, joint_vel, last_action, command]
Action: 1-D position offset in radians, scaled by 1.0, added to default pose (0.0).
"""
from __future__ import annotations
import argparse
import math
import time
from pathlib import Path
import numpy as np
import onnxruntime as ort
from mjlab_microduck.robot.testbench_constants import (
TESTBENCH_ARM_MASS,
TESTBENCH_XML,
_set_arm_mass,
)
# --- Match training env ---
CONTROL_DT = 0.02 # decimation=4 × timestep=0.005 (policy rate = 50 Hz)
SIM_DT = 0.005 # (logging rate = 200 Hz — one sample per inner sim step)
LOG_DT = SIM_DT
DEFAULT_POS = 0.0
MAX_ANGLE = math.radians(80.0)
# XL330 present_velocity is returned by rustypot as raw ticks (i32, NOT converted).
# Each tick = 0.229 RPM (per Dynamixel XL330 spec). rad/s = ticks * 0.229 * 2π/60.
DXL_VEL_TICK_TO_RAD_S = 0.229 * 2.0 * math.pi / 60.0 # ≈ 0.02398 rad/s per tick
# ---------------------------------------------------------------------------
# Shared: target schedule + policy wrapper
# ---------------------------------------------------------------------------
def make_target_schedule(
total_time: float,
hold_time: float = 4.0,
seed: int = 0,
) -> np.ndarray:
"""Return one target angle per control step."""
rng = np.random.default_rng(seed)
n_steps = int(round(total_time / CONTROL_DT))
steps_per_hold = int(round(hold_time / CONTROL_DT))
targets = np.zeros(n_steps, dtype=np.float32)
i = 0
while i < n_steps:
angle = float(rng.uniform(-MAX_ANGLE, MAX_ANGLE))
end = min(i + steps_per_hold, n_steps)
targets[i:end] = angle
i = end
return targets
class PolicyRunner:
def __init__(self, onnx_path: str, action_scale: float = 1.0):
print(f"Loading policy: {onnx_path} (action_scale={action_scale})")
self.session = ort.InferenceSession(onnx_path)
self.in_name = self.session.get_inputs()[0].name
in_shape = self.session.get_inputs()[0].shape
print(f" input {self.in_name} shape={in_shape}")
self.action_scale = action_scale
self.last_action = np.zeros(1, dtype=np.float32)
def reset(self):
self.last_action[:] = 0.0
def step(self, q: float, qd: float, target: float) -> float:
# Matches the testbench env's policy obs layout:
# [joint_pos_rel, joint_vel_rel, last_action, command] (4-d).
obs = np.array(
[q - DEFAULT_POS, qd, self.last_action[0], target],
dtype=np.float32,
)[None, :]
action = self.session.run(None, {self.in_name: obs})[0].reshape(-1)
self.last_action = action.astype(np.float32)
return DEFAULT_POS + float(action[0]) * self.action_scale
# ---------------------------------------------------------------------------
# Sim rollout (mujoco, same XL330 testbench XML as training)
# ---------------------------------------------------------------------------
def rollout_sim_bam(onnx_path: str, total_time: float, seed: int, action_scale: float) -> dict:
"""Sim rollout using bam's MujocoController on a vanilla MuJoCo step loop.
Pros: 200 Hz inner-step logging, no torch/mjwarp. Cons: not the exact
actuator that was trained against (uses bam upstream, not mjlab's M6).
"""
import mujoco # local import so --mode real works without mujoco
from bam.actuators import actuators as bam_actuators
from bam.model import models as bam_models
from bam.mujoco import MujocoController
# Load the fitted XL330 m6 params from the canonical bam bundle (identical to
# the values that used to live in mjlab_microduck.actuator.bam_params).
import json as _json
from bam.model import _resolve_json_path
with open(_resolve_json_path(None, "xl330", "m6")) as _f:
DEFAULT_XL330_M6 = _json.load(_f)
VIN = 7.4
KP_FW = 200.0
ACTUATOR_NAME = "1"
# Build BAM's M6 model + XL330 voltage-controlled actuator. The
# MujocoController below drives the joint via this model on every step,
# writing torque to data.ctrl and updating dof_frictionloss/dof_damping
# so MuJoCo's solver applies BAM's Stribeck+load+quadratic friction.
bam_model = bam_models["m6"]()
bam_model.set_actuator(bam_actuators["xl330"]())
bam_model.actuator.kp = KP_FW
bam_model.actuator.vin = VIN
bam_model.load_parameters_from_dict(DEFAULT_XL330_M6)
kt = bam_model.kt.value
R = bam_model.R.value
spec = mujoco.MjSpec.from_file(str(TESTBENCH_XML))
_set_arm_mass(spec, TESTBENCH_ARM_MASS)
# MujocoController needs a torque-controlled motor; the XL330 entry in the
# XML is a position actuator, so convert it and set the voltage-bounded
# force range. Armature is set on the dof by MujocoController.__init__.
for act in spec.actuators:
act.set_to_motor()
act.forcelimited = False
fl = VIN * kt / R
act.forcerange = (-fl, fl)
act.gear = [1.0, 0, 0, 0, 0, 0]
for joint in spec.joints:
if joint.type == mujoco.mjtJoint.mjJNT_HINGE:
joint.damping = 0.0
joint.frictionloss = 0.0
model = spec.compile()
data = mujoco.MjData(model)
model.opt.timestep = SIM_DT
joint_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, "1")
dof_id = int(model.jnt_dofadr[joint_id])
qpos_id = int(model.jnt_qposadr[joint_id])
data.qpos[qpos_id] = 0.0
data.qvel[dof_id] = 0.0
mujoco.mj_forward(model, data)
bam_ctrl = MujocoController(bam_model, ACTUATOR_NAME, model, data)
bam_ctrl.reset(data.qpos)
runner = PolicyRunner(onnx_path, action_scale=action_scale)
policy_targets = make_target_schedule(total_time, seed=seed)
decim = int(round(CONTROL_DT / SIM_DT))
# Logging at SIM_DT (200 Hz): decim samples per policy step.
N_log = len(policy_targets) * decim
rec = {k: np.zeros(N_log, dtype=np.float32)
for k in ("t", "target", "q", "qd", "action", "ctrl")}
t = 0.0
log_i = 0
for policy_i, target in enumerate(policy_targets):
q = float(data.qpos[qpos_id])
qd = float(data.qvel[dof_id])
goal = runner.step(q, qd, float(target))
action_raw = float(runner.last_action[0])
for _ in range(decim):
q = float(data.qpos[qpos_id])
dq = float(data.qvel[dof_id])
# ---- log at 200 Hz ----
rec["t"][log_i] = t
rec["target"][log_i] = target
rec["q"][log_i] = q
rec["qd"][log_i] = dq
rec["action"][log_i] = action_raw
rec["ctrl"][log_i] = goal
log_i += 1
# BAM owns control/torque/friction: set the target, then update()
# writes torque to data.ctrl and pushes friction/damping onto the
# dof so MuJoCo's solver applies them on the next step.
bam_ctrl.set_q_target(ACTUATOR_NAME, goal)
bam_ctrl.update()
mujoco.mj_step(model, data)
t += SIM_DT
return rec
def rollout_sim_mjlab(onnx_path: str, total_time: float, seed: int, action_scale: float) -> dict:
"""Sim rollout via the actual mjlab testbench env (same BAM M6 the policy was trained against).
Boots make_testbench_env_cfg() with num_envs=1, overrides the target_angle
command with our deterministic schedule each policy tick, and steps the env
with the policy action. We replicate ManagerBasedRlEnv.step's inner
decimation loop manually so we can log q/qd at SIM_DT (200 Hz) between
sub-steps, matching the bam backend's logging rate.
"""
import torch
from mjlab.envs import ManagerBasedRlEnv
from mjlab_microduck.tasks.testbench_env_cfg import make_testbench_env_cfg
env_cfg = make_testbench_env_cfg(play=True)
env_cfg.scene.num_envs = 1
# Disable auto-resampling and auto-reset so our deterministic schedule and
# initial pose hold for the entire rollout.
env_cfg.commands["target_angle"].resampling_time_range = (1e6, 1e6)
env_cfg.episode_length_s = max(total_time + 10.0, env_cfg.episode_length_s)
# Drop observation noise so the mjlab path is a fair sim2real reference
# (matches the bam path which doesn't inject noise either).
env_cfg.observations["policy"].enable_corruption = False
device = "cuda:0" if torch.cuda.is_available() else "cpu"
env = ManagerBasedRlEnv(cfg=env_cfg, device=device)
env.reset(seed=seed)
cmd_term = env.command_manager.get_term("target_angle")
robot = env.scene["robot"]
runner = PolicyRunner(onnx_path, action_scale=action_scale)
policy_targets = make_target_schedule(total_time, seed=seed)
decim = env.cfg.decimation
physics_dt = env.physics_dt
N_log = len(policy_targets) * decim
rec = {k: np.zeros(N_log, dtype=np.float32)
for k in ("t", "target", "q", "qd", "action", "ctrl")}
t = 0.0
log_i = 0
for target in policy_targets:
# Inject deterministic target and recompute obs so the policy sees it
# this tick (the env's TargetAngleCommand otherwise samples randomly).
cmd_term._target[0, 0] = float(target)
# update_history=True is critical: the testbench env's joint_vel obs
# has a 1-tick delay, so the history buffer must advance each policy
# tick or the policy sees stale velocity.
obs_buf = env.observation_manager.compute(update_history=True)
policy_obs = obs_buf["policy"][0].detach().cpu().numpy().astype(np.float32)
ort_out = runner.session.run(None, {runner.in_name: policy_obs[None, :]})[0].reshape(-1)
runner.last_action = ort_out.astype(np.float32)
action_raw = float(ort_out[0])
goal = DEFAULT_POS + action_raw * action_scale
# Manually run the decimation loop ManagerBasedRlEnv.step uses, so we
# can sample joint state at the physics rate (200 Hz).
action = torch.as_tensor(ort_out, device=device).reshape(1, -1)
env.action_manager.process_action(action)
for _ in range(decim):
# Log the pre-step state to mirror the bam backend (which records
# q/qd right before each mj_step).
rec["t"][log_i] = t
rec["target"][log_i] = float(target)
rec["q"][log_i] = float(robot.data.joint_pos[0, 0].item())
rec["qd"][log_i] = float(robot.data.joint_vel[0, 0].item())
rec["action"][log_i] = action_raw
rec["ctrl"][log_i] = goal
log_i += 1
env.action_manager.apply_action()
env.scene.write_data_to_sim()
env.sim.step()
env.scene.update(dt=physics_dt)
t += physics_dt
env.close()
return rec
# ---------------------------------------------------------------------------
# Real rollout (rustypot XL330)
# ---------------------------------------------------------------------------
def rollout_real(
onnx_path: str,
total_time: float,
seed: int,
port: str,
motor_id: int,
baudrate: int,
kp: int,
action_scale: float,
) -> dict:
from rustypot import Xl330PyController
ctrl = Xl330PyController(port, baudrate, 0.05)
assert ctrl.ping(motor_id), f"motor id={motor_id} not responding on {port}"
# Match the firmware gain used in sim (BAM kp_fw=200).
ctrl.write_torque_enable(motor_id, False)
ctrl.write_operating_mode(motor_id, 3) # position control
ctrl.write_position_p_gain(motor_id, kp)
ctrl.write_position_i_gain(motor_id, 0)
ctrl.write_position_d_gain(motor_id, 0)
# Read back to confirm the gain actually landed (firmware silently clamps
# out-of-range values, so verifying catches mismatches early).
readback = ctrl.read_position_p_gain(motor_id)
if isinstance(readback, (list, tuple)):
readback = readback[0]
print(f" XL330 position P-gain: requested={kp}, readback={readback}")
ctrl.write_goal_position(motor_id, 0.0)
ctrl.write_torque_enable(motor_id, True)
time.sleep(1.0) # let it settle at zero
runner = PolicyRunner(onnx_path, action_scale=action_scale)
policy_targets = make_target_schedule(total_time, seed=seed)
decim = int(round(CONTROL_DT / LOG_DT)) # samples per policy tick (4 at 200 Hz / 50 Hz)
N_log = len(policy_targets) * decim
rec = {k: np.zeros(N_log, dtype=np.float32)
for k in ("t", "target", "q", "qd", "action", "ctrl")}
def _scalar(x) -> float:
if isinstance(x, (list, tuple)):
return float(x[0])
return float(x)
t_start = time.perf_counter()
prev_q = 0.0
log_i = 0
goal = 0.0
action_raw = 0.0
for policy_i, target in enumerate(policy_targets):
tick_start = time.perf_counter()
target_f = float(target)
# Read once, run policy, write goal — all at the start of the 20 ms window.
q = _scalar(ctrl.read_present_position(motor_id))
try:
qd = _scalar(ctrl.read_present_velocity(motor_id)) * DXL_VEL_TICK_TO_RAD_S
except Exception:
qd = (q - prev_q) / CONTROL_DT
goal = runner.step(q, qd, target_f)
action_raw = float(runner.last_action[0])
# ctrl.write_goal_position(motor_id, float(np.clip(goal, -MAX_ANGLE, MAX_ANGLE)))
ctrl.write_goal_position(motor_id, float(goal))
# First 200 Hz sample uses the values we just read (no extra USB round-trip).
rec["t"][log_i] = time.perf_counter() - t_start
rec["target"][log_i] = target_f
rec["q"][log_i] = q
rec["qd"][log_i] = qd
rec["action"][log_i] = action_raw
rec["ctrl"][log_i] = goal
prev_q = q
log_i += 1
# Remaining (decim-1) samples inside the policy window: read only.
for k in range(1, decim):
sample_deadline = tick_start + (k + 1) * LOG_DT
while time.perf_counter() < sample_deadline - 0.001:
time.sleep(0.0005)
q = _scalar(ctrl.read_present_position(motor_id))
try:
qd = _scalar(ctrl.read_present_velocity(motor_id)) * DXL_VEL_TICK_TO_RAD_S
except Exception:
qd = (q - prev_q) / LOG_DT
prev_q = q
rec["t"][log_i] = time.perf_counter() - t_start
rec["target"][log_i] = target_f
rec["q"][log_i] = q
rec["qd"][log_i] = qd
rec["action"][log_i] = action_raw
rec["ctrl"][log_i] = goal
log_i += 1
# Live status on every new segment plus a heartbeat.
new_segment = policy_i == 0 or policy_targets[policy_i] != policy_targets[policy_i - 1]
if new_segment or policy_i % 25 == 0:
print(
f"\r t={rec['t'][log_i-1]:6.2f}s target={math.degrees(target_f):+6.1f}° "
f"q={math.degrees(q):+6.1f}° err={math.degrees(q - target_f):+6.1f}° "
f"goal={math.degrees(goal):+6.1f}°",
end="" if not new_segment else "\n",
flush=True,
)
# Hold the remaining time of the policy window if we got here early.
dt_left = CONTROL_DT - (time.perf_counter() - tick_start)
if dt_left > 0:
time.sleep(dt_left)
print()
ctrl.write_torque_enable(motor_id, False)
return rec
# ---------------------------------------------------------------------------
# Plotting / analytics
# ---------------------------------------------------------------------------
def _mae(a: np.ndarray, b: np.ndarray) -> float:
n = min(len(a), len(b))
return float(np.mean(np.abs(a[:n] - b[:n])))
def npz_to_bam_log(npz_path: str, json_path: str, *, mass: float, length: float,
kp: int, vin: float) -> None:
"""Convert a rollout .npz (written by rollout_sim/rollout_real) to a BAM log json.
BAM log format (see ~/Rhoban/bam/bam/logs.py):
top-level: mass, length, kp, vin, motor, trajectory, dt
entries: position, speed, load, input_volts, temp, goal_position, torque_enable, timestamp
Can be fed to `python -m bam.plot --logdir <dir> --actuator xl330`.
"""
import json
d = dict(np.load(npz_path))
t = d["t"]
# Prefer the actual recorded timestamps for dt to handle small jitter;
# fall back to the fixed control period if there are fewer than 2 samples.
dt = float(np.mean(np.diff(t))) if len(t) > 1 else CONTROL_DT
entries = []
for i in range(len(t)):
entries.append({
"position": float(d["q"][i]),
"speed": float(d["qd"][i]),
"load": 0.0,
"input_volts": vin,
"temp": 25.0,
"goal_position": float(d["ctrl"][i]),
"torque_enable": True,
"timestamp": float(t[i]),
})
log = {
"mass": mass,
"length": length,
"kp": kp,
"vin": vin,
"motor": "xl330",
"trajectory": "rl_policy",
"dt": dt,
"entries": entries,
}
out = Path(json_path)
out.parent.mkdir(parents=True, exist_ok=True)
with open(out, "w") as f:
json.dump(log, f)
print(f"Wrote BAM log: {out} ({len(entries)} entries, dt={dt:.4f}s, mass={mass}kg, kp={kp})")
print(f" Replay with: (cd ~/Rhoban/bam && python -m bam.plot --logdir {out.parent} --actuator xl330)")
def compare_and_plot(sim_file: str, real_file: str, out_path: str) -> None:
import matplotlib.pyplot as plt
sim = dict(np.load(sim_file))
real = dict(np.load(real_file))
n = min(len(sim["t"]), len(real["t"]))
t = sim["t"][:n]
err_sim = sim["q"][:n] - sim["target"][:n]
err_real = real["q"][:n] - real["target"][:n]
print("\n=== Analytics ===")
print(f" steps compared : {n}")
print(f" MAE q (sim vs real) : {_mae(sim['q'], real['q']):.4f} rad "
f"({math.degrees(_mae(sim['q'], real['q'])):.2f}°)")
print(f" sim tracking MAE : {float(np.mean(np.abs(err_sim))):.4f} rad "
f"({math.degrees(float(np.mean(np.abs(err_sim)))):.2f}°)")
print(f" real tracking MAE : {float(np.mean(np.abs(err_real))):.4f} rad "
f"({math.degrees(float(np.mean(np.abs(err_real)))):.2f}°)")
print(f" sim qd RMS : {float(np.sqrt(np.mean(sim['qd'][:n]**2))):.3f} rad/s")
print(f" real qd RMS : {float(np.sqrt(np.mean(real['qd'][:n]**2))):.3f} rad/s")
print(f" action MAE : {_mae(sim['action'], real['action']):.4f} rad")
fig, axes = plt.subplots(4, 1, figsize=(11, 10), sharex=True)
axes[0].plot(t, sim["target"][:n], "k-", lw=1, label="target", alpha=0.4)
axes[0].plot(t, sim["q"][:n], "b-", lw=1.2, label="sim q")
axes[0].plot(t, real["q"][:n], "r-", lw=1.2, label="real q")
axes[0].plot(t, sim["ctrl"][:n], "b:", lw=0.8, alpha=0.6, label="sim goal (policy)")
axes[0].plot(t, real["ctrl"][:n], "r:", lw=0.8, alpha=0.6, label="real goal (policy)")
axes[0].set_ylabel("position [rad]")
axes[0].legend(loc="upper right", fontsize=8)
axes[0].grid(alpha=0.3)
axes[0].set_title(f"Testbench sim2real — MAE(sim, real) = {_mae(sim['q'], real['q']):.4f} rad")
axes[1].plot(t, np.degrees(err_sim), "b-", lw=1, label="sim")
axes[1].plot(t, np.degrees(err_real), "r-", lw=1, label="real")
axes[1].axhline(0, color="k", lw=0.5)
axes[1].set_ylabel("tracking error [deg]")
axes[1].legend(fontsize=8)
axes[1].grid(alpha=0.3)
axes[2].plot(t, sim["qd"][:n], "b-", lw=1, label="sim")
axes[2].plot(t, real["qd"][:n], "r-", lw=1, label="real")
axes[2].set_ylabel("velocity [rad/s]")
axes[2].legend(fontsize=8)
axes[2].grid(alpha=0.3)
axes[3].plot(t, sim["action"][:n], "b-", lw=1, label="sim action")
axes[3].plot(t, real["action"][:n], "r-", lw=1, label="real action")
axes[3].set_ylabel("policy action [rad]")
axes[3].set_xlabel("time [s]")
axes[3].legend(fontsize=8)
axes[3].grid(alpha=0.3)
plt.tight_layout()
plt.savefig(out_path, dpi=140)
print(f"\nSaved plot: {out_path}")
plt.show()
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--mode", choices=["sim", "real"], help="Rollout mode")
ap.add_argument("--sim-backend", choices=["bam", "mjlab"], default="bam",
help="Sim backend: 'bam' uses bam.MujocoController on a vanilla "
"mujoco loop (200 Hz log, lightweight); 'mjlab' boots the actual "
"make_testbench_env_cfg() mjlab env with its BamM6Actuator (50 Hz log).")
ap.add_argument("--onnx", type=str, help="Path to trained ONNX policy")
ap.add_argument("--out", type=str, help="Output .npz log file")
ap.add_argument("--duration", type=float, default=30.0, help="Total time [s]")
ap.add_argument("--seed", type=int, default=0, help="Target schedule seed")
# real-only
ap.add_argument("--port", type=str, default="/dev/ttyUSB0")
ap.add_argument("--motor-id", type=int, default=1)
ap.add_argument("--baudrate", type=int, default=1_000_000)
ap.add_argument("--kp", type=int, default=200, help="XL330 position P gain")
ap.add_argument("--action-scale", type=float, default=1.0,
help="Multiplier applied to the policy action before offsetting "
"by the default pose (must match training env action scale)")
# compare mode
ap.add_argument("--compare", nargs=2, metavar=("SIM_NPZ", "REAL_NPZ"),
help="Plot two logged runs side by side")
ap.add_argument("--out-plot", type=str, default="testbench_sim2real.png")
# BAM log export
ap.add_argument("--to-bam", nargs=2, metavar=("NPZ", "JSON"),
help="Convert a rollout NPZ to BAM log format "
"(run with: python -m bam.plot --logdir <dir> --actuator xl330)")
ap.add_argument("--bam-mass", type=float, default=TESTBENCH_ARM_MASS, help="Payload mass [kg]")
ap.add_argument("--bam-length", type=float, default=0.1, help="Arm length [m]")
ap.add_argument("--bam-vin", type=float, default=7.4, help="Supply voltage [V]")
args = ap.parse_args()
if args.compare:
compare_and_plot(args.compare[0], args.compare[1], args.out_plot)
return
if args.to_bam:
npz_to_bam_log(
args.to_bam[0],
args.to_bam[1],
mass=args.bam_mass,
length=args.bam_length,
kp=args.kp,
vin=args.bam_vin,
)
return
if not (args.mode and args.onnx and args.out):
ap.error("--mode, --onnx and --out are required for a rollout")
if args.mode == "sim":
sim_fn = rollout_sim_mjlab if args.sim_backend == "mjlab" else rollout_sim_bam
rec = sim_fn(args.onnx, args.duration, args.seed, args.action_scale)
else:
rec = rollout_real(
args.onnx, args.duration, args.seed,
args.port, args.motor_id, args.baudrate, args.kp,
args.action_scale,
)
out = Path(args.out)
out.parent.mkdir(parents=True, exist_ok=True)
np.savez(out, **rec)
err = rec["q"] - rec["target"]
print(f"\nSaved {len(rec['t'])} samples to {out}")
print(f" tracking MAE: {float(np.mean(np.abs(err))):.4f} rad ({math.degrees(float(np.mean(np.abs(err)))):.2f}°)")
if __name__ == "__main__":
main()