Wobbly Robot Model¶
The wobbly
robot model simulates a two-wheeled differential-drive mobile
robot with a low center of gravity. The body is designed with enough ground
clearance to tilt, but the low center of mass will wobble it back upright. The
body includes a mast with two distance sensors, a radio receiver and emitter,
GPS receiver for location, compass for heading, and three-axis accelerometer.
Each wheel has both a rotational motor and a position sensor.
The shape is constructed entirely of primitive cylinders.The same geometry is referenced to use as bounding objects for collision and automatic calculation of physics parameters.
The user-accessible parameters include the wheel radius, axle length, body color, and wheel color. The kinematics, shape, and physics will parametrically scale, but the actuator parameters do not change.
This model is demonstrated in the wobbly-demo.wbt
world.
System Kinematics and Components¶
The bodies are as follows:
name |
color |
notes |
---|---|---|
body |
blue |
the core solid including counterweight, stalk, axle, and sensor disc |
left wheel |
red |
left wheel disc, cylinder with parameterized radius |
right wheel |
red |
right wheel disc, cylinder with parameterized radius |
The joints are as follows:
name |
parent |
child |
notes |
---|---|---|---|
(6DOF free) |
world |
body |
mobile body; body +X axis is ‘forward’, +Z is ‘up’ |
left wheel |
body |
left wheel |
rotational joint on axle, parameterized Z position |
right wheel |
body |
right wheel |
rotational joint on axle, parameterized Z position |
The joint axes are as follows:
name |
direction |
notes |
---|---|---|
left wheel |
along +Y |
along axle; direction chosen so positive rotation moves forward |
right wheel |
along +Y |
along axle; direction chosen so positive rotation moves forward |
The motors and sensors are named as follows:
name |
notes |
---|---|
left wheel motor |
RotationalMotor on left wheel |
right wheel motor |
RotationalMotor on right wheel |
left wheel sensor |
PositionSensor on left wheel |
right wheel sensor |
PositionSensor on right wheel |
leftDistanceSensor |
DistanceSensor near top of body, aimed left of enter |
rightDistanceSensor |
DistanceSensor near top of body, aimed right of center |
gps |
GPS locator mounted on body in center of axle |
compass |
compass mounted on body in center of axle |
accelerometer |
three-axis accelerometer mounted on body in center of axle |
receiver |
radio Receiver |
emitter |
radio Emitter |
wobbly.proto¶
The robot model has been encapsulated in a .proto file for easy reuse. The model includes user-accessible scaling and color parameters.
1#VRML_SIM R2022a utf8
2# Two-wheeled differential-drive mobile robot with a low center of gravity. The
3# body is designed with enough ground clearance to tilt, but the low center of
4# mass will wobble it back upright. The body includes a mast with two distance
5# sensors, a radio receiver and emitter, and a GPS receiver. Each wheel has
6# both a rotational motor and a position sensor. The shape is constructed
7# entirely of primitive cylinders.
8# documentation url: https://courses.ideate.cmu.edu/16-375
9# license: No copyright, 2020-2022 Garth Zeglin. This file is explicitly placed in the public domain.
10PROTO wobbly [
11 field SFVec3f translation 0 0 0
12 field SFRotation rotation 0 1 0 0
13 field SFString controller "wobbly"
14 field SFString name "Wobbly"
15 field SFFloat wheelRadius 0.1
16 field SFFloat axleLength 0.14
17 field SFColor bodyColor 0.0820075 0.364731 0.8
18 field SFColor wheelColor 1 0 0
19 field SFString customData ""
20]
21{
22 Robot {
23 # connect properties to user-visible data fields
24 translation IS translation
25 rotation IS rotation
26 controller IS controller
27 name IS name
28 customData IS customData
29
30 # calculate derived parameters
31 %{
32 local counterweightHeight = fields.wheelRadius.value
33 local counterweightOffset = 0.75*fields.wheelRadius.value
34 local counterweightTop = 1.25*fields.wheelRadius.value
35 local sensorHeight = 3*fields.wheelRadius.value
36 local stalkHeight = sensorHeight - counterweightTop
37 local stalkOffset = counterweightTop + 0.5*stalkHeight
38 }%
39
40 children [
41 # The body group contains three cylinders: the massive counterweight at
42 # bottom, a thin stalk rising above it, topped by the sensor disc.
43 # All these shapes participate in collision; the axle is kept separate
44 # as it is just for rendering.
45 DEF bodyShape Group {
46 children [
47 # counterweight
48 Transform {
49 translation 0 0 %{= counterweightOffset }%
50 rotation 1 0 0 0
51 children [
52 Shape {
53 appearance DEF bodyAppearance PBRAppearance {
54 baseColor IS bodyColor
55 roughness 0.5
56 metalness 0.5
57 }
58
59 geometry Cylinder {
60 height %{= counterweightHeight }%
61 radius 0.05
62 }
63 }
64 ]
65 }
66 # sensor disc
67 Transform {
68 translation 0 0 %{= sensorHeight }%
69 rotation 1 0 0 0
70 children [
71 Shape {
72 appearance USE bodyAppearance
73 geometry Cylinder {
74 height 0.01
75 radius 0.025
76 }
77 }
78 ]
79 }
80 # stalk
81 Transform {
82 translation 0 0 %{= stalkOffset }%
83 rotation 1 0 0 0
84 children [
85 Shape {
86 appearance USE bodyAppearance
87 geometry Cylinder {
88 height %{= stalkHeight }%
89 radius 0.01
90 }
91 }
92 ]
93 }
94 ]
95 }
96 # Visible axle shape, not part of the boundingObject.
97 DEF axleShape Transform {
98 translation 0 0 %{= fields.wheelRadius.value }%
99 rotation 1 0 0 1.5707963267948966
100 children [
101 Shape {
102 appearance USE bodyAppearance
103 geometry Cylinder {
104 height %{= fields.axleLength.value + 0.02 }%
105 radius 0.005
106 }
107 }
108 ]
109 }
110 # Define the left wheel axis, pointing in the +Y direction along the axle.
111 HingeJoint {
112 jointParameters HingeJointParameters {
113 axis 0 1 0
114 anchor 0 0 %{= fields.wheelRadius.value }%
115 }
116 device [
117 RotationalMotor {
118 name "left wheel motor"
119 acceleration 10
120 maxTorque 2
121 }
122 PositionSensor {
123 name "left wheel sensor"
124 }
125 ]
126 # Define the left wheel solid, offset along +Y along the axle.
127 endPoint DEF leftWheel Solid {
128 translation 0 %{= 0.5*fields.axleLength.value }% %{= fields.wheelRadius.value }%
129 rotation -1 0 0 1.5707963267948966
130 children [
131 # Define the left wheel shape and appearance, which is used by the right wheel solid.
132 DEF wheelShape Transform {
133 rotation 0 0 1 0
134 children [
135 Shape {
136 appearance PBRAppearance {
137 baseColor IS wheelColor
138 roughness 0.5
139 metalness 0.5
140 }
141 geometry Cylinder {
142 height 0.01
143 radius %{= fields.wheelRadius.value }%
144 }
145 }
146 ]
147 }
148 ]
149 name "left wheel"
150 boundingObject USE wheelShape
151 physics DEF wheelPhysics Physics {
152 density 600
153 }
154 }
155 }
156 # Define the right wheel axis, also pointing in the +Y direction along the axle
157 # so positive wheel rotations will move forward.
158 HingeJoint {
159 jointParameters HingeJointParameters {
160 axis 0 1 0
161 anchor 0 0 %{= fields.wheelRadius.value }%
162 }
163 device [
164 RotationalMotor {
165 name "right wheel motor"
166 acceleration 10
167 maxTorque 2
168 }
169 PositionSensor {
170 name "right wheel sensor"
171 }
172 ]
173 # Define the right wheel solid, offset along -Y along the axle.
174 # The wheel shape is inherited from the left wheel.
175 endPoint DEF rightWheel Solid {
176 translation 0 %{= -0.5*fields.axleLength.value }% %{= fields.wheelRadius.value }%
177 rotation 1 0 0 1.5707963267948966
178 children [
179 USE wheelShape
180 ]
181 name "right wheel"
182 boundingObject USE wheelShape
183 physics USE wheelPhysics
184 }
185 }
186 # Define the two range sensors. They are located on the sensor disc on
187 # top of the body stalk, each pointed off-axis by 1.0 radian.
188 DEF leftEyeSensor DistanceSensor {
189 translation 0.019 0.016 %{= sensorHeight }%
190 rotation 0 0 1 0.7
191 children [
192 DEF eyeShape Transform {
193 rotation 0 1 0 1.57
194 children [
195 Shape {
196 appearance PBRAppearance {
197 baseColor 0.975691 0.981481 0.0252992
198 roughness 0.5
199 metalness 0.5
200 }
201 geometry Cylinder {
202 height 0.004
203 radius 0.005
204 }
205 }
206 ]
207 }
208 ]
209 name "leftDistanceSensor"
210 lookupTable [
211 0 0.025 0
212 1 1 0
213 ]
214 numberOfRays 2
215 aperture 0.4
216 }
217 DEF rightEyeSensor DistanceSensor {
218 translation 0.019 -0.016 %{= sensorHeight }%
219 rotation 0 0 1 -0.7
220 children [
221 USE eyeShape
222 ]
223 name "rightDistanceSensor"
224 lookupTable [
225 0 0.025 0
226 1 1 0
227 ]
228 numberOfRays 2
229 aperture 0.4
230 }
231 # add a default radio receiver and transmitter
232 Receiver {
233 }
234 Emitter {
235 }
236 # add a position-sensing 'GPS' device located at the center of the axle
237 GPS {
238 translation 0 0 %{= fields.wheelRadius.value }%
239 }
240 # add a rotation-sensing 'compass' returning only the X and Y components of the North vector
241 Compass {
242 zAxis FALSE
243 }
244 # add an accelerometer located at the center of the axle to measure both gravity and reactive forces
245 Accelerometer {
246 translation 0 0 %{= fields.wheelRadius.value }%
247 }
248
249 ] # close children list of Robot
250 boundingObject USE bodyShape
251 physics Physics {
252 density -1
253 mass 3
254 }
255 } # close Robot
256}
Sample Control Code¶
1# wobbly.py
2
3# Sample Webots controller file for driving the wobbly diff-drive
4# mobile robot.
5
6# No copyright, 2020-2021, Garth Zeglin. This file is
7# explicitly placed in the public domain.
8
9print("loading wobbly.py...")
10
11# Import the Webots simulator API.
12from controller import Robot
13
14# Import standard Python libraries.
15import math, random, time
16
17# Define the time step in milliseconds between controller updates.
18EVENT_LOOP_DT = 200
19
20################################################################
21class Wobbly(Robot):
22 def __init__(self):
23
24 super(Wobbly, self).__init__()
25 self.robot_name = self.getName()
26 print("%s: controller connected." % (self.robot_name))
27
28 # Attempt to randomize the random library sequence.
29 random.seed(time.time())
30
31 # Initialize geometric constants. These should match
32 # the current geometry of the robot.
33 self.wheel_radius = 0.1
34 self.axle_length = 0.14
35
36 # Fetch handles for the wheel motors
37 self.l_motor = self.getDevice('left wheel motor')
38 self.r_motor = self.getDevice('right wheel motor')
39
40 # Adjust the low-level controller gains.
41 print("%s: setting PID gains." % (self.robot_name))
42 self.l_motor.setControlPID(1.0, 0.0, 0.1)
43 self.r_motor.setControlPID(1.0, 0.0, 0.1)
44
45 # Fetch handles for the wheel joint sensors.
46 self.l_pos_sensor = self.getDevice('left wheel sensor')
47 self.r_pos_sensor = self.getDevice('right wheel sensor')
48
49 # Specify the sampling rate for the joint sensors.
50 self.l_pos_sensor.enable(EVENT_LOOP_DT)
51 self.r_pos_sensor.enable(EVENT_LOOP_DT)
52
53 # Connect to the eye sensors.
54 self.l_eye_sensor = self.getDevice('leftDistanceSensor')
55 self.r_eye_sensor = self.getDevice('rightDistanceSensor')
56 self.l_eye_sensor.enable(EVENT_LOOP_DT)
57 self.r_eye_sensor.enable(EVENT_LOOP_DT)
58
59 # Connect to the radio emitter and receiver.
60 self.receiver = self.getDevice('receiver')
61 self.emitter = self.getDevice('emitter')
62 self.radio_interval = 1000
63 self.radio_timer = 0
64 self.receiver.enable(self.radio_interval)
65
66 # Maintain a table of peer robot locations received over the radio.
67 self.peers = {}
68
69 # Connect to the GPS position sensor.
70 self.gps = self.getDevice('gps')
71 self.gps_timer = 0
72 self.gps_interval = 1000
73 self.gps.enable(self.gps_interval)
74 self.gps_location = [0, 0, 0.1] # reference pose value
75
76 # Connect to the compass orientation sensor.
77 self.compass = self.getDevice('compass')
78 self.compass_timer = 0
79 self.compass_interval = 200
80 self.compass.enable(self.compass_interval)
81
82 # State variables for reporting compass readings. The heading is the
83 # body forward (body +X) direction expressed in degrees clockwise from
84 # North (world +Y). The compass_vector is the direction of North
85 # expressed in body coordinates. The neutral pose points East.
86 self.heading = 90
87 self.compass_vector = [0,1] # reference pose value
88 self.heading_error = 0
89
90 # Connect to the accelerometer sensor.
91 self.accelerometer = self.getDevice('accelerometer')
92 self.accelerometer_timer = 0
93 self.accelerometer_interval = 200
94 self.accelerometer.enable(self.accelerometer_interval)
95 self.accel_vector = [0, 0, 9.81] # reference pose value
96
97 # Initialize generic behavior state machine variables.
98 self.state_timer = 0 # timers in milliseconds
99 self.state_index = 0 # current state
100 self.target_heading = 0
101 self.target_velocity = 0
102 return
103
104 #================================================================
105 # Polling function to process sensor input at different timescales.
106 def poll_sensors(self):
107
108 self.gps_timer -= EVENT_LOOP_DT
109 if self.gps_timer < 0:
110 self.gps_timer += self.gps_interval
111 location = self.gps.getValues()
112 if not math.isnan(location[0]):
113 self.gps_location = location
114 # print("%s GPS: %s" % (self.robot_name, location))
115
116 self.compass_timer -= EVENT_LOOP_DT
117 if self.compass_timer < 0:
118 self.compass_timer += self.compass_interval
119 orientation = self.compass.getValues()
120 if not math.isnan(orientation[0]) and not math.isnan(orientation[1]):
121 # For heading, 0 degrees is North, 90 is East, 180 is South, 270 is West.
122 # The world is assumed configured 'ENU' so X is East and Y is North.
123 # The robot 'front' is along body +X, so the neutral pose is facing East.
124 self.heading = math.fmod(2*math.pi + math.atan2(orientation[1], orientation[0]), 2*math.pi) * (180.0/math.pi)
125 self.compass_vector = orientation[0:2]
126 # print("%s Compass: %s, heading %3.0f deg" % (self.robot_name, self.compass_vector, heading))
127
128 self.accelerometer_timer -= EVENT_LOOP_DT
129 if self.accelerometer_timer < 0:
130 self.accelerometer_timer += self.accelerometer_interval
131 self.accel_vector = self.accelerometer.getValues()
132 # The accelerometer will read [0, 0, 9.81] when stationary in the reference pose.
133 # print("%s Accelerometer: %s" % (self.robot_name, self.accel_vector))
134
135 return
136
137 #================================================================
138 # Polling function to process radio and network input at different timescales.
139 def poll_communication(self):
140 self.radio_timer -= EVENT_LOOP_DT
141 if self.radio_timer < 0:
142 self.radio_timer += self.radio_interval
143 while self.receiver.getQueueLength() > 0:
144 packet = self.receiver.getData()
145 # print("%s Receiver: %s" % (self.robot_name, packet))
146 tokens = packet.split()
147 if len(tokens) != 5:
148 print("%s malformed packet: %s" % (self.robot_name, packet))
149 else:
150 name = tokens[0].decode() # convert bytestring to Unicode
151 if self.peers.get(name) is None:
152 print("%s receiver: new peer observed: %s" % (self.robot_name, name))
153
154 self.peers[name] = {'location' : [float(tokens[1]), float(tokens[2]), float(tokens[3])],
155 'heading' : float(tokens[4]),
156 'timestamp' : self.getTime(),
157 }
158
159 # done with packet processing
160 self.receiver.nextPacket()
161
162 # Transmit a status message at the same rate
163 name_token = self.robot_name.replace(" ","_")
164 status = "%s %.2f %.2f %.2f %.0f" % (name_token, self.gps_location[0], self.gps_location[1],
165 self.gps_location[2] - self.wheel_radius, self.heading)
166
167 # emitter requires a bytestring, not a Python Unicode string
168 data = status.encode()
169
170 # print("%s emitter: sending %s" % (self.robot_name, data))
171 self.emitter.send(data)
172
173 #================================================================
174 # motion primitives
175 def go_forward(self, velocity):
176 """Command the motor to turn at the rate which produce the ground velocity
177 specified in meters/sec. Negative values turn backward. """
178
179 # velocity control mode
180 self.l_motor.setPosition(math.inf)
181 self.r_motor.setPosition(math.inf)
182
183 # calculate the rotational rate in radians/sec based on the wheel radius
184 theta_dot = velocity / self.wheel_radius
185 self.l_motor.setVelocity(theta_dot)
186 self.r_motor.setVelocity(theta_dot)
187 return
188
189 def go_rotate(self, rot_velocity):
190 """Command the motors to turn in place at the rate which produce the rotational
191 velocity specified in radians/sec. Negative values turn
192 backward."""
193
194 # velocity control mode
195 self.l_motor.setPosition(math.inf)
196 self.r_motor.setPosition(math.inf)
197
198 # calculate the difference in linear velocity of the wheels
199 linear_velocity = self.axle_length * rot_velocity
200
201 # calculate the net rotational rate in radians/sec based on the wheel radius
202 theta_dot = linear_velocity / self.wheel_radius
203
204 # apply the result symmetrically to the wheels
205 self.l_motor.setVelocity( 0.5*theta_dot)
206 self.r_motor.setVelocity(-0.5*theta_dot)
207 return
208
209 def heading_difference(self, target, current):
210 """Calculate a directional error in degrees, always returning a value on (-180, 180]."""
211 err = target - current
212 # fold the range of values to (-180, 180]
213 if err > 180.0:
214 return err - 360
215 elif err <= -180.0:
216 return err + 360
217 else:
218 return err
219
220 def go_heading(self, target_heading):
221 """Rotate toward the heading specifed in positive degrees, with 0 at North (+Y),
222 90 at East (+X). This assume the compass-reading process is
223 active."""
224
225 # find the directional error in degrees
226 self.heading_error = self.heading_difference(target_heading, self.heading)
227
228 # apply a linear mapping from degrees error to rotational velocity in radians/sec
229 rot_vel = 0.02 * self.heading_error
230 self.go_rotate(rot_vel)
231 # print("go_heading: %f, %f, %f" % (target_heading, self.heading, rot_vel))
232 return
233
234 def go_still(self):
235 """Actively damp any wobble to come to rest in place."""
236 # map an error in X acceleration to a linear velocity
237 vel = -0.05 * self.accel_vector[0]
238 self.go_forward(vel)
239 # print("go_still: %f, %f" % (self.accel_vector[0], vel))
240 return
241
242 #================================================================
243 def peer_heading_distance(self, record):
244 """Given a peer record, return a tuple (heading, distance) with the compass
245 heading and distance in meters of the peer from this robot current
246 location."""
247 loc = record['location']
248 dx = loc[0] - self.gps_location[0]
249 dy = loc[1] - self.gps_location[1]
250 distance = math.sqrt(dx*dx + dy*dy)
251 heading = math.fmod(2*math.pi + math.atan2(dx, dy), 2*math.pi) * (180.0/math.pi)
252 return heading, distance
253
254 def nearest_peer(self, range=2.0):
255 """Locate the nearest peer (as reported by radio) within the given range.
256 Returns either None or a dictionary with the location record."""
257 result = None
258 best = math.inf
259 for name in self.peers:
260 record = self.peers[name]
261 heading, dist = self.peer_heading_distance(record)
262 if dist < best:
263 result = record
264 best = dist
265 return result
266
267 #================================================================
268 def poll_wandering_activity(self):
269 """State machine update function to aimlessly wander around the world."""
270
271 # This machine always transitions at regular intervals.
272 timer_expired = False
273 if self.state_timer < 0:
274 self.state_timer += 3000
275 timer_expired = True
276
277 # Evaluate the side-effects and transition rules for each state.
278 if self.state_index == 0:
279 print("Init state, entering cycle.")
280 self.state_index = 1
281
282 elif self.state_index == 1:
283 self.go_forward(0.2)
284
285 if timer_expired:
286 self.state_index += 1
287
288 elif self.state_index == 2:
289 self.go_heading(self.target_heading)
290
291 if timer_expired:
292 self.state_index += 1
293
294 elif self.state_index == 3:
295 self.go_still()
296
297 if timer_expired:
298 self.state_index += 1
299
300 elif self.state_index == 4:
301 self.go_rotate(math.pi / 6)
302
303 if timer_expired:
304 self.state_index = 1
305 self.target_heading = random.randint(0,360)
306
307 else:
308 print("%s: invalid state, resetting." % (self.robot_name))
309 self.state_index = 0
310
311 if timer_expired:
312 print("%s: transitioning to state %s" % (self.robot_name, self.state_index))
313
314 return
315
316 #================================================================
317 def poll_following_activity(self):
318 """State machine update function to always move toward the nearest peer."""
319
320 if self.state_timer < 0:
321 self.state_timer += 1000
322
323 # periodically test if there is a nearby peer
324 nearest = self.nearest_peer()
325 if nearest is None:
326 self.state_index = 1
327 else:
328 self.state_index = 2
329 heading, distance = self.peer_heading_distance(nearest)
330 self.target_heading = heading
331 self.target_velocity = 0.1 * distance
332 print("%s: peer to follow at %f deg, %f meters" % (self.robot_name, heading, distance))
333
334 # always either stabilize, turn, or move
335 if self.state_index < 1:
336 self.go_still()
337
338 else:
339 heading_err = self.heading_difference(self.target_heading, self.heading)
340 if abs(heading_err) > 20.0 or abs(self.target_velocity) < 0.05:
341 self.go_heading(self.target_heading)
342 else:
343 self.go_forward(self.target_velocity)
344
345 #================================================================
346 def run(self):
347 # Run loop to execute a periodic script until the simulation quits.
348 # If the controller returns -1, the simulator is quitting.
349 while self.step(EVENT_LOOP_DT) != -1:
350 # Read simulator clock time.
351 self.sim_time = self.getTime()
352
353 # Read sensor values.
354 self.poll_sensors()
355
356 # Check the radio and/or network.
357 self.poll_communication()
358
359 # Update the activity state machine.
360 self.state_timer -= EVENT_LOOP_DT
361
362 # This will run some open-loop motion. One robot will be the leader, the rest will follow.
363 mode = self.getCustomData()
364 if mode == 'leader':
365 self.poll_wandering_activity()
366 else:
367 self.poll_following_activity()
368
369
370################################################################
371# Start the script.
372robot = Wobbly()
373robot.run()