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:
| Round | J5 P | J5 sag | Notes |
|---|---|---|---|
| 1 | 500 | 27° | Initial attempt |
| 2 | 1500 | ~5° | Increased all gains |
| 3 | 3000 | ~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):
- Sample 24 PID parameters (P, I, D, i_clamp × 6 joints) using log-uniform TPE
- Publish gains to
ar4/tuning/gains - Reconfigure — deactivate JTC, set new params via
ros2 param set, reactivate - Home hold — command home pose, observe steady-state error for 15s
- Reach move — command reach pose
[0.5, 0.3, -0.4, 0.8, -0.5, 0.3]rad, observe for 25s - Score —
cost = 10 × Σ(steady_state_errors) + 1 × settling_time
Search Space
| Parameter | Range | Scale |
|---|---|---|
| P | 100 – 10,000 | log-uniform |
| I | 10 – 2,000 | log-uniform |
| D | 5 – 500 | log-uniform |
| i_clamp | 10 – 500 | log-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
| Joint | P | I | D | i_clamp | Role |
|---|---|---|---|---|---|
| J1 (base) | 267.0 | 28.7 | 13.0 | 44.9 | Base rotation — light load |
| J2 (shoulder) | 528.6 | 1066.4 | 25.6 | 54.2 | Heavy gravity load — high I |
| J3 (elbow) | 265.7 | 10.3 | 13.1 | 14.3 | Moderate load |
| J4 (wrist roll) | 1474.5 | 975.0 | 137.1 | 16.0 | High P+I for precision |
| J5 (wrist pitch) | 6383.0 | 459.0 | 226.3 | 27.7 | Highest P — fights gravity sag |
| J6 (wrist yaw) | 2934.0 | 1794.8 | 17.9 | 441.9 | High 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
| Metric | Round 3 (manual) | Round 4 (Optuna) |
|---|---|---|
| Tuning time | ~3 hours iterating | ~5.4 hours automated |
| Parameters explored | 6 configurations | 300 configurations |
| J5 P gain | 3000 | 6383 |
| Approach | uniform scaling | per-joint optimization |
| Reproducible | no | yes (SQLite study) |
Running the Tuning Pipeline
The study is resumable — running the script again will continue from where it left off (same SQLite database).
Docker Services
| Service | Purpose |
|---|---|
zenoh-router | Central Zenoh broker with in-memory storage |
sim-tabletop-headless | Headless Gazebo simulation |
zenoh-bridge | DDS-to-Zenoh bridge (discovers ROS topics) |
pid-tuner | Standalone Optuna optimizer container |
Key Files
| File | Purpose |
|---|---|
zenoh/zenoh-storage.json5 | Zenoh router storage configuration |
zenoh/cyclonedds-bridge.xml | CycloneDDS 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 theSharedMemoryelement (unsupported by the bridge's bundled CycloneDDS)