Firmware ≥1.11 plans a rest-to-rest 7-segment S-curve and tracks it with velocity + acceleration feedforward plus a light PID on the encoder (optional 200-step MT6701 LUT from fw 1.10). This page is the user-facing guide: how it works, what to set, and the error / RPM numbers from hardware.
How closed-loop motion works (fw ≥1.11)
| 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 | S-curve + v_ff + Ka·a_ff + PID | Yes → MOVE_DONE |
Each closed-loop MOVE_ABS / MOVE_REL:
- Plan an S-curve with
vmax = min(max_speed, cl_max_speed),amax = cl_max_accel, andjerk = cl_max_jerk(0 = autoamax/0.05 s). - Every 5 ms: reference
r(t),v_ff(t),a_ff(t). - Command:
v = v_ff + Ka·a_ff + Kp·(r − encoder) + Ki·∫ − Kd·v_meas. - Settle when the plan is done, error ≤
pid_tolerance, and speed is low.
Calibrate the LUT once (node.calibrate_encoder_lut()) so detent INL is
linearized. Firmware 1.12 ships the precision defaults measured below.
Why open-loop can look faster
Open-loop run() never has to decelerate to a target or wait for encoder
settle. Closed-loop does. The physical open-loop ceiling on this motor is
about 3000 RPM; 5000 RPM is not reachable (back-EMF / torque, not the
profiler).
One-shot setup (precision)
node.set_direction(True) # if closed loop ran away (polarity)
node.calibrate_encoder_lut()
node.configure_closed_loop_speed(
720.0, # 120 RPM cruise
accel_deg_s2=1440.0,
run_current=40,
microsteps=8,
stealthchop=False, # SpreadCycle
persist=True,
)
node.enable()
node.set_zero()
node.move_to(180.0, blocking=True)| Parameter | Role | Precision value (fw 1.12) |
|---|---|---|
cl_max_speed | S-curve cruise vmax | 720 deg/s (120 RPM) |
cl_max_accel | S-curve peak accel | 1440 |
cl_max_jerk | Jerk; 0 = auto | 0 |
pid_ka | Accel feedforward | 0.04 s |
run_current | Driver % | 40% precision |
microsteps | TMC µsteps | 8 (use 2 only for OL ~3000 RPM) |
stealthchop | 0 = SpreadCycle | 0 |
pid_kp/ki/kd | Tracking trim | 10 / 0.3 / 0.35 |
lut_enable | 200-step encoder LUT | 1 after calibration |
pid_tolerance | Settle window | 0.35° |
PID gains do not set cruise speed — cl_max_speed does. PID only trims lag.
From a PLC, the same knobs are CANopen objects (REAL32): 0x200F Kp, 0x2010 Ki, 0x2011 Kd, 0x201A Ka, 0x2012 tolerance, 0x6081 velocity, 0x6083 accel. Procedure: Tune gains from the PLC.
Cooling for high-speed duty
High continuous current + high cl_max_speed (especially multi-node hops)
heats the TMC2209. A heatsink is strongly recommended for production
or soak-style tests.
| Cooling | What we observe |
|---|---|
| No heatsink, aggressive hops (~65%, up to 4800 deg/s) | Frequent OTPW; some axes may hit OT (fault=4) |
| Heatsink (+ airflow) | Much less OTPW; continuous high speed more reliable |
| Heatsink + slightly lower current/cruise | Best for long multi-axis duty |
Watch node.get_driver_status().otpw / .over_temp_shutdown (fw ≥1.4). See
Hardware cooling and
Troubleshooting OTPW/OT.
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.11 (confirmation) / 1.12 (factory defaults), supply 24.0 V, node 3, SpreadCycle for high-speed tests, 200-step LUT on.
Open-loop vs closed-loop ceilings
| Mode | Meaning | deg/s | RPM |
|---|---|---|---|
Open-loop run() | Firmware-velocity cruise, 2 µstep, 80% | ~17860 | ~3000 RPM |
| 3300 RPM command | Starts to sag | ~18450 | ~3075 RPM (93%) |
| 3500–5000 RPM | Stall / lost steps | — | 5000 not reachable |
| Closed-loop precision | S-curve settle (error table below) | 720 | 120 RPM |
Cruise is cl_max_speed. Host USB dpos/dt inflates peaks — use
node.get_motion() firmware velocity. 100% current can match 3000 RPM
briefly, then TMC OT (fault=4). Restore 40% / 8 µstep after speed tests.
Confirmation error table (2026-10-08, fw 1.11, LUT on)
Hold at 0° for 2 s: 0.000° p-p. 9/9 closed-loop moves settled.
| Move | Overshoot | Settle error | |err| |
|---|---|---|---|
| +30° | 0.081° | +0.125° | 0.125° |
| +45° | 0.044° | +0.176° | 0.176° |
| +90° | 0.088° | +0.110° | 0.110° |
| +180° | 0.000° | +0.132° | 0.132° |
| −45° | 0.000° | +0.044° | 0.044° |
| −90° | 0.000° | +0.088° | 0.088° |
| +360° | 1.560° | +0.286° | 0.286° |
| +720° | 0.066° | +0.066° | 0.066° |
| +1080° | 0.132° | +0.198° | 0.198° |
Median |err| 0.125°, max 0.286°. Trap-only 180° used to overshoot
~43°; S-curve + Ka=0.04 + LUT brought that move to 0.000° overshoot.
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 (historical fw 1.2): trap + v_ff at 70% / 4800 was solid for continuous duty. Current production positioning uses the fw 1.11 S-curve precision tune in the error table above.
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 |
OTPW / fault=4 (OT) mid-soak | Add heatsink; lower current or cruise; improve airflow |
Firmware history
| FW | 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 |
| 1.4 | TMC2209 DRIVER telemetry: OTPW/OT, shorts, open-load, cs_actual; faults 4/5 |
| 1.5 | STATUS byte 7 HOME / GPIO8 diagnostics |
| 1.7 | HOME raw boot diagnostics |
| 1.8 | Endstop hard defaults every boot (enable, active-high, stop) |
| 1.9 | Low-power boot: no auto-enable, hold ≤5, run cap 40, freewheel standstill |
| 1.10 | 200-step MT6701 encoder LUT |
| 1.11 | 7-segment S-curve + pid_ka acceleration feedforward |
| 1.12 | Factory defaults from bench: accel 1440, Kp 10, Kd 0.35, Ka 0.04 |
Use node.get_driver_status() (fw ≥1.4) during high-current soaks to watch
OTPW before a full OT shutdown. MCU temperature is still get_env()[0].
Without a heatsink, OTPW (and occasional OT) under high-speed multi-node
hops is expected — cool the TMC first, then re-tune current/speed.
Related
- Quickstart
- Python library
- Protocol — parameter IDs and DRIVER bit layout
- Troubleshooting — OTPW / OT checklist
- Repo
docs/closed_loop_tuning.md— full engineer notes (same content source)