Best practices for synchronized multi-axis control on large 3D printer (3x3m) using CAN?

Hi everyone,

I’m building a very large 3D printer (3x3m build volume) and using multiple ODrive Pros to control the motors via CAN from an ESP32 main controller. Due to the machine’s size, CAN is essentially my only option for reliable communication.

Current Setup:

  • ESP32 as main controller, calculating speed and position from gcode and transmitting via CAN
  • Each ODrive Pro running standard firmware
  • Controllers set to INPUT_MODE_TRAP_TRAJ
  • ESP32 sends a target position to each ODrive, then waits for all ODrives to report they’re within tolerance of that position before sending the next target

The Problem: I’m concerned about timing precision and synchronization between axes. My current approach of “move, wait for all axes to arrive, then move again” seems imprecise and leads to:

  • Stuttering motion as axes wait for the slowest one
  • Loss of smooth velocity profiles
  • Issues with axes starting and arriving at slightly different times

My Questions:

  1. Is this “wait for position” approach fundamentally wrong for coordinated motion? Should I be streaming positions at a fixed rate instead? Does the ODrive CAN implementation have a preferred way of achieving this?
  2. Is INPUT_MODE_TRAP_TRAJ the right mode for this application, or should I be using something else?
  3. If I switch to streaming positions, what update rate should I target for CAN commands? (50-100Hz?)
  4. Are there any ODrive Pro features I should be leveraging that are specifically designed for synchronized multi-axis systems?
  5. Has anyone successfully built a similar ODrive CAN system with good synchronization?

I’m using standard ODrive Pro firmware and would prefer not to go down the custom firmware route if possible.

Any advice, best practices, or examples of similar implementations would be greatly appreciated!

Thanks in advance!

Very cool project!

  • Is this “wait for position” approach fundamentally wrong for coordinated motion? Should I be streaming positions at a fixed rate instead? Does the ODrive CAN implementation have a preferred way of achieving this?

Yes, waiting for the actual position to settle is usually the wrong approach. Typically in a system like this, the right approach is to set up/tune the ODrive and trapezoidal motion parameters such that the trajectory acceleration/velocity/deceleration is all feasible, and then assume that the ODrive will reach the end position at the same timestamp that the trajectory is expected to take. This is especially the case with something like a 3D printer, where you’ll have a very consistent load profile on each axis (nearly entirely inertia driven).

For instance, if you can accelerate/decelerate at 2 m/s^2, and move at a max speed of 0.5 m/s^2, and you send a position setpoint to move 1.5m, you can calculate a total trajectory time of 3.25s. So you could just send the new setpoint and then assume that the ODrive will be at the final position in exactly 3.25s.

To make this easier, you can check trajectory_done as well – this will go to True once the trajectory move has finished. Note that this is set to true based on the trajectory setpoints – not the actual ODrive position/velocity – so you don’t have the issues you’re having with waiting for the actual ODrive position to settle. This is also a flag in the heartbeat CAN message.

For best performance, you should definitely also set the inertia term – this will allow the ODrive to add a torque feedforward to the acceleration/deceleration stages of the trapezoidal move, which makes the tracking performance much better, and brings transient error down to near zero.

  • Is INPUT_MODE_TRAP_TRAJ the right mode for this application, or should I be using something else?

You could switch to POS_FILTER and externally calculate+stream in the trajectory yourself. However, you’d want to be streaming at ≥100Hz (which can be a good bit of bandwidth and CPU time on the ESP32), and there’s not much reason to do this unless you’re doing something more advanced than trapezoidal movement in the first place (e.g. S-curves, blended motion, etc).

  • Are there any ODrive Pro features I should be leveraging that are specifically designed for synchronized multi-axis systems?

We have some upcoming functionality for trajectory preplanning and synchronization, but even just with the current CAN stack, you can get synchronization within +/- a few hundred microseconds, just by sending the position setpoint messages sequentially.

  • Has anyone successfully built a similar ODrive CAN system with good synchronization?

Yes! I’m not sure if there’s anything public I can point to, but we have some customers who synchronize multiple axis this way. Unfortunately I can’t share the exact details of their application, but the method of sending setpoints in TRAP_TRAJ mode is the most common one.

@solomondg Thanks so much for your thoughtful and detailed reply!

I tried your suggestion of streaming positions without checking encoder values, and instead calculating the theoretical duration of each move. That approach works well — but I ran into another issue I’m hoping you might have some insight on.

When I test my system on a circular toolpath (with a point every 5 mm along the circumference), I notice that as I increase the speed, the deceleration phase of the TRAP_TRAJ becomes a larger and larger portion of each move. At low speeds, acceleration and deceleration make up a small fraction of the motion, and the circle runs smoothly. But as I go faster, those acceleration phases dominate, and the motion becomes increasingly choppy — the system keeps speeding up and slowing down between each point.

Do you have any suggestions for improving this?

Would it make sense to remove the deceleration phase of the TRAP_TRAJ when streaming continuous points (so the motor never really stops, just flows into the next point)? Or could POS_FILTER help with this?

A few follow up questions:

For continuous motion, I tried sending the next position when the motor is about 98% of the way to the current one — but over time the toolpath starts to drift. Any ideas on what might cause that or how to prevent it?

I intend to also use the Gantry system for CNC milling functionality on top of the 3d printing. Would you still recommend the method you describe even with a varying load on each axis when CNC milling?

Any chance there is some documentation on how to best tweak the inertia term?

Thanks so much for the help!

Would it make sense to remove the deceleration phase of the TRAP_TRAJ when streaming continuous points (so the motor never really stops, just flows into the next point)? Or could POS_FILTER help with this?

If you’re streaming continuous points, then POS_FILTER is the right mode for this. You’ll have to calculate acceleration/deceleration offboard, but that should be fairly straightforward (it’s a common thing for gcode interpreters to do). As long as you also provide the velocity feedforward, this should be very smooth/precise.

For continuous motion, I tried sending the next position when the motor is about 98% of the way to the current one — but over time the toolpath starts to drift. Any ideas on what might cause that or how to prevent it?

Not 100% sure what you mean by “drift”, can you elaborate?

I intend to also use the Gantry system for CNC milling functionality on top of the 3d printing. Would you still recommend the method you describe even with a varying load on each axis when CNC milling?

Yup, that’s fine. You just need to make sure that your expected maximum load is less than the achievable torque of the system.

Any chance there is some documentation on how to best tweak the inertia term?

Should be the actual inertia of your system! A basic way to characterize is to accelerate the system in VEL_RAMP mode while monitoring torque_estimate. Since accel = torque/inertia, you can do the math to figure out the inertia. e.g. if it takes 0.25 Nm of torque to accelerate (or decelerate) at 5 rev/s^2, your inertia will be (0.25/5)/2pi = 7.95e-3 kg-m^2.