Skip to content

Commit 79089cc

Browse files
authored
Merge pull request #10 from JuliaComputing/fbc/3d-yaw-steering
Steerable 3D bot: cascade balance/position control + yaw controller with control mixer
2 parents 89d5512 + f73b29f commit 79089cc

23 files changed

Lines changed: 980 additions & 102 deletions

README.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@ Plant models:
1414
- `DyadBot3D`: three-dimensional model with two individually spinning wheels
1515
with slip-based ground contact, allowing the body to tilt.
1616
- `RollingDyadBot3D`: three-dimensional model with an ideal no-slip rolling
17-
wheel axle (`MultibodyComponents.RollingWheelSet`). Same interface and
18-
parameters as `PlanarDyadBot`; for motion in the vertical plane it is
19-
dynamically equivalent to the planar model.
17+
wheel axle (`MultibodyComponents.RollingWheelSet`) and individually driven
18+
wheels, so the robot can be steered by differential torque. Outputs
19+
odometric path position/velocity, tilt angle/rate and yaw (heading)
20+
angle/rate. With equal torque on both wheels it is dynamically equivalent
21+
to `PlanarDyadBot`.
2022

2123
Closed-loop models around the planar plant:
2224

@@ -37,6 +39,11 @@ Closed-loop models around the 3D plant:
3739
`AngleController` as `AngleControlledDyadBot`; with in-plane motion the
3840
closed-loop response is identical to the planar model
3941
(`test/test_stabilization.jl` verifies this).
42+
- `YawControlledDyadBot3D`: `RollingDyadBot3D` with the balance/position
43+
cascade (`CascadeController`, position reference held at zero) and a
44+
separate `YawController` tracking a heading reference that steps at t = 5;
45+
a `ControlMixer` combines drive and yaw torque into the two wheel torque
46+
commands, so the robot spins in place to the new heading.
4047

4148
## Controller tuning scripts (`scripts/`)
4249

assets/ControlMixer.svg

Lines changed: 21 additions & 0 deletions
Loading

assets/YawController.svg

Lines changed: 18 additions & 0 deletions
Loading

