Simulation and reinforcement learning
A simulator lets you walk the robot thousands of times without breaking a part; reinforcement learning (RL) lets the computer discover a walking controller by trial and error inside that simulator. Strido uses MuJoCo for fast physics and training, Gazebo for testing the complete ROS 2 stack, and PPO to train a walking policy that runs on the Pi with plain NumPy.
In Strido
- MuJoCo model:
models/mujoco/strido.xml+scene.xml, generated from the CAD bymake models. - Gazebo model:
ros2/strido_description/urdf/strido.urdf.xacro(withsim:=true), worldros2/strido_bringup/worlds/flat.sdf, launched bygazebo.launch.py. - RL code:
packages/strido-sim/src/strido_sim/rl/(env.py,train.py,evaluate.py); policy runtimepackages/strido-core/src/strido/policy.py. - Released policy:
models/policies/ppo-v1/policy.npz(MLP 23 → 128 → 128 → 12). - Commands:
make sim-walk,make sim-gazebo,make rl-smoke,make rl-train,make rl-eval(simulation).
Why two simulators
| MuJoCo 3 | Gazebo Harmonic | |
|---|---|---|
| Runs | Python process (mujoco==3.17.0) | Docker container with ROS 2 Jazzy |
| Strength | Fast, deterministic contact physics; easy to step thousands of times per second from Python | Simulates sensors (IMU, GPU LiDAR, camera) and runs the real ROS 2 nodes and controllers |
| Used for | Gait demo, PPO training and evaluation | End-to-end test of gait_node / policy_node with the same topics as the robot |
| Command | make sim-walk, make rl-* | make sim-gazebo |
Running a policy trained in MuJoCo through Gazebo is a cheap sim-to-sim check: if it only works in the simulator it was trained in, it has learned that simulator's quirks, and it will not survive the real robot either.
The models, generated from CAD
strido_cad.models builds all simulation models from the verified CAD assembly, so the simulated robot has the real link lengths, masses and joint limits:
- 13 link meshes (
ros2/strido_description/meshes/), decimated to 25 k faces each; - masses from part volumes and densities, inertias from the meshes, cables by routed length: 3.07 kg in total (
ros2/strido_description/config/mass_report.yaml); - MJCF with 12 position actuators (
kp 25,kv 0.4, torque limit ±3.432 N·m = SPT5435LV stall torque at 6 V), an IMU site with quaternion, gyro and accelerometer sensors, and foot sites; - physics step 0.002 s (500 Hz) with the
implicitfastintegrator.
One simplification: the knee is modelled as a direct revolute joint on the calf. The four-bar linkage is solved in software (gait, firmware) and its limits are applied before commands reach the joint (kinematics).
In Gazebo, the xacro with sim:=true adds gz_ros2_control with a position command interface per joint, an IMU (200 Hz), a GPU LiDAR (360 samples, 10 Hz, 0.05–12 m, Gaussian noise σ 0.01 m), a camera (640 × 360, 15 Hz) and a ground-truth odometry publisher (/ground_truth/odom, 50 Hz).
MuJoCo: the gait walk
make sim-walk # 10 s trot at 0.15 m/s → build/sim/walk.mp4
uv run --locked python -m strido_sim.walk --seconds 6 --wz 0.6 # turn
uv run --locked mjpython -m strido_sim.walk --viewer # interactive (macOS needs mjpython)This runs strido.gait, the same trot the robot runs through ROS 2, at 100 Hz like the real command stream. It adds a heading hold using the simulator's yaw, which the robot does not have. Recorded result: 1.10 m in 10 s at 0.15 m/s, body height 0.174 m (verification).
Gazebo: the full ROS 2 stack
make sim-gazebo # build image, headless walk test, exit code = pass/fail
make sim-gazebo POLICY=models/policies/ppo-v1/policy.npz # same test with policy_nodewalk_smoke waits 6 s for the robot to stand, sends 0.15 m/s on /cmd_vel for 8 s and passes if the base moved ≥ 0.4 m forward (ground truth), stayed upright (|roll|, |pitch| < 0.5 rad, height > 0.10 m) and /joint_states, /imu/data, /scan, /camera/image_raw and /ground_truth/odom all published. Recorded: 0.98 m in 8 s with the gait node.
Reinforcement learning in one paragraph
An agent (here: a neural network, the policy
where the discount
where
The environment, as implemented
StridoEnv (strido_sim/rl/env.py) is a Gymnasium environment: "walk at the commanded velocity".
Observations (23 numbers)
| Part | Size | Source on the robot |
|---|---|---|
| Angular velocity (gyro) | 3 | IMU (/imu/data) |
| Gravity direction in the body frame | 3 | IMU orientation quaternion |
| Command vx, vy, ωz | 3 | /cmd_vel |
| Previous action | 12 | the policy's own output |
| Gait clock sin, cos (0.4 s period) | 2 | a timer |
There are no joint angles or velocities. The SPT5435LV servos report nothing, so a policy that relied on joint feedback could not run on the robot (ADR-006 in decisions). The observation holds only what the robot can measure or knows.
Actions (12 numbers)
Each action is in
- the firmware's 6 rad/s slew limit (
per policy step), - the joint and knee-crank clamp, exactly as the firmware does it.
Modelling these inside training means the policy learns with the same limits the robot will impose, instead of discovering them on hardware.
Timing
Policy at 50 Hz (CONTROL_DT = 0.02 s), physics at 500 Hz:
Commands
Each episode samples a command: 20 % of episodes stand still (zero command); the rest draw uniformly from vx −0.15…0.30 m/s, vy −0.10…0.10 m/s, ωz −0.6…0.6 rad/s. policy_node clips /cmd_vel to these ranges on the robot, because the policy has never seen anything outside them.
Reward
Per step, scaled so perfect tracking gives at most
Here
An episode ends with a −2 penalty if the base drops below 0.09 m or tilts beyond about 53°.
The exponential "tracking kernels" give a smooth reward that is 1 for perfect tracking and fades with error: with the width 0.01, a speed error of
Domain randomisation
The real robot will not match the model. Rather than hoping it does, training varies the model each episode so the policy learns to cope with a range:
| Randomised | Range |
|---|---|
| Servo stiffness (kp, and the matching bias) | × 0.85 … 1.15 |
| Base mass | × 0.90 … 1.15 |
| Contact friction, floor and feet together | × 0.6 … 1.2 |
| IMU noise | gyro σ 0.05 rad/s, gravity σ 0.02 |
| Initial joint angles | stand pose + σ 0.02 rad |
Floor and feet are scaled together because MuJoCo uses the larger friction of the two contacting geoms; scaling one alone would often change nothing.
Training, exporting and evaluating
uv sync --locked --group rl # once: PyTorch, Stable-Baselines3, Gymnasium (opt-in, large)
make rl-smoke # 20 k steps + evaluation: checks the pipeline in seconds
make rl-train # 20 M steps, 8 parallel envs, CPU → build/rl/ppo-v1/
make rl-eval # evaluate models/policies/ppo-v1/policy.npz with a video
uv run --locked --group rl python -m strido_sim.rl.evaluate build/rl/<run>/policy.npz --vx 0.2 --video build/rl/eval.mp4Key PPO settings in train.py: 1024 steps per env per update, batch 2048, 5 epochs, learning rate [128, 128], critic [256, 256]; observations and rewards normalised (VecNormalize, clip 10). The recorded ppo-v1 run took 1734 s on a CPU.
Training writes policy.zip (Stable-Baselines3), vecnormalize.pkl, progress.csv and policy.npz: the actor's weights, biases and the observation mean/variance as plain arrays. strido.policy.MlpPolicy re-implements the forward pass in a dozen lines of NumPy: normalise evaluate.py and in policy_node, so what is evaluated is bit-for-bit the function the robot runs, and the Pi needs no PyTorch.
What ppo-v1 achieves
| Test | Result |
|---|---|
| MuJoCo, vx 0.2 m/s command (current limits) | 0.215 m/s mean, 2.15 m in 10 s, upright |
| MuJoCo, v0.1 sweep | vx 0.15 → 0.152 m/s; 0 → stands; −0.1 → −0.068 m/s; vy 0.1 → 0.023 m/s (weak); ωz 0.5 → 232° in 10 s |
Gazebo through policy_node (sim-to-sim) | 0.82 m in 8 s at 0.15 m/s, upright |
Lateral and reverse tracking are weak: expected from a policy without joint feedback (ADR-006).
The sim-to-real gap
Everything above is simulation. Nothing has yet run on a physical Strido. The known gaps:
- Servo response. kp 25 / kv 0.4 and zero latency are estimates; real hobby servos have dead band, backlash, gear compliance and a voltage-dependent speed.
- Battery sag. Servo speed and torque drop with pack voltage; the model assumes 6 V.
- IMU. Training reads a perfect quaternion plus Gaussian noise; the robot's comes from a Mahony filter with its own lag and bias.
- Contacts. Printed feet on a real floor, not a uniform plane with randomised friction.
- Unmodelled mechanics. The knee linkage is a direct joint in simulation; print tolerances and flex are not modelled.
The repo's procedure for a first hardware run: evaluate in MuJoCo and Gazebo, start with the robot on a stand, keep /strido/estop at hand, and expect to retrain with measured servo response (kp/kv, latency) once the robot exists.
ros2 launch strido_bringup robot.launch.py use_policy:=true policy:=/path/policy.npzWARNING
A learned policy can command fast, large motions the scripted gait never would. The firmware's slew limit, joint clamps and watchdog still apply, but they cannot stop a leg hitting your hand. Test on a stand first (safety).
Check yourself
- Why does the policy observe no joint angles?
- What two firmware behaviours are reproduced inside
env.step(), and why? - Why are floor and foot friction randomised together?
- Why is
policy.npzexported instead of running the Stable-Baselines3 model on the Pi? - What does running the MuJoCo-trained policy in Gazebo tell you?
Answers
- The SPT5435LV servos have no position feedback; a policy must use only what the real robot measures (IMU, command, its own previous action, a clock).
- The 6 rad/s slew limit and the joint/knee-crank clamp, so the policy trains under the same constraints the robot imposes.
- MuJoCo uses the larger friction of the two contacting geoms; scaling only one would often have no effect.
- The robot would need PyTorch; the NumPy runtime is small, and the same code is used in evaluation, so the deployed function is exactly the evaluated one.
- Whether the policy depends on MuJoCo-specific quirks: a sim-to-sim gap is a warning sign for the sim-to-real gap.