A motor-level motion model for Smoothieware

Backlash, modeled at the motor.

Static slack and inertial lag, compensated live without corrupting Smoothie’s logical toolpath.

Begin with the invariant
Actuator A / conceptual signal path qlogical + Δ → qmotor
Motor and leadscrew compensation signal diagram A logical motion signal enters a compensator, which commands a motor and leadscrew while preserving the requested load position. COMPENSATOR static + dynamic offset LOGICAL q planner-owned STEPPER LOAD FREE PLAY B TIME → COMPENSATED LOAD UNCOMPENSATED LAG
qmotor(t) = qlogical(t) + Δstatic + Δdynamic(t)
The governing invariant

The planner describes where the tool must be. The motor decides how far its shaft must move to make that true.

Backlash is not a change to geometry. It is a difference between requested load position and required motor position.

q
Logical actuator position

The kinematic and planner result. Exact, stable, and never polluted by compensation.

u
Commanded motor position

The cumulative STEP pulses emitted to the driver, including corrective motion.

Estimated load position

The physical axis position inferred after removing the modeled offset from the shaft position.

G-codeG0 G1 G2 G3
Robotgeometry + IK
Plannerlogical Block
StepTickerlogical phase
Motor modelq + Δ → STEP/DIR
Source-grounded context

Where motion exists today.

Smoothie already separates geometry, planning, queue ownership, and pulse emission. The feature belongs at the final boundary.

Robot.cpp

Build machine-space milestones

Applies work offsets, compensation transforms, segmentation, inverse kinematics, per-axis rate limits, and per-actuator acceleration limits.

append_milestone() at line 1273
Planner.cpp

Turn actuator targets into logical Blocks

Calls steps_to_target(), computes junction speed, performs reverse and forward look-ahead, and prepares trapezoids.

append_block() at line 52
Conveyor.cpp

Marshal Blocks safely across contexts

The planner produces at head_i, the interrupt consumes through isr_tail_i, and idle context reclaims through tail_i.

double-ring explanation at line 33
Block.cpp

Convert a trapezoid into fixed-point rates

Each motor receives 2.62 fixed-point step phase, acceleration change, plateau rate, and the exact logical step count.

prepare() at line 315
StepTicker.cpp

Emit at most one pulse per motor per tick

The timer interrupt advances a phase accumulator and emits a pulse once it crosses one whole step.

step_tick() at line 134
StepperMotor.h

Own STEP, DIR, and emitted shaft position

This is the correct home for persistent, per-motor mechanical state. It is also where raw shaft position must become distinct from estimated load position.

step() at line 22
The physical problem

Two errors. One offset.

Both effects make motor position differ from load position, but they have different causes, timescales, and state.

B

Mechanical free play

A dead zone caused by clearance in screws, belts, couplers, gears, or bearings. It appears most clearly when direction reverses.

m/k

Dynamic compliance

A transient displacement caused by moving mass, drive stiffness, damping, friction, gravity, velocity, and acceleration.

Direction reversal demonstrator positive flank engaged
Leadscrew backlash reversal diagram A screw rotates inside a nut with clearance. Direction buttons show which flank engages and how the load follows after slack is crossed. MOTOR SHAFT NUT CARRIAGE / LOAD CLEARANCE B POSITIVE NEGATIVE
Mechanical free play

Backlash is positional memory.

The same motor coordinate can correspond to two load positions. The result depends on which mechanical flank is engaged.

u(t) = q(t) + b(s),   b(+) = +B+/2,   b(-) = -B-/2

Here q is logical actuator position, u is motor shaft command, and s is the currently engaged direction. Separate positive and negative offsets allow asymmetric hardware.

Backlash hysteresis loop The load position follows parallel branches depending on direction, with a horizontal dead band during reversal. B DEAD BAND MOTOR SHAFT POSITION u → LOAD POSITION x → POSITIVE FLANK NEGATIVE FLANK
Backlash hysteresis graph showing two directional branches and the dead band.
1

Move on one flank

The shaft and load move together with a stable directional offset.

2

Reverse the shaft

The first B steps cross clearance. The load has not moved yet.

3

Engage the opposite flank

Normal logical motion resumes once the new side carries force.

Δureversal = b(snew) - b(sold)

The compensator does not add a generic B steps to every block. It changes the persistent motor offset only when engagement changes. Tiny sign chatter should be rejected with an engagement threshold.

Inertia and compliance

The load is not rigidly attached.

