Firmware ≥1.2 plans a rest-to-rest trapezoidal position move and tracks it with velocity feedforward plus a light PID on the encoder. This page is the user-facing guide: how it works, what to set, and what we measured on real hardware.
How closed-loop motion works (fw ≥1.2)
| Mode | Profile | Settles on encoder? |
|---|---|---|
run(v) open-loop velocity | Accel → constant speed | No |
move_to open-loop | Trapezoid (step generator) | Step count only |
move_to closed-loop | Trap plan + v_ff + tracking PID | Yes → MOVE_DONE |
Each closed-loop MOVE_ABS / MOVE_REL:
- Plan a trap (or triangle if short) with
vmax = min(max_speed, cl_max_speed)andamax = cl_max_accel. - Every 5 ms: reference
r(t), feedforwardv_ff(t). - Command:
v = v_ff + Kp·(r − encoder) + Ki·∫ − Kd·v_meas. - Settle when the plan is done, error ≤
pid_tolerance, and speed is low.
There is no S-curve (jerk limit) yet — constant acceleration only.
Why open-loop can look faster
Open-loop run() never has to decelerate to a target or wait for encoder
settle. Closed-loop does. On long moves, closed-loop cruise still reaches a
large fraction of the continuous open-loop ceiling (see measured RPM below).
Pre-1.1 pure PID (no planned deceleration) overshot hard at high cl_max_speed
and often never settled — that architectural gap is what 1.2 fixes.
One-shot setup
node.set_direction(True) # if closed loop ran away (polarity)
node.configure_closed_loop_speed(
4800.0, # trap cruise deg/s (÷6 = RPM)
run_current=70,
microsteps=8,
stealthchop=False, # SpreadCycle — required at high speed
persist=True,
)
node.enable()
node.set_zero()
node.move_to(720.0, blocking=True)| Parameter | Role | Production value |
|---|---|---|
cl_max_speed | Trap cruise vmax | 4800 deg/s (800 RPM) |
cl_max_accel | Trap accel/decel | 19200 (≈ 4× cruise) |
run_current | Driver % | 70% continuous |
microsteps | TMC µsteps | 8 |
stealthchop | 0 = SpreadCycle | 0 |
pid_kp/ki/kd | Tracking trim only | 12 / 0.3 / 0.10 |
pid_tolerance | Settle window | 0.35° |
PID gains do not set cruise speed — cl_max_speed does. PID only trims lag.
Measured max RPM (characterization motor)
Motor under test
All numbers below were taken on this motor unless noted:
| Item | Spec |
|---|---|
| Type | NEMA 17 stepper |
| Model | PR42HS40-1204AF-02 |
| Step angle | 1.8° |
| Shaft | D-type, Ø 4.5 mm, length 19 mm |
| Holding torque | 4.2 kg·cm |
| Rated current | 1.2 A |
| Inductance | 3.2 mH |
| Rotor inertia | 54 g·cm² |
| Detent torque | 0.22 kg·cm |
| Frame / length | 42×42 mm / 40 mm |
| Phases / leads | 2 / 4 wire |
| Net weight | 250 g |
Drive stack: Grafito CANStepper (ESP32-C3 + TMC2209 + MT6701), firmware
1.2, supply 24.0 V, node 1, invert_dir=1, SpreadCycle for high-speed
tests.
Open-loop vs closed-loop ceilings
| Mode | Meaning | deg/s | RPM |
|---|---|---|---|
Open-loop run() | Continuous velocity still tracking encoder | ~7200 | ~1200 RPM |
| Closed-loop cruise (max proven) | Highest trap cruise that settles | ~6000 | ~1000 RPM |
| Closed-loop production | Long-duty default (10 min soak) | 4800 | 800 RPM |
Peaks during a closed-loop move can exceed the cruise setting briefly
(encoder samples during accel); cruise is the number you configure with
cl_max_speed.
Hardware matrix (observations)
Default conditions: fw 1.2, 24 V, 8 µstep unless noted, tracking PID as above.
Current × closed-loop cruise
At 40–85% current, closed-loop settled through 6000 deg/s on long moves. 70% is the preferred continuous setting.
100% current:
- Can reach OL ~7200 and CL 6000 in short tests with cool-downs between moves (including a 5× burst at 6000 all OK).
- Aggressive continuous 100% duty has dropped USB (CDC brownout /
/dev/ttyACM0gone). Prefer 70% for long runs; use 100% only for short high-torque bursts.
Microsteps
| µsteps | High-speed closed-loop |
|---|---|
| 4 | Resonance / timeouts at some mid-high cruises |
| 8 | Best consistency |
| 16 | Works; often slower settle mid-band |
StealthChop vs SpreadCycle
| Mode | High-speed closed-loop |
|---|---|
| StealthChop | Weak / timeouts; lower peaks |
| SpreadCycle | Required for high cruise |
10-minute closed-loop soak
Continuous reversing position moves at the production tune:
| Item | Value |
|---|---|
| Duration | 10 minutes |
| Tune | 70% current, 4800 deg/s cruise, 8 µstep, SpreadCycle |
| Pattern | 0° → 1440° → 0° (two settles per cycle) |
| Full cycles | 239 |
| Settles | 478 / 478 |
| Success rate | 100% |
| Faults / timeouts / USB errors | 0 / 0 / 0 |
| Settle time (med) | 1.15 s (min 1.09, max 3.91) |
| Peak speed (med) | ~5658 deg/s |
| Final error (med / max) | 0.11° / 0.46° |
| MCU temp | 41.4 → 61.4 °C |
| Vbus | 24.0 V flat |
Verdict: closed-loop trap + v_ff at 70% / 4800 is solid for continuous duty on this NEMA 17.
Tuning procedure
- Fix polarity: if closed loop runs away,
node.set_direction(True)thensave_config(). - Baseline: SpreadCycle, 8 µstep, 70% current, 24 V.
- Ladder tools from the repo:
python3 tools/cl_speed_validate.py /dev/ttyACM0 1 70 8 python3 examples/closed_loop_speed.py /dev/ttyACM0 1 4800 python3 tools/cl_matrix.py /dev/ttyACM0 1 --quick - Pick a cruise with margin (if 6000 works, ship 4800).
- Persist with
configure_closed_loop_speed(..., persist=True). - Optional: multi-minute soak before locking production config.
If settle fails
| Symptom | Try |
|---|---|
NO_PROGRESS immediately | Toggle invert_dir; check encoder OK |
| Timeout near target | Slightly raise pid_tolerance or kd |
| Stall mid-move | More current (toward 85%); 8 µstep; SpreadCycle |
| USB disconnect under load | Drop continuous current (100% → 70%); check 24 V supply |
Firmware history
| FW | Closed-loop behaviour |
|---|---|
| ≤1.0 | Pure PI/D chase; default cl_max_speed 30 deg/s |
| 1.1 | Braking-envelope cap (no planned cruise) |
| 1.2 | Trapezoid + velocity feedforward + tracking PID |
Related
- Quickstart
- Python library
- Protocol — parameter IDs
- Troubleshooting
- Repo
docs/closed_loop_tuning.md— full engineer notes (same content source)