Skip to content

Commit 56ed1ec

Browse files
baggepinnenclaude
andcommitted
Add closed-loop 3D model with angle controller and planar-equivalence test
Add RollingDyadBot3D, a 3D plant with an ideal no-slip rolling wheel axle (MultibodyComponents.RollingWheelSet) and the same interface and parameters as PlanarDyadBot, plus AngleControlledDyadBot3D closing the loop with the existing AngleController. For motion in the vertical plane the 3D model is dynamically equivalent to the planar one; the new test verifies that the closed-loop theta and x trajectories of the two models agree. The plant uses a plain Body instead of BodyShape for the pendulum body, since the 3D BodyShape applies the r_cm offset twice (once through translation_cm and once through the inner Body), placing the center of mass at twice the requested offset. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent fce9ade commit 56ed1ec

13 files changed

Lines changed: 655 additions & 0 deletions

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ Plant models:
1313
position/velocity/tilt/tilt-rate out.
1414
- `DyadBot3D`: three-dimensional model with two individually spinning wheels
1515
with slip-based ground contact, allowing the body to tilt.
16+
- `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.
1620

1721
Closed-loop models around the planar plant:
1822

@@ -27,6 +31,13 @@ Closed-loop models around the planar plant:
2731
integral action (state-space system loaded from `data/lqg_*.csv`).
2832
- `LQGTuningDyadBot`: analysis model used by the LQG design script.
2933

34+
Closed-loop models around the 3D plant:
35+
36+
- `AngleControlledDyadBot3D`: `RollingDyadBot3D` stabilized by the same
37+
`AngleController` as `AngleControlledDyadBot`; with in-plane motion the
38+
closed-loop response is identical to the planar model
39+
(`test/test_stabilization.jl` verifies this).
40+
3041
## Controller tuning scripts (`scripts/`)
3142

3243
Each script activates the `scripts/` environment and can be run directly:

dyad/closed_loop.dyad

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,79 @@ metadata {
230230
}
231231
end
232232

233+
"""
234+
Three-dimensional version of `AngleControlledDyadBot`: the ideal-rolling 3D
235+
robot `RollingDyadBot3D` stabilized by the same `AngleController` with the
236+
same gains. The robot starts from a tilted initial configuration in the
237+
vertical plane and the controller recovers the upright pose; since the motion
238+
stays in the plane, the closed-loop response is identical to that of the
239+
planar model (verified by `test/test_stabilization.jl`).
240+
"""
241+
component AngleControlledDyadBot3D
242+
world = MultibodyComponents.World(g = 9.82, nominal_length = 0.1) {^world}
243+
plant = RollingDyadBot3D(phi0 = phi0) {^plant}
244+
controller = AngleController(k_angle = k_angle, Ti_angle = Ti_angle, Td_angle = Td_angle) {^controller}
245+
"Proportional gain of the angle controller"
246+
parameter k_angle::Real = 0.487401
247+
"Integrator time constant of the angle controller"
248+
parameter Ti_angle::Time = 0.0587352
249+
"Derivative time constant of the angle controller"
250+
parameter Td_angle::Time = 0.0420526
251+
"Initial tilt angle of the body"
252+
parameter phi0::Angle = 0.1
253+
relations
254+
connect(controller.torque, plant.torque) {^id4}
255+
connect(plant.theta, controller.measurement) {^id5}
256+
metadata {
257+
"Dyad": {
258+
"tests": {"case1": {"stop": 5}},
259+
"icons": {"default": "dyad://DyadBotComponents/DyadBot3D.svg"}
260+
},
261+
"_links": {
262+
"world": {
263+
"Dyad": {
264+
"placement": {
265+
"diagram": {"iconName": "default", "x1": 200, "y1": 250, "x2": 100, "y2": 150, "rot": 180}
266+
},
267+
"tags": []
268+
}
269+
},
270+
"plant": {
271+
"Dyad": {
272+
"placement": {
273+
"diagram": {"iconName": "default", "x1": 560, "y1": 400, "x2": 760, "y2": 600, "rot": 0}
274+
},
275+
"tags": []
276+
}
277+
},
278+
"controller": {
279+
"Dyad": {"placement": {"diagram": {"x1": 270, "y1": 420, "x2": 420, "y2": 570}}}
280+
},
281+
"id4": {
282+
"Dyad": {
283+
"edges": [{"S": 1, "M": [{"x": 503.75, "y": 495}, {"x": 503.75, "y": 500}], "E": 2}]
284+
}
285+
},
286+
"id5": {
287+
"Dyad": {
288+
"edges": [
289+
{
290+
"S": 1,
291+
"M": [
292+
{"x": 770, "y": 492},
293+
{"x": 770, "y": 660},
294+
{"x": 220, "y": 660},
295+
{"x": 220, "y": 495}
296+
],
297+
"E": 2
298+
}
299+
]
300+
}
301+
}
302+
}
303+
}
304+
end
305+
233306
"""
234307
Discrete-time version of `AngleControlledDyadBot`. Identical to the continuous
235308
model except that the control system is a sampled-data `DiscreteAngleController`

dyad/segway_3d.dyad

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,4 +188,211 @@ metadata {
188188
}
189189
}
190190
}
191+
end
192+
193+
"""
194+
Three-dimensional model of a two-wheeled balancing robot with ideal rolling
195+
contact.
196+
197+
The two wheels form an ideal no-slip rolling axle
198+
(`MultibodyComponents.RollingWheelSet`, which resolves the kinematic loop
199+
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.
204+
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.
208+
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
212+
- `theta`, `theta_dot`: tilt angle and tilt rate of the body (zero when upright)
213+
214+
The model must be accompanied by a `MultibodyComponents.World` component in
215+
the top level of the enclosing model.
216+
"""
217+
component RollingDyadBot3D
218+
wheels = MultibodyComponents.RollingWheelSet(radius = R, m_wheel = m / 2, I_axis = Iw / 2, I_long = Iw / 4, track = track) {^wheels}
219+
"Body tilt joint about the axle axis"
220+
pitch = MultibodyComponents.Revolute(n = [0, 0, 1]) {^pitch}
221+
# A plain Body is used instead of BodyShape: BodyShape places its inner Body
222+
# at translation_cm.frame_b and additionally passes r_cm on to it, so the
223+
# center of mass would end up at twice the requested offset.
224+
body = MultibodyComponents.Body(color = [0.2, 0.2, 0.2, 0.9], m = M, r_cm = [0, L, 0], I_11 = Ib, I_22 = Ib / 10, I_33 = Ib) {^body}
225+
"Splits the motor torque equally between the two wheels"
226+
gain_half = BlockComponents.Math.Gain(k = 0.5) {^gain_half}
227+
torque_left = RotationalComponents.Sources.TorqueSource() {^torque_left}
228+
torque_right = RotationalComponents.Sources.TorqueSource() {^torque_right}
229+
damper_left = RotationalComponents.Components.Damper(d = d / 2) {^damper_left}
230+
damper_right = RotationalComponents.Components.Damper(d = d / 2) {^damper_right}
231+
"Motor torque command"
232+
torque = Dyad.RealInput() {^torque}
233+
"Position of the wheel axis"
234+
x = Dyad.RealOutput() {^x}
235+
"Body tilt angle"
236+
theta = Dyad.RealOutput() {^theta}
237+
"Velocity of the wheel axis"
238+
x_dot = Dyad.RealOutput() {^x_dot}
239+
"Body tilt rate"
240+
theta_dot = Dyad.RealOutput() {^theta_dot}
241+
"Body mass"
242+
parameter M::Mass = 0.1
243+
"Total wheel mass (split between the two wheels)"
244+
parameter m::Mass = 0.05
245+
"Wheel radius"
246+
parameter R::Length = 0.04
247+
"Distance from wheel axis to body center of mass"
248+
parameter L::Length = 0.08
249+
"Body moment of inertia about the axle axis"
250+
parameter Ib::Inertia = 2.5e-4
251+
"Total wheel + motor moment of inertia about the spin axis (split between the two wheels)"
252+
parameter Iw::Inertia = 1e-4
253+
"Motor viscous friction (split between the two wheels)"
254+
parameter d::RotationalDampingConstant = 0.0001
255+
"Initial tilt angle of the body"
256+
parameter phi0::Angle = 0.0
257+
"Distance between the wheels"
258+
parameter track::Length = 0.13
259+
relations
260+
initial pitch.phi = phi0
261+
initial pitch.w = 0
262+
connect(wheels.frame_middle, pitch.frame_a) {^id1}
263+
connect(pitch.frame_b, body.frame_a) {^id2}
264+
connect(torque, gain_half.u) {^id3}
265+
connect(gain_half.y, torque_left.tau, torque_right.tau) {^id4}
266+
connect(torque_left.spline, damper_left.spline_a, wheels.axis1) {^id5}
267+
connect(torque_right.spline, damper_right.spline_a, wheels.axis2) {^id6}
268+
connect(torque_left.support, torque_right.support, damper_left.spline_b, damper_right.spline_b, pitch.axis) {^id7}
269+
x = wheels.x
270+
theta = pitch.phi
271+
x_dot = der(wheels.x)
272+
theta_dot = pitch.w
273+
metadata {
274+
"Dyad": {"icons": {"default": "dyad://DyadBotComponents/DyadBot3D.svg"}},
275+
"_links": {
276+
"wheels": {
277+
"Dyad": {
278+
"placement": {
279+
"diagram": {"iconName": "default", "x1": 400, "y1": 620, "x2": 600, "y2": 820, "rot": 0}
280+
},
281+
"tags": []
282+
}
283+
},
284+
"pitch": {
285+
"Dyad": {"placement": {"diagram": {"x1": 450, "y1": 480, "x2": 550, "y2": 580}}}
286+
},
287+
"body": {
288+
"Dyad": {
289+
"placement": {"diagram": {"x1": 350, "y1": 130, "x2": 650, "y2": 430, "rot": 270}}
290+
}
291+
},
292+
"gain_half": {
293+
"Dyad": {"placement": {"diagram": {"x1": 180, "y1": 460, "x2": 260, "y2": 540}}}
294+
},
295+
"torque_left": {
296+
"Dyad": {"placement": {"diagram": {"x1": 150, "y1": 620, "x2": 250, "y2": 720}}}
297+
},
298+
"torque_right": {
299+
"Dyad": {"placement": {"diagram": {"x1": 850, "y1": 620, "x2": 750, "y2": 720}}}
300+
},
301+
"damper_left": {
302+
"Dyad": {"placement": {"diagram": {"x1": 150, "y1": 760, "x2": 250, "y2": 860}}}
303+
},
304+
"damper_right": {
305+
"Dyad": {"placement": {"diagram": {"x1": 850, "y1": 760, "x2": 750, "y2": 860}}}
306+
},
307+
"torque": {
308+
"Dyad": {
309+
"placement": {
310+
"diagram": {"iconName": "default", "x1": 20, "y1": 460, "x2": 100, "y2": 540, "rot": 0}
311+
},
312+
"tags": []
313+
}
314+
},
315+
"x": {
316+
"Dyad": {
317+
"placement": {
318+
"diagram": {"iconName": "default", "x1": 880, "y1": 300, "x2": 960, "y2": 380, "rot": 0}
319+
},
320+
"tags": []
321+
}
322+
},
323+
"theta": {
324+
"Dyad": {
325+
"placement": {
326+
"diagram": {"iconName": "default", "x1": 880, "y1": 420, "x2": 960, "y2": 500, "rot": 0}
327+
},
328+
"tags": []
329+
}
330+
},
331+
"x_dot": {
332+
"Dyad": {
333+
"placement": {
334+
"diagram": {"iconName": "default", "x1": 880, "y1": 540, "x2": 960, "y2": 620, "rot": 0}
335+
},
336+
"tags": []
337+
}
338+
},
339+
"theta_dot": {
340+
"Dyad": {
341+
"placement": {
342+
"diagram": {"iconName": "default", "x1": 880, "y1": 660, "x2": 960, "y2": 740, "rot": 0}
343+
},
344+
"tags": []
345+
}
346+
},
347+
"id1": {"Dyad": {"edges": [{"S": 1, "M": [], "E": 2}], "renderStyle": "standard"}},
348+
"id2": {"Dyad": {"edges": [{"S": 1, "M": [], "E": 2}], "renderStyle": "standard"}},
349+
"id3": {"Dyad": {"edges": [{"S": 1, "M": [], "E": 2}], "renderStyle": "standard"}},
350+
"id4": {
351+
"Dyad": {
352+
"edges": [
353+
{"S": 1, "M": [], "E": -1},
354+
{"S": -1, "M": [{"x": 200, "y": 670}], "E": 2},
355+
{"S": 3, "M": [{"x": 800, "y": 670}], "E": -1}
356+
],
357+
"junctions": [{"x": 300, "y": 560}],
358+
"renderStyle": "standard"
359+
}
360+
},
361+
"id5": {
362+
"Dyad": {
363+
"edges": [
364+
{"S": 1, "M": [], "E": -1},
365+
{"S": -1, "M": [], "E": 2},
366+
{"S": 3, "M": [], "E": -1}
367+
],
368+
"junctions": [{"x": 350, "y": 700}],
369+
"renderStyle": "standard"
370+
}
371+
},
372+
"id6": {
373+
"Dyad": {
374+
"edges": [
375+
{"S": 1, "M": [], "E": -1},
376+
{"S": -1, "M": [], "E": 2},
377+
{"S": 3, "M": [], "E": -1}
378+
],
379+
"junctions": [{"x": 650, "y": 700}],
380+
"renderStyle": "standard"
381+
}
382+
},
383+
"id7": {
384+
"Dyad": {
385+
"edges": [
386+
{"S": 1, "M": [], "E": -1},
387+
{"S": -1, "M": [], "E": 2},
388+
{"S": 3, "M": [], "E": -1},
389+
{"S": 4, "M": [], "E": -1},
390+
{"S": 5, "M": [], "E": -1}
391+
],
392+
"junctions": [{"x": 500, "y": 900}],
393+
"renderStyle": "standard"
394+
}
395+
}
396+
}
397+
}
191398
end

0 commit comments

Comments
 (0)