Belts stretch, screws twist, frames deflect, and bearings resist motion. Under force, the shaft must lead the requested load position.

Mass spring damper model of a motor axis A commanded motor anchor is connected to a moving carriage mass through stiffness and damping elements. MOTOR u(t) STIFFNESS k DAMPING c m MOVING LOAD x(t) COMMAND LEAD Δ DESIRED LOAD MOTION q(t)
m ẍ

Inertial force required to accelerate the carriage, gantry, tool, and coupled mechanics.

c(ẋ - u̇)

Velocity-dependent dissipation: bearing drag, belt damping, and structural losses.

k(x - u)

Elastic restoring force. Lower stiffness produces more positional deflection for the same force.

Fc sign(ẋ)

Coulomb friction or a consistent load bias. Gravity can add a direction-dependent term on vertical axes.

m ẍ + c(ẋ - u̇) + k(x - u) + Fc sign(ẋ) = 0

This is a useful conceptual plant model. Smoothie has no axis encoder in the normal configuration, so it cannot observe x directly. Compensation must therefore be bounded feed-forward unless feedback hardware is added.

Δdynamic = τvv + τa2a + Bload sign(v)

A practical first implementation uses directly tunable coefficients. Velocity in steps/s times τv in seconds yields steps. Acceleration in steps/s² times τa² yields steps. Clamp, filter, and slew-limit the result.

Interactive example model

See the error become an offset.

Adjust an illustrative open-loop axis. The plot compares the requested path, uncompensated load estimate, and compensated load estimate.

Discrete mass, spring, damping, and free-play model illustrative parameters, not machine calibration

Model parameters

Every input updates the same deterministic simulation. Units are explicit so the graph can be reasoned about.

12 steps
2.4 kg
180 N/mm
18 N·s/m
5.0 ms²
logical request load without correction load with correction

The model intentionally exposes assumptions. It is a design instrument, not evidence that these default values match a real machine.

Proposed abstraction

A stateful model owned by each motor.

The policy is per actuator, persistent across Blocks, independent of kinematics, and deterministic inside the timer interrupt.

Robot + arm_solution

Produces actuator-space milestones from machine-space geometry.

Planner + Block

Owns logical distance, look-ahead, junction velocity, and trapezoid timing.

StepTicker

Advances logical phase at the fixed timer rate and asks each motor for its physical pulse decision.

StepperMotionCompensator

Owns engagement state, static offset, dynamic filter, limits, and estimated load position.

StepperMotor

Owns DIR setup, STEP pulse, raw shaft count, enable state, and hardware pins.

src/libs/StepperMotionCompensator.hproposed interface
struct MotionSample {
    int64_t logical_position_fp;      // 2.62 steps
    int64_t logical_velocity_fp;      // 2.62 steps per tick
    int64_t logical_acceleration_fp;  // 2.62 steps per tick squared
    bool    logical_motion_complete;
};

struct PulseDecision {
    bool should_step;
    bool direction;
    bool requires_direction_setup_tick;
};

class StepperMotionCompensator {
public:
    PulseDecision advance(const MotionSample& sample);
    int32_t estimated_load_steps() const;
    int32_t commanded_motor_steps() const;
    bool has_settled() const;

    void reset_at_known_load_position(int32_t logical_steps, Direction engaged);
    void freeze_after_halt();
};
MotionSample

A signed, block-local logical state. It contains no mechanical policy and is safe to derive from existing fixed-point Block data.

PulseDecision

A bounded hardware action for this ticker interrupt. It can request zero or one pulse and explicitly handles DIR setup timing.

estimated_load_steps()

The position used for real-time machine coordinates and recovery after an abort. It is not the raw pulse counter.

reset_at_known_load_position()

The homing and manual-reset seam. It makes mechanical engagement explicit instead of silently assuming shaft equals load.

The 100 kHz execution path

Separate the clock from the correction.

The logical phase remains the timing authority. The compensator converts its continuously changing target into a safe physical pulse stream.

Advance logical rate

Apply the existing acceleration change and trapezoid phase transitions.

Advance logical phase

Integrate signed steps per tick without assuming a pulse has already occurred.

Compute target offset

Select directional free-play offset and update the bounded dynamic lead.

Compare target to shaft

Quantize the difference into zero or one legal pulse for this interrupt.

Honor DIR setup

Change direction first, then wait the configured setup interval before pulsing.

Finish by two conditions

The logical profile is complete and the compensated physical target has settled.

