Skip to content

Fix spindle position-sync jitter and catch infeasible thread pitches - #4441

Open
grandixximo wants to merge 7 commits into
LinuxCNC:masterfrom
grandixximo:g33-sync-fixes
Open

Fix spindle position-sync jitter and catch infeasible thread pitches#4441
grandixximo wants to merge 7 commits into
LinuxCNC:masterfrom
grandixximo:g33-sync-fixes

Conversation

@grandixximo

@grandixximo grandixximo commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Five fixes to spindle-synchronized motion, one per commit.

tp: fix velocity jitter in spindle position sync (fixes #164)

The error correction added sqrt(x_err * a_max) to the tracking velocity. That is the velocity needed to close the error from a standstill, so it is only right when the tracking velocity is zero, which during tracking it never is. It over-corrected, and its gain diverged as the error went to zero, so the loop limit-cycled at the servo rate. This is the acceleration jitter in the plot on #4391.

v_p = sqrt(v_0^2 + x_err * a_max) accounts for the non-zero tracking velocity. The formula and derivation are Robert W. Ellenberg's, from PR #581, which has been open since 2019 and carries 283 files; this is the part that fixes #164, on its own.

Measured on a G33 pass with a 256 count/rev encoder, the acceleration standard deviation drops from 38.3 to 7.4 and the RMS tracking error from 1.25e-4 to 7.7e-5 in. With an ideal encoder the jitter goes to zero exactly. The residual at 256 count/rev is the one-cycle backward difference of a quantized position, which no correction formula fixes; see below.

interp: reject a thread pitch the axes cannot follow (refs #4391, #167)

Nothing rejected a feed the machine cannot deliver: the planner clamped the velocity, the axis fell behind, and the thread was cut wrong with no message. The check runs at interpret time so the offending line is named before it cuts.

Two points for review:

  • The bound is the per-axis maximum, not the traj maximum. tpGetMaxTargetVel exempts position-synced moves from the max velocity slider, so the traj value would reject valid programs with the slider down.
  • G33.1 is checked against K times I, since I multiplies the retract speed.

Needs a new GET_EXTERNAL_AXIS_MAX_VELOCITY canon call. The standalone interpreter has no machine and reports zero, which skips the check.

motion: fault when the spindle outruns a synchronized move (refs #4391)

The interpreter check only sees the commanded S word. The spindle can still outrun the axis at run time through the override, constant surface speed, or a drive that overshoots. This raises an error and aborts instead of clamping in silence.

Demand is measured as revolutions turned across a 0.25 s window times the pitch, the one quantity with no transient in it: the tracking error carries the v^2/2a lag every G33 picks up while the axis ramps up from rest, which is several pitches on a coarse thread or a slow axis, and a single-cycle spindle velocity is buried in encoder quantization noise. The axis must also be pinned at its ceiling, which excludes the ramp at the start and the stop on the endpoint at the end.

interp: suspend the spindle override during G33 and G76

A thread is cut in several passes over the same helix, so moving the spindle speed part way through shifts the lead. The override is suspended for a G33 move and a G76 cycle, restoring whatever M48, M49 or M51 last selected. Commercial controls inhibit it during thread cutting too.

G33.1 is deliberately left alone, as it is on at least one commercial control: a tap is self-guiding in its own hole, so there is no lead to spoil, and slowing the spindle while it is in the work is useful. That is also why the run-time fault still matters, since G33.1 is where the override stays live.

The G76 half is superseded by the last commit, which holds the override instead.

interp: check the thread pitch in constant surface speed mode

The check above only ran in G97. In G96 the S word is a surface speed, so the spindle speed depends on the radius and there was no single number to check against. The worst case is knowable: the speed at the smallest cutting radius the move reaches, capped by the G96 D word and by [SPINDLE_n]MAX_FORWARD_VELOCITY, which are the caps motion itself applies. A G96 thread that never reaches a small radius still passes.

The ini limit now bounds G97 as well, where an S word above what the spindle can turn would otherwise reject a move the machine would simply run slower.

Needs a GET_EXTERNAL_SPINDLE_MAX_VELOCITY canon call alongside the axis one, and the D word kept in interpreter state rather than only passed to canon. If the move reaches the centre of rotation and neither cap is configured the speed is unbounded and the check is skipped; the run-time fault still covers that case.

interp: restore the spindle override after an aborted thread

Abort clears the interpreter list, taking the queued restore with it, and nothing else re-enables the override. It stays dead until a later thread completes. Re-asserted in Interp::on_abort, active spindle only: the command carries a spindle number but motion writes one global bit, so looping over all of them has the inactive ones clear it again.

tp: hold the spindle override for a G76 cycle instead of dropping it

A G76 cycle is one block, so the override can be held at the value it started with rather than forced to the programmed speed. Threading at 80% then needs no edit to S. G33 is one pass per line with the retract in between, so it keeps the plain suspend.

New SS_LOCKED bit on the per-segment enables mask, no new NML message. Motion latches min(scale, 1.0) on the rising edge, so the value is the one at the first cut, and the cap keeps the pitch check valid. Dropped on abort.

Fanuc, Siemens and Haas all take the override away for a thread; none holds the entry value.

Sim lathe, S300, 0.05 in/rev: 80% entry cuts every pass at 240 and a slider move to 40% mid-cycle does nothing; 120% entry cuts at 300; G33 at 80% still runs at 300; abort mid-cycle leaves the override live.

Testing

Simulation only. runtests passes.

The two checks were swept over both unit systems, machine velocities from 0.05 to 100 units/s, and encoder resolutions from 16 counts/rev to ideal. No false positives on valid threads running at the axis limit, which is the case that arms the check; a single fault and abort on every spindle runaway. Also verified that an override requested during a G33 has no effect and is restored afterwards, and that G33.1 keeps the override and still faults if that override outruns the axis.

The constant surface speed path was checked in both radius and diameter mode, against the D word and against the ini limit with no D word, and on a large radius thread where the speed never approaches either cap and must not be rejected.

I have no lathe. @Sigma1912 offered real hardware on #4391; that is the test that matters here, particularly for the residual jitter at coarse encoder resolutions, which simulation cannot speak to honestly. Holding for those results.

Not included

PR #581 also adds a spindle velocity estimator and switches the planner to spindle.N.speed-in. The second half is the interesting one: a real encoder component derives velocity from time between edges rather than counts per period, and the planner already trusts speed-in for G95 while computing its own difference for G33.

It needs a way to know whether speed-in is connected. The dummysig aliasing used for that in #581, copied from pid.c, writes into a HAL IN pin and clobbers the signal if a user has netted it, and per #4382 the HAL query API must not be called from realtime. An explicit opt-in is the way to do it, in its own change.

#167 looks like a duplicate of #4391 and is not auto-closed here.

@BsAtHome

Copy link
Copy Markdown
Contributor

Tried the same test example again as in #164 and #4391 which now has a Zaccel jitter in the order of about ±2e-10. That is about 11 orders of magnitude better than previously. If that holds with real hardware, then I'd say that part is solved ;-)

@Sigma1912

Sigma1912 commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Some results from my test setup:

Spindle is a servomotor with encoder output from the drive set to 36kcounts/rev.
Note that the encoder velocity comes from the mesa hardware:

# encoder.00.velocity-rpm is position-units/min (ie °/min) so we must divide by 360 to get revs/min
net spindle-enc-vel-upm       <= hm2_[MESA](BOARD).0.encoder.04.velocity-rpm => scale.enc-vel.in
setp scale.enc-vel.gain   0.00277778  #1/360
net spindle-vel               <= scale.enc-vel.out                   => spindle.0.speed-in

Axes are setup as an X/Y gantry with the X axis parallel to the spindle axis.

Jitter reduction

(left: current master, right: new code )

Screenshot from 2026-08-22 09-19-43

reject a thread pitch the axes cannot follow

Seems to work as expected

suspend the spindle override during G33 and G76

tested with the axis gui slider

Seems to work as expected.
- G33 suspends spindle override
- G33.1 respects spindle override
- G76 suspends spindle override

fault when the spindle outruns a synchronized move

Haven't quite figured out how to test that.

Also tested

Tapered threads in both G96 and G97

@grandixximo

Copy link
Copy Markdown
Contributor Author

Thanks @Sigma1912, that is the hardware confirmation I was hoping for. Good to see the jitter result hold on a real servo spindle at 36k counts/rev, and that tapered threads work in both G96 and G97.

On the overrun fault: you would have to deliberately break something to see it, and that is the intent. The interpreter check already rejects any pitch and S combination the axes cannot follow, so the runtime fault only fires when the spindle actually turns faster than what was commanded. That means feedback or drive side trouble: a wrong encoder scale, the wrong gear range selected, a wrong or slipping pulley, a drive overspeeding. In those cases the axis is already pinned at its limit and losing the thread, so aborting beats cutting a bad part.

If you want to force it, scale the spindle position feedback up. On your setup, multiply the hm2 encoder scale for the spindle by 4, then run a G33 whose pitch and S already put the X axis close to its limit. LinuxCNC will count four times the revolutions, demand four times the axis velocity, and trip within a quarter second. Put the scale back afterwards.

The half that matters more is the one you cannot see: it must not fire during normal correct operation. The detector only arms once the axis is already saturated at 99% of its max velocity, and then it still needs the spindle to demand more than 102% of that max averaged over a 0.25 second window. A thread that legitimately runs the axis right at its limit stays clear, and neither the entry lag nor the deceleration at the end of the move arms it. That is why it measures spindle demand over a window instead of instantaneous following error: the entry lag alone is many times larger than any sane error threshold, so an error based test gives false positives on perfectly good threads.

So if it never fires for you, that is the correct result. Worth reporting only if it ever fires on a cut that was actually good.

@Sigma1912

Copy link
Copy Markdown
Contributor

I've changed my config so it resembles a lathe (Z parallel to the spindle rotation).

Looks good to me:

Axis Z max_velocity is set to 300 so at 1000Rpm, g33 z100 k18 cuts fine (tried about 10 times).

Trying to run anything over pitch 18 (like g33 z100 k18) aborts with error:

G33 pitch 18.1 at spindle speed 1000 needs 18100 per minute on the Z axis, which exceeds its maximum velocity of 18000

Reducing my spindle encoder scale by 10% so the spindle speed registers that much faster I now get the overrun error after the ~0.25s.

Screenshot from 2026-08-22 13-38-14

@grandixximo

Copy link
Copy Markdown
Contributor Author

Thanks, that settles it for me, ready to merge, waiting for approval from another dev. It should merge faster than last time ;-)

@Sigma1912

Copy link
Copy Markdown
Contributor

One more thing, running gcode programs:

This one with G97 is caught by the interpreter, so it errors out before running:

g97 s1000 m3
g76 p18.02 z100 i-2 j1 k2

m2

This one with G96 is not caught by the interpreter, so it runs and errors out when executing G76:

g96 D2000 s2000 m4
g0 x1
g76 p18 z100 i-2 j1 k2

m2

Is that the expected behavior?

@grandixximo

Copy link
Copy Markdown
Contributor Author

Folded it in, thanks for finding it.

The G96 case is now checked at interpret time. The speed used is the fastest the move can reach, which is the speed at its smallest cutting radius, capped the same way motion caps it: by the G96 D word and by [SPINDLE_n]MAX_FORWARD_VELOCITY. Your G96 example errors before it runs now, and so does the same program with the D word removed, provided the ini carries a spindle limit. A G96 thread that stays at a large radius and never approaches either cap still runs, so this does not reject valid programs.

One side effect worth knowing about: the ini spindle limit now bounds G97 too. A program asking for S3000 on a spindle configured for 2000 is checked at 2000, because that is what the machine will turn. Before this it would have been checked at 3000 and could have rejected a move the machine would simply have run slower.

Not covered: if the move crosses the centre of rotation and neither the D word nor [SPINDLE_n]MAX_FORWARD_VELOCITY is set, the speed is unbounded and the check is skipped. The run-time fault is still there for that.

Tested in both radius and diameter mode. runtests passes, 297 of 297.

The error correction added sqrt(x_err * a_max) to the tracking velocity.
That is the velocity needed to close the error from a standstill, so it
is only right when the tracking velocity is zero, which during spindle
tracking it never is. It over-corrected, and its gain diverged as the
error went to zero, so the loop limit-cycled at the servo rate: the axis
chattered and the acceleration slammed between its limits every cycle
even with a noise-free encoder.

Use v_p = sqrt(v_0^2 + x_err * a_max), which accounts for the non-zero
tracking velocity. In simulation the jitter goes away entirely with an
ideal encoder; at 256 count/rev the acceleration standard deviation drops
from 38.3 to 7.4 and the RMS tracking error from 1.25e-4 to 7.7e-5 in.

Formula by Robert W. Ellenberg, from the unmerged PR LinuxCNC#581.

Fixes LinuxCNC#164
G33, G33.1 and G76 take the axis feed from pitch times spindle speed, and
nothing rejected a feed the machine cannot deliver: the planner clamped
the velocity, the axis fell behind, and the thread was cut wrong with no
message.

Check the pitch against the per-axis maximum velocity at interpret time,
so the offending line is named before it cuts. The feed is projected onto
each axis by its share of the move length, as the planner distributes it.

The bound is the per-axis limit rather than the traj maximum because
tpGetMaxTargetVel exempts position-synced moves from the max velocity
slider, so the traj value would reject valid programs with the slider
down. G33.1 uses K times I, since I multiplies the retract speed.

Needs a new GET_EXTERNAL_AXIS_MAX_VELOCITY canon call. The standalone
interpreter has no machine and reports zero, which skips the check.

Refs LinuxCNC#4391, LinuxCNC#167
The interpreter check only sees the commanded S word. The spindle can
still outrun the axis at run time through the spindle override, constant
surface speed, or a drive that overshoots, and the planner has no answer
but to clamp and cut a wrong thread in silence. Raise an error and abort
instead.

Demand is measured as revolutions turned across a 0.25 s window times the
pitch, the one quantity here with no transient in it: the tracking error
carries the v^2/2a lag every G33 picks up while the axis ramps up from
rest, and a single-cycle spindle velocity is buried in encoder
quantization noise. The axis must also be pinned at its ceiling, which
excludes the ramp at the start and the stop on the endpoint at the end.

The planner is a separate module and can neither report to the operator
nor abort re-entrantly from its own cycle, so it records the fault in
emcmotStatus and the controller raises it.

Refs LinuxCNC#4391
A thread is cut in several passes over the same helix, so moving the
spindle speed part way through shifts the lead. Suspend the override for
the duration of a G33 move and a G76 cycle, restoring whatever M48, M49
or M51 last selected. Commercial controls inhibit it during thread
cutting too.

G33.1 is deliberately left alone, as it is on at least one commercial
control: a tap is self-guiding in its own hole, so there is no lead to
spoil, and slowing the spindle while it is in the work is useful.
G96 sets a surface speed, so the spindle speed depends on where the tool is
and the pitch check had to skip it.  Use the fastest speed the move can reach
instead: the speed at its smallest cutting radius, capped by the G96 D word
and by [SPINDLE_n]MAX_FORWARD_VELOCITY, which is how motion caps it too.

The ini limit now bounds G97 as well, so an S above what the spindle can turn
no longer rejects a move the machine would run slower anyway.
@Sigma1912

Copy link
Copy Markdown
Contributor

Nice. Thanks for taking this on.

@Sigma1912

Copy link
Copy Markdown
Contributor

Could we maybe freeze the set spindle override when running G33, G76 instead of ignoring it?

G33 and G76 suspend the spindle override and queue the restore behind the move. An abort clears the interpreter list, so the restore is lost and nothing else re-enables it: the override stays dead until a later thread completes, and Interp::synch() reads that back as the modal M48 or M49 state.

Re-assert it in Interp::on_abort, on the active spindle only. Motion keeps one global enable bit, so looping over every spindle has the inactive ones clear it again.
A G76 cycle is a single block, so the override can be held at the value in effect when it starts rather than forced down to the programmed speed. Threading at 80% then needs no edit to S. A G33 thread is one pass per line with the retract in between, so there is no span to hold and it keeps the plain suspend.

The hold rides on the per-segment enables mask, which already carries the suspend correctly through read-ahead. Motion latches min(scale, 1.0) on the rising edge, so the value is the one in effect when the first cut starts, and drops the hold on abort.

Sim lathe, S300, 0.05 in/rev: entering at 80% cuts every pass at 240 and moving the slider to 40% mid-cycle has no effect; entering at 120% cuts at 300; G33 at 80% still runs at 300; aborting mid-cycle leaves the override live.
@grandixximo

Copy link
Copy Markdown
Contributor Author

@Sigma1912 on the override question from #4453.

The ban stays, but the reason in the docs was wrong and is fixed here. The commanded position follows the spindle, so the thread lands in the same place at any speed. What does not is the axis lag, which is proportional to speed: your 0.046 mm residual between 1000 and 600 rpm is that lag. Move the override mid-thread and the tool shifts that far along the helix it is already cutting, 1.5% of a 3 mm pitch, into the flank. Fanuc says the same in the background of US7039493B2, and clamps the override during threading by default.

Locking what you come in with is a fair ask though, and a G76 cycle is a single block, so it can be done. Pushed here: the override is held for the whole cycle at whatever was in effect when it started, capped at 100% so the pitch check stays valid. Enter at 80% and every pass cuts at 80%, the slider does nothing during the cycle, and the previous setting returns at the end. G33 is one pass per line with your retract in between, so there is no span to hold and it keeps the plain suspend.

Second commit: aborting mid-thread left the override dead, since the restore is queued behind the move and abort discards the queue.

Worth trying if you have time: a G76 entered at a reduced override, and an abort mid-thread.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Excessive jitter spindle position tracking

3 participants