Skip to content

feat(pace): integrate PACE actuator system identification#117

Open
fan-ziqi wants to merge 12 commits into
mainfrom
devel-pace
Open

feat(pace): integrate PACE actuator system identification#117
fan-ziqi wants to merge 12 commits into
mainfrom
devel-pace

Conversation

@fan-ziqi

@fan-ziqi fan-ziqi commented May 29, 2026

Copy link
Copy Markdown
Owner

Summary

Integrate the upstream leggedrobotics/pace-sim2real into robot_lab as first-class modules, and register a demo task Isaac-Pace-Anymal-D-v0 that mirrors the PACE ANYmal-D example.

PACE (Precise Adaptation through Continuous Evolution) estimates physical actuator and joint parameters (armature, viscous friction, static/dynamic friction, encoder bias, command delay) from chirp-excitation data using CMA-ES, then lets you apply those parameters back into your simulation for higher-fidelity sim-to-real transfer. See the PACE documentation and paper.

What's added

  • robot_lab.actuators.PaceDCMotor / PaceDCMotorCfg — DCMotor with encoder bias and torque delay buffer (port of upstream).
  • robot_lab.utils.paths.project_root() — repo-root helper used by the PACE scripts that go through the IsaacLab launcher.
  • robot_lab.tasks.manager_based.pace/ — base env_cfg, mdp/, optim/CMAESOptimizer, agents/ placeholder.
  • robot_lab.tasks.manager_based.pace.config.anymal_d/AnymalDPaceEnvCfg and registration of Isaac-Pace-Anymal-D-v0.
  • scripts/pace/{data_collection,fit,plot_trajectory}.py — three user-facing entry points mirroring upstream. plot_trajectory.py resolves the project root from __file__ so it can run offline without launching Isaac Sim.
  • setup.py: add cmaes runtime dependency.
  • .gitignore: ignore root-level /data/ for chirp recordings.
  • README: PACE workflow section under "Try examples" + acknowledgement.

Usage

python scripts/pace/data_collection.py --headless --task=Isaac-Pace-Anymal-D-v0
python scripts/pace/fit.py --headless --task=Isaac-Pace-Anymal-D-v0 --num_envs=4096
python scripts/pace/plot_trajectory.py --plot_trajectory --plot_score --robot_name=anymal_d_sim

Outputs: data/anymal_d_sim/chirp_data.pt, logs/pace/anymal_d_sim/<timestamp>/{config.pt, mean_xxx.pt, best_trajectory.pt}.

Notes

  • PACE is developed against Isaac Sim 5.0+. On 4.5 the joint viscous friction setter is silently ignored, degrading identification quality.
  • All upstream PACE invariants preserved (fix_root_link=True, static < dynamic friction write order, 49-dim parameter layout, ANYmal-D 12-joint order).
  • Existing locomotion tasks (e.g. Isaac-Velocity-Flat-Anymal-D-v0) are not affected.
  • pre-commit (ruff lint + ruff-format + SPDX header insertion + codespell + ...) passes on all PACE files.

Test Plan (verified locally on RTX 4090, Isaac Sim 5.x)

  • pip install -e source/robot_lab succeeds and pulls in cmaes (cmaes-0.13.0)
  • Isaac-Pace-Anymal-D-v0 discovered via robot_lab.tasks and parse_env_cfg succeeds (verified end-to-end via data_collection.py and fit.py)
  • python scripts/pace/data_collection.py --headless --task=Isaac-Pace-Anymal-D-v0 --num_envs=1 --duration=1 produces data/anymal_d_sim/chirp_data.pt with {time:[400], dof_pos:[400,12], des_dof_pos:[400,12]}
  • python scripts/pace/fit.py --headless --task=Isaac-Pace-Anymal-D-v0 --num_envs=256 runs all 200 CMA-ES generations and writes mean_000.pt … mean_199.pt, best_trajectory.pt, TensorBoard events; score decreases monotonically (e.g. min 0.124 → 0.059 over the first 7 generations)
  • MPLBACKEND=Agg python scripts/pace/plot_trajectory.py --plot_trajectory --robot_name=anymal_d_sim runs offline (no Isaac Sim launch) and recovers parameters close to ground truth: armature ≈ 0.100 (truth 0.1), viscous friction ≈ 4.45 (truth 4.5), encoder bias ≈ 0.05 (truth 0.05), delay ≈ 5.67 (truth 5)
  • Isaac-Velocity-Flat-Anymal-D-v0 and other existing tasks remain registered (no regression)
  • pre-commit run --all-files passes

It is an offline visualisation tool, so importing robot_lab.utils.paths
(which pulls in the full robot_lab.tasks registry and requires launching
the Isaac Sim app) was overkill. Resolve the project root directly from
the script's __file__ location instead.
@fan-ziqi

Copy link
Copy Markdown
Owner Author

How to collect data on real hardware