One direction reversalnot to scale
Tick-level compensation timeline Logical direction reverses, static offset changes, compensated target moves ahead, and physical pulses catch up while logical position remains continuous. TICK → logical q offset Δ target u shaft pulses DIR REVERSAL DIR SETUP
Timeline of logical motion, compensation offset, motor target, pulse stream, and direction setup around a reversal.
src/libs/StepTicker.cppintegration sketch
// Existing Block math remains the logical trajectory generator.
MotionSample sample {
    .logical_position_fp     = tickinfo.logical_phase,
    .logical_velocity_fp     = signed_rate(block, tickinfo),
    .logical_acceleration_fp = signed_acceleration(block, tickinfo),
    .logical_motion_complete = tickinfo.logical_step_count ==
                               tickinfo.logical_steps_to_move
};

PulseDecision decision = motor[m]->advance_compensated_motion(sample);

if (decision.requires_direction_setup_tick) {
    motor[m]->set_direction(decision.direction);
} else if (decision.should_step) {
    motor[m]->step();
    unstep.set(m);
}

if (sample.logical_motion_complete &&
    motor[m]->compensation_has_settled()) {
    motor[m]->stop_moving();
}
Persistent motor state

Engagement crosses Block boundaries.

Direction is not merely a bit on the current Block. It is a remembered physical relationship between the shaft and the driven load.

Positive engaged

Use b(+). Positive mechanical flank is carrying force.

Crossing clearance

Slew toward the opposite offset. Load motion is not inferred from these take-up pulses.

Negative engaged

Use b(-). Negative mechanical flank is carrying force.

snext changes only when |Δq| ≥ qengage and |v| ≥ vengage

This hysteresis prevents tiny delta segments, rounding noise, or near-zero oscillation from commanding repeated full backlash take-up moves.

Machine topology

Compensate actuators, not Cartesian labels.

Smoothie’s arm solution already determines which motor motion realizes X, Y, and Z. Backlash must follow that same boundary.

CoreXY makes the reason obvious

A Cartesian X reversal can reverse both motors, one motor, or neither depending on the combined X/Y trajectory. Applying an X-axis backlash offset before inverse kinematics would model the wrong physical component.

CoreXY actuator mapping diagram Two motor coordinates alpha and beta combine to create Cartesian X and Y motion, so compensation remains per motor. CARRIAGE MOTOR α MOTOR β +Y +X α = X + Y    β = X - Y
Position and recovery semantics

Raw shaft position is no longer tool position.

The feature is incomplete unless reports, homing, manual steps, probes, limits, and emergency stops all agree on which coordinate they use.

M114.1 / M114.2

Report estimated load

Forward kinematics must receive compensated load coordinates, not the raw pulse counter.

M114.3

Expose raw actuator state

Keep shaft position observable for calibration, diagnostics, and comparison with the load estimate.

ON_HALT

Freeze, then reconcile

Do not issue a final correction. Recover logical machine position from the frozen load estimate.

Homing

Establish known engagement

Disable dynamic lead while probing a stop, then initialize static state from the final homing direction.

src/libs/StepperMotor.hposition ownership sketch
class StepperMotor : public Module {
public:
    int32_t get_commanded_motor_step() const {
        return commanded_motor_steps;
    }

    int32_t get_estimated_load_step() const {
        return motion_compensator.estimated_load_steps();
    }

    float get_estimated_load_position() const {
        return get_estimated_load_step() / steps_per_mm;
    }

private:
    volatile int32_t commanded_motor_steps;
    StepperMotionCompensator motion_compensator;
};
Configuration and bounds

Disabled by default. Explicit in steps.

Calibration belongs to each physical motor. Step units work for linear, rotary, and coupled kinematics without pretending every actuator is millimetric.

configproposed per-actuator settings
# Static free-play model
alpha_motion_compensation_enable             true
alpha_backlash_positive_steps                12
alpha_backlash_negative_steps                14
alpha_backlash_engagement_min_steps          3

# Dynamic feed-forward model
alpha_dynamic_velocity_lead_ms               0.20
alpha_dynamic_acceleration_lead_ms2          0.015
alpha_dynamic_static_load_bias_steps         0
alpha_dynamic_max_lead_steps                 24
alpha_dynamic_slew_steps_per_second          4000

# Pulse budget and safe state
alpha_compensation_step_rate_margin          0.10
alpha_homing_engagement_direction            negative
|vlogical| + |dΔ/dt| ≤ (1 - margin) fticker

