PID Tuning

The AR4 simulation uses gravity-enabled Gazebo with gz_ros2_control, which requires carefully tuned PID gains. The upstream AR4 driver ships gains tuned for zero-gravity visualization (P=10, D=1, I=1) — unusable under real physics.

The Problem

The upstream URDF has no <dynamics> elements (damping=0, friction=0), and the implicitSpringDamper Gazebo tag has no effect with zero stiffness. Under gravity, every joint sags — joint 5 (wrist pitch) dropped 27° with the upstream gains.

Our sim_tabletop.launch.py post-processes the xacro output to inject <dynamics damping="..." friction="..."> into each joint via xml.etree.ElementTree, then spawns the URDF via file (not topic) to avoid DDS discovery issues.

Manual Tuning (Rounds 1-3)

Three rounds of manual tuning improved things but couldn't eliminate the J5 sag:

RoundJ5 PJ5 sagNotes
150027°Initial attempt
21500~5°Increased all gains
33000~1.5°Diminishing returns

Automated Tuning with Optuna (Round 4)

We built an automated pipeline using Optuna TPE optimization, decoupled from ROS via Zenoh.

Architecture

The optimizer is a standalone Python container (no ROS dependency) that communicates with the simulation exclusively through Zenoh pub/sub. The tuning bridge node inside the sim container translates between Zenoh messages and ROS 2 controller operations.

Trial Sequence

Each trial (~65 seconds):

  1. Sample 24 PID parameters (P, I, D, i_clamp × 6 joints) using log-uniform TPE
  2. Publish gains to ar4/tuning/gains
  3. Reconfigure — deactivate JTC, set new params via ros2 param set, reactivate
  4. Home hold — command home pose, observe steady-state error for 15s
  5. Reach move — command reach pose [0.5, 0.3, -0.4, 0.8, -0.5, 0.3] rad, observe for 25s
  6. Scorecost = 10 × Σ(steady_state_errors) + 1 × settling_time

Search Space

ParameterRangeScale
P100 – 10,000log-uniform
I10 – 2,000log-uniform
D5 – 500log-uniform
i_clamp10 – 500log-uniform

24 total parameters (4 per joint × 6 joints).

Results (300 Trials)

Best trial: 162, cost = 40.726

All 300 trials completed successfully with zero penalties. The cost converged to a tight range of 40.72–40.76 after ~100 trials.

Optimized Gains

JointPIDi_clampRole
J1 (base)267.028.713.044.9Base rotation — light load
J2 (shoulder)528.61066.425.654.2Heavy gravity load — high I
J3 (elbow)265.710.313.114.3Moderate load
J4 (wrist roll)1474.5975.0137.116.0High P+I for precision
J5 (wrist pitch)6383.0459.0226.327.7Highest P — fights gravity sag
J6 (wrist yaw)2934.01794.817.9441.9High I for steady-state

Key observations:

  • J5 (wrist pitch) needs the highest P gain (6383) to counteract gravitational torque on the wrist
  • J2 (shoulder) and J6 (wrist yaw) rely heavily on integral action for gravity compensation
  • J1 (base) and J3 (elbow) need surprisingly low gains — they're well-supported by the arm structure
  • The cost floor at ~40.7 reflects residual steady-state error that PID alone cannot eliminate (would need feedforward gravity compensation)

Comparison: Manual vs Optuna

MetricRound 3 (manual)Round 4 (Optuna)
Tuning time~3 hours iterating~5.4 hours automated
Parameters explored6 configurations300 configurations
J5 P gain30006383
Approachuniform scalingper-joint optimization
Reproduciblenoyes (SQLite study)

Running the Tuning Pipeline

# Prerequisites
docker compose build overlay pid-tuner
 
# Run (default 300 trials)
./scripts/run_pid_tuning.sh
 
# Or specify trial count
./scripts/run_pid_tuning.sh 50
 
# Results
cat data/tuning/best_gains.yaml      # Best gains (YAML)
ls data/tuning/optuna_study.db        # Optuna study (SQLite, resumable)

The study is resumable — running the script again will continue from where it left off (same SQLite database).

Docker Services

ServicePurpose
zenoh-routerCentral Zenoh broker with in-memory storage
sim-tabletop-headlessHeadless Gazebo simulation
zenoh-bridgeDDS-to-Zenoh bridge (discovers ROS topics)
pid-tunerStandalone Optuna optimizer container

Key Files

FilePurpose
zenoh/zenoh-storage.json5Zenoh router storage configuration
zenoh/cyclonedds-bridge.xmlCycloneDDS config for zenoh-bridge (no SharedMemory)

CycloneDDS Configuration

This host has multiple network interfaces (Docker bridges, Tailscale, Cloudflare WARP) which confuse CycloneDDS multicast discovery. Two separate configs handle this:

  • Sim containers use docker/cyclonedds.xml (bundled at /etc/ros/cyclonedds.xml) — forces loopback interface
  • Zenoh bridge uses zenoh/cyclonedds-bridge.xml — same loopback restriction but without the SharedMemory element (unsupported by the bridge's bundled CycloneDDS)

On this page