While exploring this integration I noticed the upstream PACE docs don't actually cover real-robot data collection — docs/real-world.md is an empty file and the "Real-world" entry in docs/index.md is wrapped in an HTML comment, so the docs site never renders it. The only hint is in docs/examples/anymal.md:

"While the data collection is typically performed directly on the real robot hardware, we present the full pipeline in simulation to illustrate the process and enable safe testing and validation prior to hardware deployment."

For anyone wondering "okay, but how?", here's what scripts/pace/fit.py actually expects (read off lines 65-69 of fit.py), so you can implement the recorder on your own robot stack.

What fit.py expects in data/<robot_name>/chirp_data.pt

data = torch.load(data_file)
data["time"]          # shape: [T]               , dtype float32
data["des_dof_pos"]   # shape: [T, n_joints]     , PD-target sent to motors
data["dof_pos"]       # shape: [T, n_joints]     , encoder reading

Three tensors, saved with torch.save({...}, ...). Column order must match PaceCfg.joint_order in your env_cfg. Sampling rate must match sim.dt (ANYmal default is 400 Hz, i.e. dt = 0.0025).

The 5 things to do on the robot side

  1. Use the same chirp trajectory as scripts/pace/data_collection.py:104-122. Same f0=0.1, f1=10, duration=20s, same trajectory_directions / bias / scale per joint group. Different excitation = different parameters.
  2. Run the controller at the same rate with the same PD gains as in PaceDCMotorCfg (stiffness=85, damping=0.6 for ANYmal). Mismatched gains invalidate the identification.
  3. Joint-order alignment. IsaacLab uses BFS over the URDF, the SDK on your robot likely doesn't. Record columns in the order you wrote in joint_order (for ANYmal-D: LF_HAA, LF_HFE, LF_KFE, RF_HAA, ...).
  4. Pin the base in mid-air on a rig. PACE simulation uses fix_root_link=True. The real-robot equivalent is a physical jig — otherwise the chirp will throw the robot off the floor.
  5. Save the three tensors to data/<robot_name>/chirp_data.pt, then point your env_cfg's robot_name / data_dir at it and run python scripts/pace/fit.py --task=... --num_envs=4096 --headless exactly as in the simulation example.

Minimal recorder skeleton (replace MyRobotSDK with your stack)

import torch, numpy as np, time
from math import pi

robot = MyRobotSDK()
joint_order = ["LF_HAA", "LF_HFE", "LF_KFE", "RF_HAA", "RF_HFE", "RF_KFE",
               "LH_HAA", "LH_HFE", "LH_KFE", "RH_HAA", "RH_HFE", "RH_KFE"]
n = len(joint_order)
duration, fs = 20.0, 400.0
T = int(duration * fs)

t     = np.linspace(0.0, duration, T)
phase = 2 * pi * (0.1 * t + (10.0 - 0.1) / (2 * duration) * t ** 2)
chirp = np.sin(phase)

directions = np.array([1, 1, 1, -1, 1, 1, 1, -1, -1, -1, -1, -1], dtype=float)
bias       = np.array([0.0, 0.4, 0.8] * 4, dtype=float)
scale      = np.array([0.25, 0.5, -2.0] * 4, dtype=float)
traj = (chirp[:, None] + bias[None, :]) * directions[None, :] * scale[None, :]   # [T, n]

dof_pos      = np.zeros((T, n))
des_dof_pos  = np.zeros((T, n))

robot.set_pd_gains(stiffness=85.0, damping=0.6)
robot.command_joint_positions(traj[0])
time.sleep(0.5)

dt = 1.0 / fs
for k in range(T):
    t0 = time.perf_counter()
    robot.command_joint_positions(traj[k])
    des_dof_pos[k] = traj[k]
    dof_pos[k]     = robot.read_joint_positions(joint_order)
    while time.perf_counter() - t0 < dt:
        pass

torch.save({
    "time":        torch.from_numpy(t).float(),
    "dof_pos":     torch.from_numpy(dof_pos).float(),
    "des_dof_pos": torch.from_numpy(des_dof_pos).float(),
}, "data/<robot_name>/chirp_data.pt")

Safety checklist before pulling the trigger on a real robot

  • Robot mounted on a rigid jig, e-stop in hand.
  • First run: scale all amplitudes by 0.1-0.3 to confirm joint directions and that nothing hits limits.
  • Verify PD gains aren't producing overshoot at small amplitude before going to full scale.
  • Watch motor temperatures — chirp at full amplitude for 20s is hard on actuators.
  • Record at least two takes (cold / warm) for variance, and validate the fit on a different excitation (different f0/f1 or scale) per upstream docs/tutorials/best_practice.md.

This is reverse-engineered from the upstream code (scripts/pace/{data_collection,fit}.py and pace_actuator.py), not from official docs. Treat it as a starting point and check upstream when they fill in docs/real-world.md.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant