Skip to content

Commit 69311bd

Browse files
committed
Add a python example showing ruckig use
This demonstrates how limited jerk trajectories can be implemented, along with how multiple devices can truly be synchronized to complete at the same time, unlike the approximate method used by `moteus.move_to`.
1 parent 697e9ae commit 69311bd

File tree

1 file changed

+283
-0
lines changed

1 file changed

+283
-0
lines changed
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
#!/usr/bin/python3 -B
2+
3+
# Copyright 2026 mjbots Robotic Systems, LLC. info@mjbots.com
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
"""Synchronized multi-joint motion using ruckig for trajectory generation.
18+
19+
This example demonstrates true synchronized multi-joint motion using the
20+
ruckig library for jerk-limited trajectory generation. All joints arrive
21+
at their targets simultaneously with smooth, coordinated motion profiles.
22+
23+
Each servo moves a different distance to demonstrate time synchronization:
24+
- Servo 1: moves 0.25 rev (1x scale)
25+
- Servo 2: moves 0.50 rev (2x scale)
26+
- Servo 3: moves 0.75 rev (3x scale)
27+
- etc.
28+
29+
Despite the different distances, ruckig ensures all servos start and finish
30+
at the same time by computing appropriate velocity/acceleration profiles.
31+
32+
This is the recommended approach when you need:
33+
- Smooth, jerk-limited motion profiles
34+
- Precise synchronization across multiple joints
35+
- Complex coordinated motion paths
36+
37+
Requirements:
38+
pip install ruckig
39+
40+
Usage:
41+
python3 ruckig_multiservo.py -t 1,2
42+
python3 ruckig_multiservo.py --targets=1,2,3 --max-velocity=0.5 --max-accel=2.0
43+
"""
44+
45+
import argparse
46+
import asyncio
47+
import time
48+
49+
import moteus
50+
51+
try:
52+
from ruckig import Ruckig, InputParameter, OutputParameter, Result
53+
except ImportError:
54+
print("This example requires the ruckig library.")
55+
print("Install with: pip install ruckig")
56+
raise SystemExit(1)
57+
58+
59+
def parse_args():
60+
parser = argparse.ArgumentParser(
61+
description='Synchronized multi-joint motion using ruckig')
62+
parser.add_argument(
63+
'-t', '--targets', type=str, default='1,2',
64+
help='Comma-separated list of servo IDs (default: 1,2)')
65+
parser.add_argument(
66+
'--max-velocity', type=float, default=0.5,
67+
help='Maximum velocity in rev/s (default: 0.5)')
68+
parser.add_argument(
69+
'--max-accel', type=float, default=2.0,
70+
help='Maximum acceleration in rev/s^2 (default: 2.0)')
71+
parser.add_argument(
72+
'--max-jerk', type=float, default=10.0,
73+
help='Maximum jerk in rev/s^3 (default: 10.0)')
74+
parser.add_argument(
75+
'--cycle-time', type=float, default=0.01,
76+
help='Cycle time in seconds (default: 0.01)')
77+
parser.add_argument(
78+
'--base-amplitude', type=float, default=0.25,
79+
help='Base motion amplitude in revolutions (default: 0.25)')
80+
moteus.make_transport_args(parser)
81+
return parser.parse_args()
82+
83+
84+
class SynchronizedMotion:
85+
"""Coordinates synchronized motion of multiple servos using ruckig."""
86+
87+
def __init__(self, args):
88+
self.args = args
89+
self.servo_ids = [int(x.strip()) for x in args.targets.split(',')]
90+
self.num_dofs = len(self.servo_ids)
91+
92+
self.transport = moteus.get_singleton_transport(args)
93+
self.controllers = {
94+
servo_id: moteus.Controller(id=servo_id, transport=self.transport)
95+
for servo_id in self.servo_ids
96+
}
97+
98+
self.otg = Ruckig(self.num_dofs, args.cycle_time)
99+
self.inp = InputParameter(self.num_dofs)
100+
self.out = OutputParameter(self.num_dofs)
101+
102+
self.initial_positions = None
103+
self.waypoints = []
104+
self.current_waypoint = 0
105+
106+
async def initialize(self):
107+
"""Stop servos, query positions, and set up trajectory generator."""
108+
self._setup_kinematic_limits()
109+
await self._query_initial_positions()
110+
self._build_waypoints()
111+
self._set_initial_trajectory_state()
112+
113+
def _setup_kinematic_limits(self):
114+
# Scale velocity per DOF to make them different.
115+
self.inp.max_velocity = [
116+
self.args.max_velocity * (i + 1) for i in range(self.num_dofs)
117+
]
118+
self.inp.max_acceleration = [self.args.max_accel] * self.num_dofs
119+
self.inp.max_jerk = [self.args.max_jerk] * self.num_dofs
120+
121+
async def _query_initial_positions(self):
122+
print("Stopping servos and querying initial positions...")
123+
await self.transport.cycle([
124+
c.make_stop() for c in self.controllers.values()
125+
])
126+
await asyncio.sleep(0.1)
127+
128+
results = await self.transport.cycle([
129+
c.make_query() for c in self.controllers.values()
130+
])
131+
132+
self.initial_positions = []
133+
for servo_id in self.servo_ids:
134+
pos = 0.0
135+
for result in results:
136+
if result.id == servo_id:
137+
pos = result.values.get(moteus.Register.POSITION, 0.0)
138+
break
139+
self.initial_positions.append(pos)
140+
print(f" Servo {servo_id}: position = {pos:.4f}")
141+
142+
def _build_waypoints(self):
143+
# Each servo moves a different distance: servo i moves (i+1) *
144+
# base_amplitude.
145+
#
146+
# This demonstrates that ruckig synchronizes arrival times despite
147+
# different travel distances.
148+
149+
base = self.args.base_amplitude
150+
151+
scales = [i + 1 for i in range(self.num_dofs)]
152+
153+
waypoint_multipliers = [1.0, 0.0, -1.0, 0.0]
154+
155+
self.waypoints = []
156+
for mult in waypoint_multipliers:
157+
wp = [
158+
self.initial_positions[i] + mult * base * scales[i]
159+
for i in range(self.num_dofs)
160+
]
161+
self.waypoints.append(wp)
162+
163+
print(f"\nWaypoints (absolute positions):")
164+
print(f" Each servo moves {base} * (servo_index + 1) revolutions")
165+
for i, wp in enumerate(self.waypoints):
166+
wp_str = ", ".join(f"{p:.3f}" for p in wp)
167+
print(f" {i}: [{wp_str}]")
168+
169+
def _set_initial_trajectory_state(self):
170+
self.inp.current_position = list(self.initial_positions)
171+
self.inp.current_velocity = [0.0] * self.num_dofs
172+
self.inp.current_acceleration = [0.0] * self.num_dofs
173+
174+
self.current_waypoint = 0
175+
self.inp.target_position = self.waypoints[self.current_waypoint]
176+
self.inp.target_velocity = [0.0] * self.num_dofs
177+
178+
async def run(self):
179+
"""Main control loop - stream trajectory to servos."""
180+
print("\nStarting synchronized motion...")
181+
print("Press Ctrl+C to stop")
182+
183+
last_cycle_time = time.time()
184+
last_print_time = 0
185+
186+
try:
187+
while True:
188+
positions, velocities = self._update_trajectory()
189+
if positions is None:
190+
break
191+
192+
results = await self._send_commands(positions, velocities)
193+
self._print_status(results, last_print_time)
194+
last_print_time = self._maybe_update_print_time(last_print_time)
195+
196+
self.out.pass_to_input(self.inp)
197+
last_cycle_time = await self._maintain_timing(last_cycle_time)
198+
199+
except KeyboardInterrupt:
200+
print("\nStopping...")
201+
finally:
202+
await self.transport.cycle([
203+
c.make_stop() for c in self.controllers.values()
204+
])
205+
print("Servos stopped.")
206+
207+
def _update_trajectory(self):
208+
"""Compute next trajectory point. Returns (positions,
209+
velocities) or (None, None) on error."""
210+
result = self.otg.update(self.inp, self.out)
211+
212+
if result == Result.Working:
213+
return list(self.out.new_position), list(self.out.new_velocity)
214+
215+
if result == Result.Finished:
216+
self.current_waypoint = (
217+
(self.current_waypoint + 1) % len(self.waypoints))
218+
self.inp.target_position = self.waypoints[self.current_waypoint]
219+
print(f"Reached waypoint, moving to waypoint {self.current_waypoint}")
220+
return list(self.out.new_position), list(self.out.new_velocity)
221+
222+
print(f"Ruckig error: {result}")
223+
return None, None
224+
225+
async def _send_commands(self, positions, velocities):
226+
"""Send position commands to all servos."""
227+
commands = [
228+
self.controllers[servo_id].make_position(
229+
position=positions[i],
230+
velocity=velocities[i],
231+
query=True,
232+
)
233+
for i, servo_id in enumerate(self.servo_ids)
234+
]
235+
return await self.transport.cycle(commands)
236+
237+
def _print_status(self, results, last_print_time):
238+
"""Print servo status at ~5Hz."""
239+
now = time.time()
240+
if now - last_print_time < 0.2:
241+
return
242+
243+
status_parts = []
244+
for result in results:
245+
pos = result.values.get(moteus.Register.POSITION, 0.0)
246+
vel = result.values.get(moteus.Register.VELOCITY, 0.0)
247+
status_parts.append(f"ID{result.id}:p={pos:+.3f},v={vel:+.3f}")
248+
if status_parts:
249+
print(" | ".join(status_parts))
250+
251+
def _maybe_update_print_time(self, last_print_time):
252+
now = time.time()
253+
if now - last_print_time >= 0.2:
254+
return now
255+
return last_print_time
256+
257+
async def _maintain_timing(self, last_cycle_time):
258+
"""Sleep to maintain cycle timing, return new cycle start time."""
259+
now = time.time()
260+
elapsed = now - last_cycle_time
261+
sleep_time = self.args.cycle_time - elapsed
262+
if sleep_time > 0:
263+
await asyncio.sleep(sleep_time)
264+
return time.time()
265+
266+
267+
async def main():
268+
args = parse_args()
269+
270+
print(f"Controlling servos: {[int(x) for x in args.targets.split(',')]}")
271+
print(f"Max velocity: {args.max_velocity} rev/s (scales with servo index)")
272+
print(f"Max acceleration: {args.max_accel} rev/s^2")
273+
print(f"Max jerk: {args.max_jerk} rev/s^3")
274+
print(f"Cycle time: {args.cycle_time} s")
275+
print(f"Base amplitude: {args.base_amplitude} rev")
276+
277+
motion = SynchronizedMotion(args)
278+
await motion.initialize()
279+
await motion.run()
280+
281+
282+
if __name__ == '__main__':
283+
asyncio.run(main())

0 commit comments

Comments
 (0)