Download scripts/testbench_sim2real.py from kenpeter123/microduck_rl: direct link, hf CLI and curl.
- Browser
- Download file 24.4 kB
-
https://huggingface.co/kenpeter123/microduck_rl/resolve/main/scripts/testbench_sim2real.py
- Command line
-
hf download hf://kenpeter123/microduck_rl/scripts/testbench_sim2real.py
-
curl -L -o testbench_sim2real.py https://huggingface.co/kenpeter123/microduck_rl/resolve/main/scripts/testbench_sim2real.py
24.4 kB
| #!/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() | |