Grafito CANStepper

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)

ModeProfileSettles on encoder?
run(v) open-loop velocityAccel → constant speedNo
move_to open-loopTrapezoid (step generator)Step count only
move_to closed-loopS-curve + v_ff + Ka·a_ff + PIDYes → MOVE_DONE

Each closed-loop MOVE_ABS / MOVE_REL:

  1. Plan an S-curve with vmax = min(max_speed, cl_max_speed), amax = cl_max_accel, and jerk = cl_max_jerk (0 = auto amax/0.05 s).
  2. Every 5 ms: reference r(t), v_ff(t), a_ff(t).
  3. Command: v = v_ff + Ka·a_ff + Kp·(r − encoder) + Ki·∫ − Kd·v_meas.
  4. 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)
ParameterRolePrecision value (fw 1.12)
cl_max_speedS-curve cruise vmax720 deg/s (120 RPM)
cl_max_accelS-curve peak accel1440
cl_max_jerkJerk; 0 = auto0
pid_kaAccel feedforward0.04 s
run_currentDriver %40% precision
microstepsTMC µsteps8 (use 2 only for OL ~3000 RPM)
stealthchop0 = SpreadCycle0
pid_kp/ki/kdTracking trim10 / 0.3 / 0.35
lut_enable200-step encoder LUT1 after calibration
pid_toleranceSettle window0.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.

CoolingWhat 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/cruiseBest 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:

ItemSpec
TypeNEMA 17 stepper
ModelPR42HS40-1204AF-02
Step angle1.8°
ShaftD-type, Ø 4.5 mm, length 19 mm
Holding torque4.2 kg·cm
Rated current1.2 A
Inductance3.2 mH
Rotor inertia54 g·cm²
Detent torque0.22 kg·cm
Frame / length42×42 mm / 40 mm
Phases / leads2 / 4 wire
Net weight250 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

ModeMeaningdeg/sRPM
Open-loop run()Firmware-velocity cruise, 2 µstep, 80%~17860~3000 RPM
3300 RPM commandStarts to sag~18450~3075 RPM (93%)
3500–5000 RPMStall / lost steps—5000 not reachable
Closed-loop precisionS-curve settle (error table below)720120 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.

MoveOvershootSettle 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/ttyACM0 gone). Prefer 70% for long runs; use 100% only for short high-torque bursts.

Microsteps

µstepsHigh-speed closed-loop
4Resonance / timeouts at some mid-high cruises
8Best consistency
16Works; often slower settle mid-band

StealthChop vs SpreadCycle

ModeHigh-speed closed-loop
StealthChopWeak / timeouts; lower peaks
SpreadCycleRequired for high cruise

10-minute closed-loop soak

Continuous reversing position moves at the production tune:

ItemValue
Duration10 minutes
Tune70% current, 4800 deg/s cruise, 8 µstep, SpreadCycle
Pattern0° → 1440° → 0° (two settles per cycle)
Full cycles239
Settles478 / 478
Success rate100%
Faults / timeouts / USB errors0 / 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 temp41.4 → 61.4 °C
Vbus24.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

  1. Fix polarity: if closed loop runs away, node.set_direction(True) then save_config().
  2. Baseline: SpreadCycle, 8 µstep, 70% current, 24 V.
  3. 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
  4. Pick a cruise with margin (if 6000 works, ship 4800).
  5. Persist with configure_closed_loop_speed(..., persist=True).
  6. Optional: multi-minute soak before locking production config.

If settle fails

SymptomTry
NO_PROGRESS immediatelyToggle invert_dir; check encoder OK
Timeout near targetSlightly raise pid_tolerance or kd
Stall mid-moveMore current (toward 85%); 8 µstep; SpreadCycle
USB disconnect under loadDrop continuous current (100% → 70%); check 24 V supply
OTPW / fault=4 (OT) mid-soakAdd heatsink; lower current or cruise; improve airflow

Firmware history

FWBehaviour
≤1.0Pure PI/D chase; default cl_max_speed 30 deg/s
1.1Braking-envelope cap (no planned cruise)
1.2Trapezoid + velocity feedforward + tracking PID
1.4TMC2209 DRIVER telemetry: OTPW/OT, shorts, open-load, cs_actual; faults 4/5
1.5STATUS byte 7 HOME / GPIO8 diagnostics
1.7HOME raw boot diagnostics
1.8Endstop hard defaults every boot (enable, active-high, stop)
1.9Low-power boot: no auto-enable, hold ≤5, run cap 40, freewheel standstill
1.10200-step MT6701 encoder LUT
1.117-segment S-curve + pid_ka acceleration feedforward
1.12Factory 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.

On this page