Uni-cycle — a one-wheeled cycle. The model treats the whole robot as one imaginary wheel. It rolls where it faces and can turn on the spot. It never slides sideways
Twist uses two of its six numbers: linear.x = , angular.z = Legend
— where the robot is, m
— where it faces, rad
— " per second": a dot is a rate
— forward speed, m/s
— turn rate, rad/s
— speed of each tyre, m/s
— distance between the wheels, m
hades.mech.northwestern.edu/index.php/Modern_Robotics — ch. 13.3, nonholonomic wheeled mobile robots
Four and two — the same code; one comes later
<!-- Gazebo's stock DiffDrive: the library file and the class inside it -->
<plugin filename="gz-sim-diff-drive-system" name="gz::sim::systems::DiffDrive">
<left_joint>left_wheel_joint</left_joint> <!-- a left wheel joint; repeat for more -->
<right_joint>right_wheel_joint</right_joint> <!-- a right wheel joint -->
<wheel_separation>0.35</wheel_separation> <!-- L, m. Default 1.0 -->
<wheel_radius>0.1</wheel_radius> <!-- r, m. Default 0.2 -->
<topic>/model/bot/cmd_vel</topic> <!-- IN: gz.msgs.Twist -->
<odom_topic>/model/bot/odometry</odom_topic> <!-- OUT: gz.msgs.Odometry -->
</plugin>
wheel_separation and wheel_radius are wrong for every real robot. It still drives — and its odometry liesgz.msgs.Twist — it reads only and . Out: gz.msgs.Odometry — position, heading, the two speeds. The bridge turns it into nav_msgs/msg/Odometry github.com/gazebosim/gz-sim/blob/gz-sim8/src/systems/diff_drive/DiffDrive.hh
<left_joint>front_left_wheel_joint</left_joint> <!-- two joints on the left… -->
<left_joint>rear_left_wheel_joint</left_joint> <!-- …both get one speed -->
<right_joint>front_right_wheel_joint</right_joint> <!-- two on the right… -->
<right_joint>rear_right_wheel_joint</right_joint> <!-- …likewise -->
<wheel_separation>0.5708</wheel_separation> <!-- L: left to right, m -->
<wheel_radius>0.1651</wheel_radius> <!-- r, m -->
front_left and front_right github.com/gazebosim/gz-sim/blob/gz-sim8/examples/worlds/diff_drive_skid.sdf
<left_joint>left_wheel_hinge</left_joint> <!-- one driven wheel a side -->
<right_joint>right_wheel_hinge</right_joint> <!-- and the other -->
<wheel_separation>0.34</wheel_separation> <!-- L, m -->
<wheel_radius>0.11</wheel_radius> <!-- r, m -->
github.com/gazebosim/gz-sim/blob/gz-sim8/examples/worlds/diff_drive.sdf
Twist in, wheel speeds out, odometry back
<!-- the loader: a system that comes with Gazebo and runs a Python module -->
<plugin filename="gz-sim-python-system-loader-system" name="gz::sim::systems::PythonSystemLoader">
<module_name>simple_diff_drive</module_name> <!-- our file: simple_diff_drive.py -->
<left_joint>left_wheel_joint</left_joint> <!-- from here on: our own tags… -->
<right_joint>right_wheel_joint</right_joint> <!-- …named like the stock ones -->
<wheel_separation>0.35</wheel_separation> <!-- L -->
<wheel_radius>0.1</wheel_radius> <!-- r -->
</plugin>
class SimpleDiffDrive: # the plugin: one object per <plugin> block
def configure(self, entity, sdf, ecm, event_mgr): ... # once, when the world loads
def pre_update(self, info, ecm): ... # every step, BEFORE physics
def post_update(self, info, ecm): ... # every step, AFTER physics
def get_system(): # the loader calls this once…
return SimpleDiffDrive() # …and gets the object back
get_system(). Then it calls the object's methods at each moment — only the ones it has. configure reads our tags by name gazebosim.org/api/sim/8/python_interfaces.html ·
github.com/gazebosim/gz-sim/blob/gz-sim8/examples/worlds/python_system_loader.sdf
on_cmd_vel only remembers the Twist. It keeps it until the next one — there is no timeoutpre_update is the only moment that may change the world. post_update only reads itclass SimpleDiffDrive:
def configure(self, entity, sdf, ecm, event_mgr): # once, when the world loads
model = Model(entity) # the model this <plugin> sits in
# <left_joint> / <right_joint>: a name in our block → a wheel joint we can command
self.left = Joint(model.joint_by_name(ecm, sdf.get_string('left_joint')))
self.right = Joint(model.joint_by_name(ecm, sdf.get_string('right_joint')))
self.separation = sdf.get_double('wheel_separation') # L
self.radius = sdf.get_double('wheel_radius') # r
self.v = self.w = 0.0 # no command yet: v = ω = 0
self.node = Node() # Gazebo transport, not ROS
# every Twist on /model/<name>/cmd_vel → on_cmd_vel
self.node.subscribe(Twist, f'/model/{model.name(ecm)}/cmd_vel', self.on_cmd_vel)
Joint is the wheel's hinge — the joint named in <left_joint>. The plugin sets its speed and reads its angleWhere these values go:
Twist arrivesLegend
joint speed — how fast a wheel turns on its hinge, rad/s
— speed of each tyre, m/s
— from the Twist
— distance between the wheels, m
— wheel radius, m
<right_joint> or <left_joint>. Its speed is how fast that wheel spins def on_cmd_vel(self, msg): # message thread, whenever a Twist arrives
self.v, self.w = msg.linear.x, msg.angular.z # keep v and ω, touch nothing else
def pre_update(self, info, ecm): # every step, BEFORE physics
if info.paused: # paused world: do nothing
return
half = self.w * self.separation / 2 # ω·L/2
# set_velocity writes a JointVelocityCmd; physics then holds the joint
# at exactly that speed for this step, whatever torque it takes
self.left.set_velocity(ecm, [(self.v - half) / self.radius]) # (v − ωL/2) / r
self.right.set_velocity(ecm, [(self.v + half) / self.radius]) # (v + ωL/2) / r
Legend
— from the Twist
— distance between the wheels, m
— wheel radius, m
— speed of the right / left tyre over the floor, m/s
joint speed — how fast the wheel turns on its hinge, rad/s
github.com/gazebosim/gz-sim/blob/gz-sim8/src/systems/diff_drive/DiffDrive.cc
A filter between the Twist and the wheels: the speed may change only so fast, and never past a maximum. A real robot cannot reach full speed in 1 ms — it would slip or tip
Legend
— the speed sent to the wheels this step
— the speed of the step before
— one step, 1 ms
— slowest and fastest change, m/s² (here −1, 1)
— lowest and highest speed, m/s (here −0.5, 0.5)
The same for , with its own bounds
github.com/gazebosim/gz-math/blob/gz-math7/src/SpeedLimiter.cc
<plugin filename="gz-sim-diff-drive-system" name="gz::sim::systems::DiffDrive"> <!-- the same block -->
… <!-- joints, L, r as before -->
<max_linear_velocity>0.5</max_linear_velocity> <!-- v_max, m/s -->
<min_linear_velocity>-0.5</min_linear_velocity> <!-- v_min: reversing -->
<max_linear_acceleration>1</max_linear_acceleration> <!-- a_max, m/s² -->
<min_linear_acceleration>-1</min_linear_acceleration> <!-- a_min: braking -->
<max_angular_velocity>1</max_angular_velocity> <!-- the same for ω, rad/s… -->
<min_angular_velocity>-1</min_angular_velocity>
<max_angular_acceleration>2</max_angular_acceleration> <!-- …and rad/s² -->
<min_angular_acceleration>-2</min_angular_acceleration>
</plugin>
<plugin> block. The values are from Gazebo's own examplemin_ is and every max_ is until you set it. The robot can then reach any speed in one stepmax_ alone leaves reversing unlimited. Set min_ too github.com/gazebosim/gz-sim/blob/gz-sim8/examples/worlds/diff_drive_skid.sdf ·
github.com/gazebosim/gz-sim/blob/gz-sim8/src/systems/diff_drive/DiffDrive.hh
What each wheel does
Legend
Our robot: = 0.35 m between the wheels, = 0.1 m wheel radius
Wheel speeds: joint speeds, rad/s
# one Twist: v = 0.5 m/s, ω = 0.5 rad/s, sent once
$ ros2 topic pub --once /cmd_vel geometry_msgs/msg/Twist \
'{linear: {x: 0.5}, angular: {z: 0.5}}' # v → linear.x, ω → angular.z
Legend
— radius of the circle, m
— time of one lap, s
— from the Twist
How the wheels work out where the robot is
Where the robot is, worked out from how far its wheels turned. Four steps, in post_update, every physics step:
| Step | Formula | In words |
|---|---|---|
| 1 · each wheel rolled | change of wheel angle × radius = distance on the floor | |
| 2 · the body moved | average → forward; difference → turn | |
| 3 · add to the pose | one step along the heading | |
| 4 · publish | every 20 ms: where, which way, how fast |
Legend
— wheel angles, as the joints report them, rad
— the change in one step
— wheel radius, m
— distance between the wheels, m
— how far each tyre rolled, m
— how far the centre moved, m
— how much the robot turned, rad
— the pose
— one step, s
— the heading as a quaternion
github.com/gazebosim/gz-math/blob/gz-math7/src/DiffDriveOdometry.cc
def post_update(self, info, ecm): # every step, AFTER physics: read only
left = self.left.position(ecm) # φ_L, rad — a list; None until asked for
right = self.right.position(ecm) # φ_R, rad
if info.paused or left is None or right is None: # nothing to read yet
return
ds_l = self.radius * (left[0] - self.last[0]) # Δs_L = r·Δφ_L
ds_r = self.radius * (right[0] - self.last[1]) # Δs_R = r·Δφ_R
self.last = (left[0], right[0]) # φ now → "previous" next step
ds = (ds_r + ds_l) / 2 # Δs
dth = (ds_r - ds_l) / self.separation # Δθ
configure: enable_position_check(ecm)Legend
— wheel angles, rad
— their change in one step
— wheel radius, m
— distance between the wheels, m
— how far each tyre rolled, m
— how far the centre moved, m
— how much the robot turned, rad
self.x += ds * math.cos(self.th + dth / 2) # x += Δs·cos(θ + Δθ/2): heading at mid-step
self.y += ds * math.sin(self.th + dth / 2) # y += Δs·sin(θ + Δθ/2)
self.th += dth # θ += Δθ
Legend
— where the robot is, m
— where it faces, rad
— how far the centre moved this step, m
— how much it turned this step, rad
— "add to what is there"
github.com/gazebosim/gz-math/blob/gz-math7/src/DiffDriveOdometry.cc
if info.sim_time >= self.next_pub: # in post_update: every 20 ms of sim time
self.next_pub += timedelta(milliseconds=20) # the next message's time
msg = Odometry() # gz.msgs.Odometry
t = info.sim_time # stamp: sim time, not the wall clock…
msg.header.stamp.sec = t.days * 86400 + t.seconds # …whole seconds
msg.header.stamp.nsec = t.microseconds * 1000 # …and nanoseconds
msg.pose.position.x, msg.pose.position.y = self.x, self.y # x, y, m
msg.pose.orientation.z = math.sin(self.th / 2) # q: half the angle…
msg.pose.orientation.w = math.cos(self.th / 2) # …about z only: a flat robot
msg.twist.linear.x = ds / info.dt.total_seconds() # v = Δs/Δt, m/s
msg.twist.angular.z = dth / info.dt.total_seconds() # ω = Δθ/Δt, rad/s
self.odom.publish(msg) # out on /model/<name>/odometry
<model>/odom, <model>/chassisLegend
— forward speed, m/s
— turn rate, rad/s
— one step, s
— the heading as a quaternion
self.odom — made once in configure: node.advertise(topic, Odometry)
Skid-steer, in formulas
Legend
— how far the wheel's axle is ahead of (or behind) the centre: half the wheelbase, m
— how far the wheel is to the side of the centre: half the track, m
— the robot's forward speed and turn rate
A wheel ahead of the centre and to its side must move
hades.mech.northwestern.edu/index.php/Modern_Robotics — ch. 13, the rolling-without-slipping constraint
wheel_separation_multiplier: 1.875WheelSlip) · or fuse an IMU — later in the courseLegend
— the turn odometry reports, rad
— the real turn, from Gazebo, rad
— how far each side's tyres rolled, m
— distance between the wheels, from the SDF, m
— the distance that would explain the real turn, m
github.com/clearpathrobotics/clearpath_common/blob/humble/clearpath_control/config/a200/control.yaml
Friction coefficient — a property of a surface. Each wheel sets it on its <collision> (the tyre); the ground has its own; physics uses the smaller of the two. The largest force a tyre can push with = coefficient × load
<friction><ode> <!-- in each wheel's <collision>; Gazebo's skid example -->
<fdir1>0 0 1</fdir1> <!-- direction 1 = the wheel's own z = its axle = SIDEWAYS -->
<mu>1</mu> <!-- friction coefficient in direction 1: across the tyre -->
<mu2>1</mu2> <!-- friction coefficient in direction 2: along the tyre -->
<slip1>0.035</slip1> <!-- sideways softness; 0 = hard -->
</ode></friction>
slip1 = 0 it does not turn: the coefficient is 1 both ways, and the track (1.25 m) is shorter than the wheelbase (1.51 m). Holding wins: the wheels spin, the body staysslip1 = 0.035 the tyres give sideways, and it turns. The Husky: mu = 0.5 sideways, a track wider than its wheelbase — it turns, slidingLegend
track — distance between left and right wheels, m
wheelbase — distance between front and rear axles, m
coefficient along / across — friction of the tyre along it / across it
load — the weight on the wheel; the same on both sides, so it cancels
github.com/gazebosim/gz-sim/blob/gz-sim8/examples/worlds/diff_drive_skid.sdf ·
sdformat.org/spec?ver=1.11&elem=collision
One wheel, our plugin
<link name="body"> <!-- the plate -->
<pose>0 0 0.1 0 0 0</pose> <!-- 0.1 m above the ground -->
<inertial auto="true"/> <!-- mass and inertia: from the shapes -->
<collision name="plate"><geometry><box><size>0.4 0.2 0.05</size></box></geometry></collision>
<collision name="ball"> <!-- the 2nd support: without it, it tips -->
<pose>-0.15 0 -0.05 0 0 0</pose> <!-- behind, under the plate -->
<geometry><sphere><radius>0.05</radius></sphere></geometry>
<surface><friction><ode><mu>0</mu><mu2>0</mu2></ode></friction></surface> <!-- no friction: slides -->
</collision>
</link>
<link name="wheel">
<pose>0.15 0 0.1 -1.5708 0 0</pose> <!-- in front; a cylinder stands on end: roll it −90° -->
<inertial auto="true"/> <!-- the same: from the shape -->
<collision name="tire"><geometry><cylinder><radius>0.1</radius><length>0.1</length></cylinder></geometry></collision>
</link>
<joint name="wheel_joint" type="revolute"> <!-- a hinge: one rotation -->
<parent>body</parent> <child>wheel</child> <!-- the wheel turns on the body -->
<axis><xyz expressed_in="__model__">0 1 0</xyz></axis> <!-- the AXLE: the model's y -->
</joint>
expressed_in="__model__" says so, so nobody has to turn the cylinder in their headauto="true" (SDF 1.11) — Gazebo computes mass and inertia from the collision shapes<visual> per link — the same shapes, with a colour. That is what you see sdformat.org/spec?ver=1.11&elem=link ·
sdformat.org/spec?ver=1.11&elem=joint
<!-- the same Python loader and module as before -->
<plugin filename="gz-sim-python-system-loader-system" name="gz::sim::systems::PythonSystemLoader">
<module_name>simple_diff_drive</module_name> <!-- our SimpleDiffDrive -->
<left_joint>wheel_joint</left_joint> <!-- the one wheel… -->
<right_joint>wheel_joint</right_joint> <!-- …named on both sides -->
<wheel_separation>0.3</wheel_separation> <!-- L: no 2nd wheel, the formula wants one -->
<wheel_radius>0.1</wheel_radius> <!-- r -->
</plugin>
teleop_twist_keyboard drives it as it is. The bridge carries /cmd_vel to the model's topicpre_update writes the left speed into the joint, then the right speed — into the same joint, wheel_joint. Physics sees only the second:AckermannSteeringright_joint: nothing moves, no odometry, no warningLegend
joint speed — how fast wheel_joint, the wheel's hinge, turns, rad/s
— from the Twist
— wheel_separation, 0.3 m: there is no second wheel, but the formula needs it
— wheel radius, 0.1 m
github.com/gazebosim/gz-sim/blob/gz-sim8/src/systems/diff_drive/DiffDrive.cc
The plugin stays behind
cmd_vel, Five Paths to a Wheel🟩 shared by all or several robots · 🟥 specific to one robot — exactly what changes when you move · 🟦 simulation only · ⬜ hardware
control.ros.org/jazzy/doc/ros2_controllers/diff_drive_controller/doc/userdoc.html ·
github.com/ROBOTIS-GIT/turtlebot3
| ① Gazebo | ② TurtleBot 3 | ③ Pioneer | ④ Husky · ⑤ home-made | |
|---|---|---|---|---|
| the message you send | Twist |
Twist |
TwistStamped |
TwistStamped |
| who turns into wheel speeds | the plugin | OpenCR firmware | ARCOS firmware | diff_drive_controller |
what replaces set_velocity |
— physics holds the joint | a speed command to each Dynamixel servo | ARCOS drives the motors | the Husky's MCU · your firmware |
| commands stop coming | drives on | drives on — until its node dies | stops | stops after 0.5 s |
linear.x, = angular.z, in m/s and rad/s, in the robot's own axesset_velocity — the driver, the firmware and the wire between them. That is the red, and that is what you replace when you move emanual.robotis.com/docs/en/platform/turtlebot3/overview ·
github.com/grupo-avispa/pioneer_ros2 ·
github.com/clearpathrobotics/clearpath_common
set_velocity is not a force. It tells physics "this joint turns at this speed", and physics applies whatever torque that takes. So a wheel reaches its speed in one step, and stops in oneGazebo, set_velocity |
A real robot | |
|---|---|---|
| who holds the speed | the physics engine — exactly, every step | firmware in a microcontroller, or inside the servo |
| feedback | the joint's exact speed, no noise | an encoder: ticks — rough and noisy |
| what turns the wheel | a torque, applied straight to the joint | PWM → H-bridge → current → motor → gearbox |
| limits | none, unless the model sets them | battery voltage, driver current, motor torque |
| what odometry sees | the ideal wheel angle | ticks — with slip and backlash |
gazebosim.org/api/sim/8/classgz_1_1sim_1_1Joint.html ·
github.com/joshnewans/ros_arduino_bridge ·
articulatedrobotics.xyz/tutorials/mobile-robot/applications/ros2_control-concepts
| What | Does it change? | So that your code does not |
|---|---|---|
| the algorithm — which and to send | no | — |
| the message type | Twist in simulation and on a TurtleBot 3, TwistStamped elsewhere |
take the type from a parameter, like teleop's stamped |
| the topic name | sometimes — <ns>/cmd_vel on a Husky |
a relative cmd_vel, and a remap in the launch file |
| how often you send | yes, if you send once: a timeout stops the robot | publish on a timer |
| whether it is obeyed literally | no — the robot's limits clip it | close the loop on odom, not on what you sent |
| the clock | use_sim_time: false |
a parameter, not code |
github.com/ros2/teleop_twist_keyboard/blob/jazzy/teleop_twist_keyboard.py
Not graded — for those who want to write a Gazebo plugin.
configure, pre_update: slides 11, 13, 14post_update and a gz.msgs.Odometry: , , the pose, , , — slides 22, 23, 24one_wheel — does "turn left" do what slide 35 says?How a drone's plugins differ from DiffDrive
gazebosim.org/api/sim/8/classgz_1_1sim_1_1systems_1_1MulticopterVelocityControl.html
DiffDrive |
MulticopterMotorModel + MulticopterVelocityControl |
|
|---|---|---|
| plugins per robot | one | one per rotor, and one controller |
| input | Twist — an intention |
one speed per rotor, from the controller |
| what it sets every step | each wheel joint's speed — physics holds it exactly | the rotor's speed, and a force along the rotor axis with a twist on the body |
| formula | left , right — exact kinematics | — then physics integrates it |
| who closes the loop | nobody needs to: the ground holds the robot level | the controller, watching the real velocity |
| odometry | yes, from the wheel angles | none — a separate plugin |
| on a real robot | firmware holds each wheel's speed: OpenCR, ARCOS, the Husky's MCU | an autopilot — PX4, ArduPilot — and an ESC per motor |
Legend
— from the Twist
— distance between the wheels, m
— wheel radius, m
— thrust of one rotor, N
— motorConstant
— the rotor's spin speed, rad/s
Lecture 4 — Building the Robot
Describing a robot yourself in URDF · parts, joints and frames · ros2_control, the same in simulation and on hardware · the robot that finally appears in RViz
docs.ros.org/en/jazzy/Tutorials/Intermediate/URDF/URDF-Main.html