dyad/cascade_ff.dyad

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,7 @@ metadata {
275275
}
276276
},
277277
"zoh": {
278-
"Dyad": {"placement": {"diagram": {"x1": 840, "y1": 450, "x2": 920, "y2": 550}}}
278+
"Dyad": {"placement": {"diagram": {"x1": 830, "y1": 450, "x2": 930, "y2": 550}}}
279279
},
280280
"clock": {
281281
"Dyad": {"placement": {"diagram": {"x1": 410, "y1": 790, "x2": 510, "y2": 890}}}

dyad/closed_loop.dyad

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,8 @@ component AngleControlledDyadBot3D
242242
world = MultibodyComponents.World(g = 9.82, nominal_length = 0.1) {^world}
243243
plant = RollingDyadBot3D(phi0 = phi0) {^plant}
244244
controller = AngleController(k_angle = k_angle, Ti_angle = Ti_angle, Td_angle = Td_angle) {^controller}
245+
"Splits the motor torque equally between the two wheels"
246+
gain_half = BlockComponents.Math.Gain(k = 0.5) {^gain_half}
245247
"Proportional gain of the angle controller"
246248
parameter k_angle::Real = 0.487401
247249
"Integrator time constant of the angle controller"
@@ -251,7 +253,8 @@ component AngleControlledDyadBot3D
251253
"Initial tilt angle of the body"
252254
parameter phi0::Angle = 0.1
253255
relations
254-
connect(controller.torque, plant.torque) {^id4}
256+
connect(controller.torque, gain_half.u) {^id4}
257+
connect(gain_half.y, plant.torque_left, plant.torque_right) {^id6}
255258
connect(plant.theta, controller.measurement) {^id5}
256259
metadata {
257260
"Dyad": {
@@ -278,9 +281,18 @@ metadata {
278281
"controller": {
279282
"Dyad": {"placement": {"diagram": {"x1": 270, "y1": 420, "x2": 420, "y2": 570}}}
280283
},
281-
"id4": {
284+
"gain_half": {
285+
"Dyad": {"placement": {"diagram": {"x1": 450, "y1": 455, "x2": 530, "y2": 535}}}
286+
},
287+
"id4": {"Dyad": {"edges": [{"S": 1, "M": [], "E": 2}]}},
288+
"id6": {
282289
"Dyad": {
283-
"edges": [{"S": 1, "M": [{"x": 503.75, "y": 495}, {"x": 503.75, "y": 500}], "E": 2}]
290+
"edges": [
291+
{"S": 1, "M": [], "E": -1},
292+
{"S": -1, "M": [{"x": 545, "y": 460}], "E": 2},
293+
{"S": 3, "M": [{"x": 545, "y": 530}], "E": -1}
294+
],
295+
"junctions": [{"x": 545, "y": 495}]
284296
}
285297
},
286298
"id5": {
@@ -745,7 +757,7 @@ metadata {
745757
"Dyad": {"placement": {"diagram": {"x1": 60, "y1": 450, "x2": 160, "y2": 550}}}
746758
},
747759
"controller": {
748-
"Dyad": {"placement": {"diagram": {"x1": 240, "y1": 410, "x2": 480, "y2": 600}}}
760+
"Dyad": {"placement": {"diagram": {"x1": 265, "y1": 410, "x2": 455, "y2": 600}}}
749761
},
750762
"id7": {"Dyad": {"edges": [{"S": 1, "M": [], "E": 2}]}},
751763
"id8": {"Dyad": {"edges": [{"S": 1, "M": [], "E": 2}]}},
@@ -856,7 +868,7 @@ metadata {
856868
"Dyad": {"placement": {"diagram": {"x1": 120, "y1": 430, "x2": 220, "y2": 530}}}
857869
},
858870
"controller": {
859-
"Dyad": {"placement": {"diagram": {"x1": 280, "y1": 390, "x2": 510, "y2": 570}}}
871+
"Dyad": {"placement": {"diagram": {"x1": 305, "y1": 390, "x2": 485, "y2": 570}}}
860872
},
861873
"id7": {"Dyad": {"edges": [{"S": 1, "M": [], "E": 2}]}},
862874
"id8": {"Dyad": {"edges": [{"S": 1, "M": [], "E": 2}]}},

dyad/lqg.dyad

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,7 @@ metadata {
288288
"mux": {
289289
"Dyad": {
290290
"placement": {
291-
"diagram": {"iconName": "default", "x1": 90, "y1": 450, "x2": 170, "y2": 550, "rot": 0}
291+
"diagram": {"iconName": "default", "x1": 80, "y1": 450, "x2": 180, "y2": 550, "rot": 0}
292292
},
293293
"tags": []
294294
}

dyad/segway_3d.dyad

Lines changed: 84 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -192,24 +192,27 @@ end
192192

193193
"""
194194
Three-dimensional model of a two-wheeled balancing robot with ideal rolling
195-
contact.
195+
contact and individually driven wheels.
196196

197197
The two wheels form an ideal no-slip rolling axle
198198
(`MultibodyComponents.RollingWheelSet`, which resolves the kinematic loop
199199
through the two wheels internally), and the inverted-pendulum body is mounted
200-
at the center of the axle through a revolute joint about the axle axis. The
201-
motor torque acts between the body and the two wheels: half the commanded
202-
torque drives each wheel and the reaction acts on the body. Viscous motor
203-
friction acts between the body and each wheel in the same split.
200+
at the center of the axle through a revolute joint about the axle axis. Each
201+
wheel has its own motor torque input, so the robot can be steered by applying
202+
a differential torque; each motor torque acts between the body and its wheel.
204203

205-
For motion in the vertical plane the model is dynamically equivalent to
206-
`PlanarDyadBot`: the two wheels share the planar model's wheel mass `m`, spin
207-
inertia `Iw` and motor friction `d`, and the body parameters are identical.
204+
With the same torque applied to both wheels the robot moves in the vertical
205+
plane and is dynamically equivalent to `PlanarDyadBot`: the two wheels share
206+
the planar model's wheel mass `m`, spin inertia `Iw` and motor friction `d`,
207+
and the body parameters are identical.
208208

209-
Inputs and outputs (same interface as `PlanarDyadBot`):
210-
- `torque`: torque applied by the motor between body and wheels
211-
- `x`, `x_dot`: position and velocity of the wheel axis along the ground
209+
Inputs and outputs:
210+
- `torque_left`, `torque_right`: motor torques applied between body and each wheel
211+
- `x`, `x_dot`: odometric path position and velocity (average wheel arc
212+
length; equals the distance driven along the — possibly curved — path, and
213+
coincides with the world-frame x coordinate when driving straight)
212214
- `theta`, `theta_dot`: tilt angle and tilt rate of the body (zero when upright)
215+
- `yaw`, `yaw_dot`: heading angle and rate (positive counterclockwise seen from above)
213216

214217
The model must be accompanied by a `MultibodyComponents.World` component in
215218
the top level of the enclosing model.
@@ -219,22 +222,28 @@ component RollingDyadBot3D
219222
"Body tilt joint about the axle axis"
220223
pitch = MultibodyComponents.Revolute(n = [0, 0, 1]) {^pitch}
221224
body = MultibodyComponents.BodyShape(color = [0.2, 0.2, 0.2, 0.9], m = M, r = [0, 2 * L, 0], r_cm = [0, L, 0], I_11 = Ib, I_22 = Ib / 10, I_33 = Ib, radius = 0.03) {^body}
222-
"Splits the motor torque equally between the two wheels"
223-
gain_half = BlockComponents.Math.Gain(k = 0.5) {^gain_half}
224-
torque_left = RotationalComponents.Sources.TorqueSource() {^torque_left}
225-
torque_right = RotationalComponents.Sources.TorqueSource() {^torque_right}
226-
damper_left = RotationalComponents.Components.Damper(d = d / 2) {^damper_left}
227-
damper_right = RotationalComponents.Components.Damper(d = d / 2) {^damper_right}
228-
"Motor torque command"
229-
torque = Dyad.RealInput() {^torque}
230-
"Position of the wheel axis"
225+
# wheels.axis1 is the wheel on the +z side of the axle, which is the right
226+
# wheel when facing the forward x direction with y up
227+
torquesource1 = RotationalComponents.Sources.TorqueSource() {^torquesource1}
228+
torquesource2 = RotationalComponents.Sources.TorqueSource() {^torquesource2}
229+
damper1 = RotationalComponents.Components.Damper(d = d / 2) {^damper1}
230+
damper2 = RotationalComponents.Components.Damper(d = d / 2) {^damper2}
231+
"Left-wheel motor torque command"
232+
torque_left = Dyad.RealInput() {^torque_left}
233+
"Right-wheel motor torque command"
234+
torque_right = Dyad.RealInput() {^torque_right}
235+
"Odometric path position (average wheel arc length)"
231236
x = Dyad.RealOutput() {^x}
232237
"Body tilt angle"
233238
theta = Dyad.RealOutput() {^theta}
234-
"Velocity of the wheel axis"
239+
"Odometric path velocity"
235240
x_dot = Dyad.RealOutput() {^x_dot}
236241
"Body tilt rate"
237242
theta_dot = Dyad.RealOutput() {^theta_dot}
243+
"Yaw (heading) angle"
244+
yaw = Dyad.RealOutput() {^yaw}
245+
"Yaw rate"
246+
yaw_dot = Dyad.RealOutput() {^yaw_dot}
238247
"Body mass"
239248
parameter M::Mass = 0.1
240249
"Total wheel mass (split between the two wheels)"
@@ -256,19 +265,23 @@ component RollingDyadBot3D
256265
relations
257266
initial pitch.phi = phi0
258267
initial pitch.w = 0
259-
x = wheels.x
268+
# Wheel spin and forward position have opposite signs (positive wheel torque
269+
# drives the robot toward negative x, like in PlanarDyadBot)
270+
x = -R * (wheels.theta1 + wheels.theta2) / 2
271+
x_dot = -R * (wheels.der_theta1 + wheels.der_theta2) / 2
260272
theta = pitch.phi
261-
x_dot = der(wheels.x)
262273
theta_dot = pitch.w
274+
yaw = wheels.phi
275+
yaw_dot = der(wheels.phi)
263276
connect(body.frame_a, pitch.frame_b) {^id14}
264277
connect(pitch.frame_a, wheels.frame_middle) {^id15}
265-
connect(torque_left.spline, wheels.axis1) {^id16}
266-
connect(torque_right.spline, wheels.axis2) {^id17}
267-
connect(damper_right.spline_b, wheels.axis2) {^id18}
268-
connect(damper_left.spline_b, wheels.axis1) {^id19}
269-
connect(torque_left.tau, torque_right.tau, gain_half.y) {^id20}
270-
connect(torque, gain_half.u) {^id21}
271-
connect(pitch.axis, torque_left.support, damper_left.spline_a, torque_right.support, damper_right.spline_a) {^id22}
278+
connect(torquesource1.spline, wheels.axis1) {^id16}
279+
connect(torquesource2.spline, wheels.axis2) {^id17}
280+
connect(damper2.spline_b, wheels.axis2) {^id18}
281+
connect(damper1.spline_b, wheels.axis1) {^id19}
282+
connect(torque_right, torquesource1.tau) {^id20}
283+
connect(torque_left, torquesource2.tau) {^id21}
284+
connect(pitch.axis, torquesource1.support, damper1.spline_a, torquesource2.support, damper2.spline_a) {^id22}
272285
metadata {
273286
"Dyad": {"icons": {"default": "dyad://DyadBotComponents/DyadBot3D.svg"}},
274287
"_links": {
@@ -290,31 +303,34 @@ metadata {
290303
"placement": {"diagram": {"x1": 350, "y1": -10, "x2": 650, "y2": 290, "rot": 270}}
291304
}
292305
},
293-
"gain_half": {
294-
"Dyad": {
295-
"placement": {"diagram": {"x1": 70, "y1": 650, "x2": 150, "y2": 730, "rot": 90}}
296-
}
297-
},
298-
"torque_left": {
306+
"torquesource1": {
299307
"Dyad": {"placement": {"diagram": {"x1": 240, "y1": 670, "x2": 340, "y2": 570}}}
300308
},
301-
"torque_right": {
309+
"torquesource2": {
302310
"Dyad": {"placement": {"diagram": {"x1": 770, "y1": 670, "x2": 670, "y2": 570}}}
303311
},
304-
"damper_left": {
312+
"damper1": {
305313
"Dyad": {
306314
"placement": {"diagram": {"x1": 350, "y1": 490, "x2": 450, "y2": 590, "rot": 90}}
307315
}
308316
},
309-
"damper_right": {
317+
"damper2": {
310318
"Dyad": {
311319
"placement": {"diagram": {"x1": 650, "y1": 490, "x2": 550, "y2": 590, "rot": 270}}
312320
}
313321
},
314-
"torque": {
322+
"torque_right": {
323+
"Dyad": {
324+
"placement": {
325+
"diagram": {"iconName": "default", "x1": 40, "y1": 580, "x2": 120, "y2": 660, "rot": 0}
326+
},
327+
"tags": []
328+
}
329+
},
330+
"torque_left": {
315331
"Dyad": {
316332
"placement": {
317-
"diagram": {"iconName": "default", "x1": 20, "y1": 460, "x2": 100, "y2": 540, "rot": 0}
333+
"diagram": {"iconName": "default", "x1": 40, "y1": 720, "x2": 120, "y2": 800, "rot": 0}
318334
},
319335
"tags": []
320336
}
@@ -351,6 +367,22 @@ metadata {
351367
"tags": []
352368
}
353369
},
370+
"yaw": {
371+
"Dyad": {
372+
"placement": {
373+
"diagram": {"iconName": "default", "x1": 880, "y1": 780, "x2": 960, "y2": 860, "rot": 0}
374+
},
375+
"tags": []
376+
}
377+
},
378+
"yaw_dot": {
379+
"Dyad": {
380+
"placement": {
381+
"diagram": {"iconName": "default", "x1": 880, "y1": 900, "x2": 960, "y2": 980, "rot": 0}
382+
},
383+
"tags": []
384+
}
385+
},
354386
"id14": {"Dyad": {"edges": [{"S": 1, "M": [], "E": 2}]}},
355387
"id15": {
356388
"Dyad": {
@@ -372,21 +404,23 @@ metadata {
372404
"id17": {"Dyad": {"edges": [{"S": 1, "M": [], "E": 2}]}},
373405
"id18": {"Dyad": {"edges": [{"S": 1, "M": [], "E": 2}]}},
374406
"id19": {"Dyad": {"edges": [{"S": 1, "M": [], "E": 2}]}},
375-
"id20": {
407+
"id20": {"Dyad": {"edges": [{"S": 1, "M": [], "E": 2}]}},
408+
"id21": {
376409
"Dyad": {
377410
"edges": [
378-
{"S": 1, "M": [{"x": 210, "y": 620}], "E": -1},
379411
{
380-
"S": -1,
381-
"M": [{"x": 210, "y": 880}, {"x": 800, "y": 880}, {"x": 800, "y": 620}],
412+
"S": 1,
413+
"M": [
414+
{"x": 160, "y": 760},
415+
{"x": 160, "y": 900},
416+
{"x": 820, "y": 900},
417+
{"x": 820, "y": 620}
418+
],
382419
"E": 2
383-
},
384-
{"S": 3, "M": [{"x": 110, "y": 880}], "E": -1}
385-
],
386-
"junctions": [{"x": 210, "y": 880}]
420+
}
421+
]
387422
}
388423
},
389-
"id21": {"Dyad": {"edges": [{"S": 1, "M": [{"x": 110, "y": 500}], "E": 2}]}},
390424
"id22": {
391425
"Dyad": {
392426
"edges": [

0 commit comments

Comments
 (0)