The 100 kHz ticker can emit at most one pulse per motor per tick. Compensation needs real pulse-rate headroom. If the bound cannot be met, the planner-visible rate must be reduced before execution.

Implementation sequence

Ship the state model before the dynamic model.

Static compensation establishes the architectural boundary, position semantics, and tests. Dynamic feed-forward then becomes one additional offset producer.

Establish state

Split logical, shaft, and load coordinates

Add the compensator shell, raw versus estimated position accessors, reset semantics, and deterministic fixed-point types. Compensation still returns zero.

Take up free play

Implement directional backlash

Add engagement memory, asymmetric offsets, reversal thresholds, bounded pulse chasing, and static Cartesian verification.

Close every lifecycle seam

Homing, halt, probe, limits, manual motion

Prove that every path which currently reads or resets current_position_steps uses the intended coordinate after compensation is enabled.

Add dynamic lead

Introduce bounded feed-forward

Start experimental and disabled. Add filtering, clamping, slew limits, telemetry, and measured calibration patterns before broad use.

src/libs/StepperMotionCompensator.cppcombined offset sketch
int64_t StepperMotionCompensator::desired_motor_position_fp(
    const MotionSample& sample
) {
    const Direction engagement = update_engagement(sample);
    const int64_t static_offset_fp = offset_for(engagement);

    const int64_t raw_dynamic_offset_fp =
        multiply_fp(velocity_lead, sample.logical_velocity_fp) +
        multiply_fp(acceleration_lead, sample.logical_acceleration_fp) +
        directional_load_bias_fp(engagement);

    const int64_t dynamic_offset_fp = slew_and_clamp(raw_dynamic_offset_fp);

    return sample.logical_position_fp +
           static_offset_fp +
           dynamic_offset_fp;
}
Verification gates

Prove coordinates, pulses, and physics separately.

A green compiler is not evidence that a motion-control feature is safe. Each layer needs a different kind of proof.

Pure model tests

Same-direction moves preserve offset. One reversal emits exactly the calibrated offset change. Tiny chatter does not re-engage. Asymmetric values work in both directions. Clamp and slew bounds hold for every tick.

Ticker and pulse tests

No motor emits more than one pulse per ticker period. DIR setup time is respected. Logical trajectory duration remains deterministic. Block handoff has no missing or duplicate pulse.

Queue boundary tests

Engagement persists across Blocks, queue starvation, continuous motion, and segmentation. The ISR still consumes only ready, unlocked Blocks, and idle cleanup remains allocation-safe.

Position semantic tests

M114 variants distinguish logical, estimated load, and shaft coordinates. Halt recovery does not shift machine coordinates by the active offset. Homing initializes a known engagement state.

Kinematic tests

Cartesian, CoreXY, and delta fixtures prove that compensation follows actuators rather than axis labels. Nonlinear segmentation must not create engagement chatter near a turning point.

Physical machine tests

Use dial-indicator reversal patterns at multiple positions and speeds. Validate low-speed static backlash first, then acceleration-dependent residuals. Keep the dynamic model disabled until measurements justify it.

e(t) = qreference(t) - xmeasured(t)

The final metric is measured load error. Internal agreement between logical counters and emitted pulses is necessary, but it cannot validate an open-loop physical model by itself.

Design decisions

What this proposal is, and is not.

Keeping the boundary narrow is what makes the feature understandable, testable, and safe enough to tune.

This is

  • A per-motor transformation from logical actuator motion to physical pulse demand
  • A persistent state machine spanning queue Blocks
  • A static hysteresis model plus optional dynamic feed-forward
  • A source of explicit load-position estimates and diagnostics
  • Bounded by ticker frequency, pulse timing, and configured safety limits

This is not

  • A Cartesian coordinate fudge added before inverse kinematics
  • Extra pulses hidden inside StepperMotor::step()
  • A closed-loop servo without an encoder
  • A reason to change Grbl junction or trapezoid mathematics
  • A universal coefficient set that can be guessed without measurement
The complete mental model

Plan the load. Command the mechanism.

Smoothie keeps its exact logical trajectory. Each actuator supplies the extra shaft motion required by its own mechanics.

u(t) = q(t)
+ b(s)
+ τvv(t)
+ τa2a(t)
+ Bload sign(v)

Static offset handles engagement. Dynamic lead handles modeled load. The pulse scheduler realizes u while every upstream coordinate remains q.