The problem definition
According to the Klipper docs, Stealth has a positional lag.
If you have an encoder, you can validate that data on your machine.
CoreXY 1 motor test Gcode
SET_VELOCITY_LIMIT ACCEL=2000
G91
G0 X10 Y10 F300
G0 X10 Y10 F600
G0 X10 Y10 F900
G0 X10 Y10 F1200
G0 X20 Y20 F1500
G0 X20 Y20 F1800
G91
G0 X-20 Y-20 F2100
G0 X-20 Y-20 F2400
G0 X-20 Y-20 F2700
G0 X-20 Y-20 F3000
G0 X-20 Y-20 F3300
G0 X-20 Y-20 F3600
G0 X-20 Y-20 F3900
G0 X-20 Y-20 F4200
G91
G0 X40 Y40 F4500
G0 X40 Y40 F4800
G0 X40 Y40 F5100
G0 X40 Y40 F5400
G91
G0 X-40 Y-40 F5700
G0 X-40 Y-40 F6000
G0 X-50 Y-50 F6300
G0 X-50 Y-50 F6600
G91
G0 X50 Y50 F6900
G0 X50 Y50 F7200
G0 X50 Y50 F7500
G0 X50 Y50 F7800
G91
G0 X-50 Y-50 F8100
G0 X-50 Y-50 F8400
G0 X-50 Y-50 F8700
G0 X-50 Y-50 F9000
G91
SET_VELOCITY_LIMIT ACCEL=5000
G0 X50 Y50 F10000
G0 X50 Y50 F12000
G0 X50 Y50 F13000
G0 X50 Y50 F14000
G91
G0 X-50 Y-50 F15000
G0 X-50 Y-50 F16000
G0 X-50 Y-50 F17000
G0 X-50 Y-50 F18000
G91
G0 X50 Y50 F19000
G0 X50 Y50 F20000
G0 X50 Y50 F21000
G0 X50 Y50 F22000
G91
G0 X-50 Y-50 F23000
G0 X-50 Y-50 F24000
G0 X-50 Y-50 F25000
G0 X-50 Y-50 F26000
G91
G0 X50 Y50 F27000
G0 X50 Y50 F28000
G0 X50 Y50 F29000
G0 X50 Y50 F30000
G91
G0 X-50 Y-50 F31000
G0 X-50 Y-50 F32000
G0 X-50 Y-50 F33000
G0 X-50 Y-50 F34000
G91
G0 X50 Y50 F35000
G0 X50 Y50 F36000
G0 X50 Y50 F37000
G0 X50 Y50 F38000
G91
G0 X-50 Y-50 F39000
G0 X-50 Y-50 F40000
G0 X-50 Y-50 F41000
G0 X-50 Y-50 F42000
G91
G0 X50 Y50 F43000
G0 X50 Y50 F44000
G0 X50 Y50 F45000
G0 X50 Y50 F46000
G90
Spread has low lag, less than 1/4 of fullstep distance, which increases with higher speeds (>200mm/s in my case), stealth has a steeper lag increase over a full speed range, and clamps it around 1 full step.
Why bother? Well, the open-loop control 3D printer is built around time precision, with an assumption that all things happen in “exact” time in a motion system, or at least with a constant lag (that implies that the whole system is lagging with the same lag, so it is still precise.
If one of the motors has a different lag from the others, this would cause a visible defect, like circles that are no longer circles.
geometry_test_simple.stl (65.7 KB)
This is an example test model, it should be printed with perimeters only.
It was created to physically detect the issue 3 years ago.
(also for the interpolation at low microstep resolutions).
Recommended settings for the test
Accel: >3000
Speed: 150-200mm/s (we want that lag).
Many walls > 5 or concentric infill.
Gap fill - everywhere.
95% of the flow will help amplify the defect.
Why, StealthChop, I’m working with the spread and it is fine.
Spread is fine.
The Stealth is interesting because it could provide smoother motor running, better autotuned microstep precision, and probably avoid rotor resonance. (But there is a magnetic/electrical resonance without a rotor damper; this is a different story.)
My theoretical base
SpreadCycle is a current source.
StealthChop is mostly a PWM/Voltage source with some feedback (it is autotune).
A stepper motor, during rotation, is driven by an alternating current.
That means that in the case of a Voltage source, there will be a current lag behind the voltage.
As you can see in an example schematic:
The peak of the current arrives after the voltage peak.
The stepper motor position defined by the current in the coils would lag at least by the same amount.
So, a stepper motor should work more or less as an RL Circuit.
If so, to get the angle, we can simply do:
phi rads = arctan(omega * L / R)
Let’s build a model:
- My motor has 200 full steps per revolution.
- Most stepper motors have 2 phases, shifted by 90 degrees.
- Each full step is a 90-degree revolution of a sinusoidal function.
- Full revolution is 360 degrees, so 360/90, there are 4 full steps per electrical revolution.
So, there are 200 / 4 = 50 electrical revolutions per 1 physical revolution.
My motor LDO 2504 has an inductance of 1.5 mH and a resistance of 1.2 Ω according to the datasheet.
omega = 2*Pi*RPS*200/4
math.degrees(math.atan((2 * 3.1415 * 10 * 200/4)*0.0015/1.2)) = 75.71 degrees
math.degrees(math.atan((2 * 3.1415 * 0.1 * 200/4)*0.0015/1.2)) = 2.24 degrees
Simply put those in graphs:
We can get the idea of the time lag/phase lag dependence and inductance role in the equation.
At low speeds, there is a low angle lag and huge time lag (1- 2ms).
At higher speeds, the degree lag got bigger, but because lag can never exceed 90 degrees, the absolute time lag decreases.
code
#!/usr/bin/python3
import numpy as np
import matplotlib.pyplot as plt
# Motor parameters
L = 0.0015 # Henries
R = 1.2 # Ohms
pole_cycles = 50 # electrical cycles per mechanical rev
steps_per_rev = 200 # Standard for 1.8° stepper
microstep_factor = 16
fstep_dist = 0.2
rps = np.linspace(0.01, 20, 2000) # Revolutions per second
omega = 2 * np.pi * pole_cycles * rps # Electrical angular frequency in rad/s
# Calculate phase lag
phi_rad = np.arctan(omega * L / R)
phi_deg = np.degrees(phi_rad)
# Calculate time lag
dt_s = phi_rad / omega
dt_us = dt_s * 1e6 # Convert to microseconds
# Calculate distance compensation
# For each degree of lag, the position error follows a sinusoidal pattern
distance_lag_degree = fstep_dist * np.sin(phi_rad)
step_rate = rps * steps_per_rev
distance_lag_time = step_rate * dt_s * fstep_dist
# Create merged plots: dual-axis for phase & time lag, single axes for compensation
fig, axes = plt.subplots(2, 1, figsize=(10, 12))
fig.suptitle("Stepper Motor Lag Analysis and Compensation", fontsize=16)
# Top: Phase Lag (°) on left axis & Time Lag (µs) on right axis
ax1 = axes[0]
ax1.plot(rps, phi_deg, 'b-', linewidth=2, label='Phase Lag (°)')
ax1.set_xlabel("Speed (RPS)")
ax1.set_ylabel("Phase Lag (degrees)", color='b')
ax1.tick_params(axis='y', labelcolor='b')
ax2 = ax1.twinx()
ax2.plot(rps, dt_us, 'r--', linewidth=2, label='Time Lag (µs)')
ax2.set_ylabel("Time Lag (µs)", color='r')
ax2.tick_params(axis='y', labelcolor='r')
ax1.set_title("Phase Lag and Time Lag vs Speed")
ax1.grid(True)
# Combine legends for both axes
lines, labels = ax1.get_legend_handles_labels()
lines2, labels2 = ax2.get_legend_handles_labels()
ax1.legend(lines + lines2, labels + labels2, loc='upper left')
# Bottom: Distance Compensation Methods
ax3 = axes[1]
ax3.plot(rps, distance_lag_degree, 'g-', linewidth=2, label='Distance Lag (Degree sin() Method)')
ax3.plot(rps, distance_lag_time, 'm-', linewidth=2, label='Distance Lag (Time Method)')
ax3.set_xlabel("Speed (RPS)")
ax3.set_ylabel("Distance Compensation (mm)")
ax3.set_title("Distance Compensation vs Speed")
ax3.grid(True)
ax3.legend()
plt.tight_layout(rect=[0, 0, 1, 0.95])
plt.show()
Validation
So, according to the encoder data, we see a similar picture in the StealthChop data.
Where lag is increasing with the speed, and clamps about 90 degrees.
So, to initially validate the idea, I want to see what happens if there is a time compensation for step timings. Because Motan graph analyzes the steps, if I mess with them on the host, I will see the same lag as I currently see. It would be hard to measure.
Also, it would be somewhat simpler for me to make an initial implementation on the MCU side. Itersolver drives me crazy :D.
PoC Code.
You probably need a beefy MCU to test that.
Basically, I calculate the electrical revolution frequency from microstep timings.
Then, I simply calculate the time lag and shift step timings around.
rads = atan(omega * s->motor_constant);
timelag = rads / omega; // seconds
Because I probably messed up with the timings or code, the encoder loses correct tracking at higher speeds. So, graphs are shorter.
So, now the lag of the StealthChop seems constant, which is good for us.
I suspect that jerkiness is the consequence of how I implemented it.
Also, it is comparable to the SpreadCycle, so I would expect the same performance here.
Printing Validation
It is hard to reproduce the original test on my machine, probably because of the low inductance motors. So, I forced the speed to increase a little (200mm/s) and make things bigger.
This is the same G-code printed with Spread, Stealth, and Compensated Stealth.
Aside from the little imperfections, I can’t distinguish compensated stealth and spread, I hope not only I’m.
Stealth gives a little error in my case, but I suspect the precision error will be greater, with higher inductance motors (as was in the original test, with the motor at 1.5A). Also, it would affect different parts/geometries differently.
Summary
Well, thanks to everyone who got to here.
I hope it was interesting.
I think the next step would be an implementation on the host side (well, it would take more time).
I think it would be good, if there are people who find the StealthChop useful.
I suspect it also makes sense if there are more examples found where Stealth currently performs worse in comparison to the SpreadCycle.
If some of the topics above require further explanation, I will try to answer them.
Thanks.
Appendix I: Hybrid Stealth ↔ Spread
Because of the current lag, there is a positional lag. Same with the Spread to Stealth or backwards. They would have a lag difference, so switching at higher speeds will introduce the rotor jerk. TMC2240, according to the datasheet, could fix that internally, an information available below.
When decelerating, the rotor goes to the PWM lag position in ~2ms.
At this moment, there is a torque loss.
At the accelerating phase, the rotor switches to the Spread lag - jumps forward in 1ms.
And then oscillating for some time. Switching in my case was configured at 110mm/s.
There is a visible accelerometer distortion, up to ~2g (it is a lot, ~20000 acceleration).
So, with higher acceleration, it probably could lose steps here.
TMC has internal hysteresis for the thresholds, +1/16 by default.
So, 110 / 16 * 17 ~= 117 mm/s
.
It would switch to Spread > 117 mm/s and switch to Stealth < 110mm/s.
I suspect it could also be fixed, either by the lag compensation or by injecting the quasi-move with a slower acceleration transition. But this is for the future.
Appendix II: SpreadCycle positional lag.
Spread is a current source, so it will instantly drive full voltage in the motor until it reaches the right amount of current. That substantially decreases the current lag and positional lag.
That voltage is limited by back EMF. So, lag will increase with speed.
I’m not sure that model is completely correct, so read this as an approximation.
Seems like either my model for SpreadCycle is not correct and/or the electrical lag at speeds < 400mm/s (for my motor) is negligibly small.
So, generally, the SpreadCycle should have close to zero electrical lag, as long as there is enough voltage.
Appendix III: TMC2240
Spread and stealth work the same as before.
But switching is interesting.
It does detect the actual rotor lag.
It just makes the SpreadCycle lag by the same degree.
So, switching is jerkless.