|
| 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