Grafito CANStepper

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)

ModeProfileSettles on encoder?
run(v) open-loop velocityAccel → constant speedNo
move_to open-loopTrapezoid (step generator)Step count only
move_to closed-loopTrap plan + v_ff + tracking PIDYes → MOVE_DONE

Each closed-loop MOVE_ABS / MOVE_REL:

  1. Plan a trap (or triangle if short) with
    vmax = min(max_speed, cl_max_speed) and amax = cl_max_accel.
  2. Every 5 ms: reference r(t), feedforward v_ff(t).
  3. Command: v = v_ff + Kp·(r − encoder) + Ki·∫ − Kd·v_meas.
  4. 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)
ParameterRoleProduction value
cl_max_speedTrap cruise vmax4800 deg/s (800 RPM)
cl_max_accelTrap accel/decel19200 (≈ 4× cruise)
run_currentDriver %70% continuous
microstepsTMC µsteps8
stealthchop0 = SpreadCycle0
pid_kp/ki/kdTracking trim only12 / 0.3 / 0.10
pid_toleranceSettle window0.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:

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.2, supply 24.0 V, node 1, invert_dir=1, SpreadCycle for high-speed tests.

Open-loop vs closed-loop ceilings

ModeMeaningdeg/sRPM
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 productionLong-duty default (10 min soak)4800800 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/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: closed-loop trap + v_ff at 70% / 4800 is solid for continuous duty on this NEMA 17.

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

Firmware history

FWClosed-loop behaviour
≤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

On this page