From 309c3921317271cbf1a3ccbb6deb97c100667fc2 Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:52:11 +1000 Subject: [PATCH 1/4] tp: fix velocity jitter in spindle position sync 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 #581. Fixes #164 --- src/emc/tp/tp.c | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/src/emc/tp/tp.c b/src/emc/tp/tp.c index 4d0d59d9091..85074d4c4ca 100644 --- a/src/emc/tp/tp.c +++ b/src/emc/tp/tp.c @@ -3617,14 +3617,31 @@ STATIC void tpSyncPositionMode(TP_STRUCT * const tp, TC_STRUCT * const tc, // we have synced the beginning of the move as best we can - // track position (minimize pos_error). tc_debug_print("tracking in pos_sync\n"); - double errorvel; spindle_vel = (tp->spindle.revs - oldrevs) / tp->cycleTime; target_vel = spindle_vel * tc->uu_per_rev; - errorvel = pmSqrt(fabs(pos_error) * tcGetTangentialMaxAccel(tc)); - if(pos_error<0) { - errorvel *= -1.0; - } - tc->target_vel = target_vel + errorvel; + + /* Correct the position error without losing the spindle: rise above + * the tracking velocity v_0 and come back to it, so the area of the + * blip is the error. + * + * velocity + * | v_p + * | /\ + * | /..\ v_0 + * |--------....----------- + * | .... + * | .... + * |_________________________ + * |----| t time + * + * That gives v_p = sqrt(v_0^2 + x_err*a_max). The + * old form added sqrt(x_err*a_max) to v_0, which is the same with v_0 + * taken as zero, so it over-corrected and its gain diverged as the + * error went to zero, limit-cycling at the servo rate. + * From robEllenberg, PR #581. */ + double a_max = tcGetTangentialMaxAccel(tc); + double v_sq = pmSq(target_vel) + pos_error * a_max; + tc->target_vel = pmSqrt(fmax(v_sq, 0.0)); } //Finally, clip requested velocity at zero From b19b6447878ce47fa2072db2ad33f5e8a54867bd Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:53:38 +1000 Subject: [PATCH 2/4] interp: reject a thread pitch the axes cannot follow 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 #4391, #167 --- docs/src/gcode/g-code.adoc | 16 +++--- src/emc/nml_intf/canon.hh | 4 ++ src/emc/rs274ngc/canonmodule.cc | 1 + src/emc/rs274ngc/gcodemodule.cc | 1 + src/emc/rs274ngc/interp_check.cc | 77 +++++++++++++++++++++++++++++ src/emc/rs274ngc/interp_convert.cc | 42 +++++++++++++++- src/emc/rs274ngc/rs274ngc_interp.hh | 2 + src/emc/sai/saicanon.cc | 6 +++ src/emc/task/emccanon.cc | 15 ++++++ 9 files changed, 153 insertions(+), 11 deletions(-) diff --git a/docs/src/gcode/g-code.adoc b/docs/src/gcode/g-code.adoc index 6253e57afb2..e110e31d62e 100644 --- a/docs/src/gcode/g-code.adoc +++ b/docs/src/gcode/g-code.adoc @@ -1113,11 +1113,8 @@ It is an error if: * An F word is given (the feed follows from K and the spindle speed). * The selected spindle is not commanded to turn (M3 or M4 active) when this command is executed. - -[NOTE] -The pitch and the spindle speed together set the axis feed. If they ask for -more than the machine can deliver, the program is not rejected: there is no -interpreter check and no error message for that case. +* The pitch and the commanded spindle speed together ask an axis to move + faster than its maximum velocity. [[gcode:g33.1]] == G33.1 Rigid Tapping(((G33.1 Rigid Tapping))) @@ -1186,11 +1183,8 @@ It is an error if: * An F word is given (the feed follows from K and the spindle speed). * The selected spindle is not commanded to turn (M3 or M4 active) when this command is executed. - -[NOTE] -The pitch and the spindle speed together set the axis feed. If they ask for -more than the machine can deliver, the program is not rejected: there is no -interpreter check and no error message for that case. +* The pitch and the commanded spindle speed together ask an axis to move + faster than its maximum velocity. [[gcode:g38]] == G38._n_ Straight Probe(((G38.n Probe))) @@ -2046,6 +2040,8 @@ It is an error if: * All the required words are not specified. * 'P-', 'J-', 'K-' or 'H-' is negative. * 'E-' is greater than half the drive line length. +* The pitch and the commanded spindle speed together ask an axis to move + faster than its maximum velocity. .HAL Connections The pins 'spindle.N.at-speed' and the 'encoder._n_.phase-Z' for the diff --git a/src/emc/nml_intf/canon.hh b/src/emc/nml_intf/canon.hh index 916b3e92971..05e21ef106f 100644 --- a/src/emc/nml_intf/canon.hh +++ b/src/emc/nml_intf/canon.hh @@ -874,6 +874,10 @@ below. extern double GET_EXTERNAL_ANGLE_UNIT_FACTOR(); */ +// Returns the maximum velocity of one axis, indexed 0-8 as XYZABCUVW, in +// program units per minute, or zero if that limit is not available +extern double GET_EXTERNAL_AXIS_MAX_VELOCITY(int axis); + // Returns the system feed rate extern double GET_EXTERNAL_FEED_RATE(); diff --git a/src/emc/rs274ngc/canonmodule.cc b/src/emc/rs274ngc/canonmodule.cc index 3399fb2fc91..8e32184575e 100644 --- a/src/emc/rs274ngc/canonmodule.cc +++ b/src/emc/rs274ngc/canonmodule.cc @@ -178,6 +178,7 @@ BOOST_PYTHON_MODULE(emccanon) { def("GET_EXTERNAL_TOOL_LENGTH_ZOFFSET",&GET_EXTERNAL_TOOL_LENGTH_ZOFFSET); def("GET_EXTERNAL_TOOL_SLOT",&GET_EXTERNAL_TOOL_SLOT); def("GET_EXTERNAL_TOOL_TABLE",&GET_EXTERNAL_TOOL_TABLE); + def("GET_EXTERNAL_AXIS_MAX_VELOCITY",&GET_EXTERNAL_AXIS_MAX_VELOCITY); def("GET_EXTERNAL_TRAVERSE_RATE",&GET_EXTERNAL_TRAVERSE_RATE); def("GET_OPTIONAL_PROGRAM_STOP",&GET_OPTIONAL_PROGRAM_STOP); def("INIT_CANON",&INIT_CANON); diff --git a/src/emc/rs274ngc/gcodemodule.cc b/src/emc/rs274ngc/gcodemodule.cc index a6114cf1808..768438a6a83 100644 --- a/src/emc/rs274ngc/gcodemodule.cc +++ b/src/emc/rs274ngc/gcodemodule.cc @@ -1066,6 +1066,7 @@ CANON_DIRECTION GET_EXTERNAL_SPINDLE(int) { return CANON_STOPPED; } int GET_EXTERNAL_TOOL_SLOT() { return 0; } int GET_EXTERNAL_SELECTED_TOOL_SLOT() { return 0; } double GET_EXTERNAL_FEED_RATE() { return 1; } +double GET_EXTERNAL_AXIS_MAX_VELOCITY(int /*axis*/) { return 0; } double GET_EXTERNAL_TRAVERSE_RATE() { return 0; } int GET_EXTERNAL_FLOOD() { return 0; } int GET_EXTERNAL_MIST() { return 0; } diff --git a/src/emc/rs274ngc/interp_check.cc b/src/emc/rs274ngc/interp_check.cc index 196f2772763..6eb889f5527 100644 --- a/src/emc/rs274ngc/interp_check.cc +++ b/src/emc/rs274ngc/interp_check.cc @@ -393,3 +393,80 @@ int Interp::check_other_codes(block_pointer block) //!< pointer to a block return INTERP_OK; } + +/****************************************************************************/ + +/*! check_spindle_sync_feed + +Returned Value: int + Returns an error if any axis of the move would have to run faster than its + maximum velocity to hold the commanded pitch at the commanded spindle speed. + Otherwise returns INTERP_OK. + +Side effects: none + +Called by: + Interp::convert_straight (G33, G33.1) + Interp::convert_threading_cycle (G76) + +Nothing downstream rejects a feed the machine cannot deliver: the planner +clamps the velocity, the axis falls behind, and the thread is cut wrong. + +The bound is the per-axis maximum, not the traj maximum, because the max +velocity slider is deliberately not applied to position-synchronized moves (see +tpGetMaxTargetVel). The feed is projected onto each axis by its share of the +move length, as the planner distributes it. Rotary axes are ignored: a pitch +is a linear distance per revolution. + +Using the commanded S word means the error names the offending line and does +not depend on the spindle already running. Skipped in constant surface speed +mode, and for any axis whose limit is unavailable (the standalone interpreter +reports zero). + +*/ + +int Interp::check_spindle_sync_feed(setup_pointer settings, //!< pointer to machine settings + double pitch, //!< program units per revolution + const char *code, //!< G code name, for the message + const double delta[9]) //!< move, program units, XYZABCUVW +{ + static const char axis_name[] = "XYZABCUVW"; + int spindle = settings->active_spindle; + + if (settings->spindle_mode[spindle] != SPINDLE_MODE::CONSTANT_RPM) + return INTERP_OK; + + double speed = settings->speed[spindle]; + if (speed <= 0.0 || pitch == 0.0) + return INTERP_OK; + + double length = 0.0; + for (int ax = 0; ax < 9; ax++) { + if (ax >= 3 && ax <= 5) + continue; /* rotary */ + length += delta[ax] * delta[ax]; + } + length = sqrt(length); + if (length <= 0.0) + return INTERP_OK; + + /* program units per minute along the path */ + double required_rate = fabs(pitch) * speed; + + for (int ax = 0; ax < 9; ax++) { + if (ax >= 3 && ax <= 5) + continue; + if (delta[ax] == 0.0) + continue; + double max_rate = GET_EXTERNAL_AXIS_MAX_VELOCITY(ax); + if (max_rate <= 0.0) + continue; + double axis_rate = required_rate * fabs(delta[ax]) / length; + CHKS((axis_rate > max_rate), + _("%s pitch %g at spindle speed %g needs %g per minute on the %c axis, " + "which exceeds its maximum velocity of %g"), + code, fabs(pitch), speed, axis_rate, axis_name[ax], max_rate); + } + + return INTERP_OK; +} diff --git a/src/emc/rs274ngc/interp_convert.cc b/src/emc/rs274ngc/interp_convert.cc index 638e87eb0c3..9784807b19f 100644 --- a/src/emc/rs274ngc/interp_convert.cc +++ b/src/emc/rs274ngc/interp_convert.cc @@ -5114,6 +5114,26 @@ int Interp::convert_spindle_mode(int dollar_number, block_pointer block, setup_p } return INTERP_OK; } + +/* Displacement of a move, ordered XYZABCUVW. */ + +static void sync_move_delta(setup_pointer settings, + double end_x, double end_y, double end_z, + double AA_end, double BB_end, double CC_end, + double u_end, double v_end, double w_end, + double delta[9]) +{ + delta[0] = end_x - settings->current_x; + delta[1] = end_y - settings->current_y; + delta[2] = end_z - settings->current_z; + delta[3] = AA_end - settings->AA_current; + delta[4] = BB_end - settings->BB_current; + delta[5] = CC_end - settings->CC_current; + delta[6] = u_end - settings->u_current; + delta[7] = v_end - settings->v_current; + delta[8] = w_end - settings->w_current; +} + /****************************************************************************/ /*! convert_stop @@ -5526,6 +5546,10 @@ int Interp::convert_straight(int move, //!< either G_0 or G_1 CHKS(((settings->spindle_turning[settings->active_spindle] != CANON_CLOCKWISE) && (settings->spindle_turning[settings->active_spindle] != CANON_COUNTERCLOCKWISE)), _("Spindle not turning in G33")); + double delta[9]; + sync_move_delta(settings, end_x, end_y, end_z, AA_end, BB_end, CC_end, + u_end, v_end, w_end, delta); + CHP(check_spindle_sync_feed(settings, block->k_number, "G33", delta)); START_SPEED_FEED_SYNCH(settings->active_spindle, block->k_number, 0); STRAIGHT_FEED(block->line_number, end_x, end_y, end_z, AA_end, BB_end, CC_end, u_end, v_end, w_end); STOP_SPEED_FEED_SYNCH(); @@ -5541,7 +5565,6 @@ int Interp::convert_straight(int move, //!< either G_0 or G_1 CHKS(((settings->spindle_turning[settings->active_spindle] != CANON_CLOCKWISE) && (settings->spindle_turning[settings->active_spindle] != CANON_COUNTERCLOCKWISE)), _("Spindle not turning in G33.1")); - START_SPEED_FEED_SYNCH(settings->active_spindle, block->k_number, 0); double scale = 1; if(block->i_flag){ scale = block->i_number; @@ -5549,6 +5572,13 @@ int Interp::convert_straight(int move, //!< either G_0 or G_1 scale = 1; } } + double delta[9]; + sync_move_delta(settings, end_x, end_y, end_z, AA_end, BB_end, CC_end, + u_end, v_end, w_end, delta); + // I multiplies the spindle speed for the retract + CHP(check_spindle_sync_feed(settings, block->k_number * scale, "G33.1", + delta)); + START_SPEED_FEED_SYNCH(settings->active_spindle, block->k_number, 0); RIGID_TAP(block->line_number, end_x, end_y, end_z, scale); STOP_SPEED_FEED_SYNCH(); // after the RIGID_TAP cycle we'll be in the same spot @@ -5771,6 +5801,16 @@ int Interp::convert_threading_cycle(block_pointer block, double target_z = end_z + fabs(k_number) * tan(compound_angle); + // A taper also moves X by the thread height over the taper distance, at + // the correspondingly larger pitch. + double plain_pass[9] = {0.0, 0.0, target_z - start_z, 0, 0, 0, 0, 0, 0}; + CHP(check_spindle_sync_feed(settings, pitch, "G76", plain_pass)); + if (taper_dist != 0.0 && (entry_taper || exit_taper)) { + double taper_pass[9] = {full_threadheight, 0.0, taper_dist, + 0, 0, 0, 0, 0, 0}; + CHP(check_spindle_sync_feed(settings, taper_pitch, "G76", taper_pass)); + } + depth = start_depth; zoff = (depth - full_dia_depth) * tan(compound_angle); while (depth < end_depth) { diff --git a/src/emc/rs274ngc/rs274ngc_interp.hh b/src/emc/rs274ngc/rs274ngc_interp.hh index af7d27e58bc..21ee6c02156 100644 --- a/src/emc/rs274ngc/rs274ngc_interp.hh +++ b/src/emc/rs274ngc/rs274ngc_interp.hh @@ -223,6 +223,8 @@ public: int check_items(block_pointer block, setup_pointer settings); int check_m_codes(block_pointer block); int check_other_codes(block_pointer block); + int check_spindle_sync_feed(setup_pointer settings, double pitch, + const char *code, const double delta[9]); int close_and_downcase(char *line); void nurbs_reset_global_variables(void); int convert_nurbs(int move, block_pointer block, setup_pointer settings); diff --git a/src/emc/sai/saicanon.cc b/src/emc/sai/saicanon.cc index 8bbc3a6387c..6158a49cd54 100644 --- a/src/emc/sai/saicanon.cc +++ b/src/emc/sai/saicanon.cc @@ -969,6 +969,12 @@ extern CANON_TOOL_TABLE GET_EXTERNAL_TOOL_TABLE(int idx) #endif //} } +/* The standalone interpreter has no machine, so no axis limits */ +double GET_EXTERNAL_AXIS_MAX_VELOCITY(int /*axis*/) +{ + return 0.0; +} + /* Returns the system traverse rate */ double GET_EXTERNAL_TRAVERSE_RATE() { diff --git a/src/emc/task/emccanon.cc b/src/emc/task/emccanon.cc index 30ce5d54eaf..7a7932303b1 100644 --- a/src/emc/task/emccanon.cc +++ b/src/emc/task/emccanon.cc @@ -3815,6 +3815,21 @@ double GET_EXTERNAL_FEED_RATE() return feed; } +// maximum velocity of one axis, in program units per minute +double GET_EXTERNAL_AXIS_MAX_VELOCITY(int axis) +{ + if (axis < 0 || axis > 8 || !axis_valid(axis)) { + return 0.0; + } + + double vel = emcAxisGetMaxVelocity(axis); + + if (axis >= 3 && axis <= 5) { + return TO_PROG_ANG(FROM_EXT_ANG(vel)) * 60.0; + } + return TO_PROG_LEN(FROM_EXT_LEN(vel)) * 60.0; +} + // traverse rate wanted is in program units per minute double GET_EXTERNAL_TRAVERSE_RATE() { From 6b3f5c3f224ce703cdd929c3a04a49d757eca332 Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:53:57 +1000 Subject: [PATCH 3/4] motion: fault when the spindle outruns a synchronized move 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 #4391 --- src/emc/motion/control.c | 11 +++++++ src/emc/motion/motion.c | 2 ++ src/emc/motion/motion.h | 4 +++ src/emc/tp/tp.c | 65 +++++++++++++++++++++++++++++++++++++++- src/emc/tp/tp_types.h | 13 ++++++++ 5 files changed, 94 insertions(+), 1 deletion(-) diff --git a/src/emc/motion/control.c b/src/emc/motion/control.c index cdc87901a55..a86b7e7a25a 100644 --- a/src/emc/motion/control.c +++ b/src/emc/motion/control.c @@ -1350,6 +1350,17 @@ static void get_pos_cmds(long period) /* run coordinated trajectory planning cycle */ tpRunCycle(&emcmotInternal->coord_tp, period); + + if (emcmotStatus->syncOverrunSpindle) { + tpAbort(&emcmotInternal->coord_tp); + reportError(_("spindle-synchronized move exceeds axis limits: " + "spindle %d is outrunning the axis by %f per " + "second, reduce the spindle speed or the pitch"), + emcmotStatus->syncOverrunSpindle - 1, + emcmotStatus->syncOverrunError); + emcmotStatus->syncOverrunSpindle = 0; + SET_MOTION_ERROR_FLAG(1); + } /* get new commanded traj pos */ tpGetPos(&emcmotInternal->coord_tp, &emcmotStatus->carte_pos_cmd); diff --git a/src/emc/motion/motion.c b/src/emc/motion/motion.c index d2cb7615958..f3eef62aae4 100644 --- a/src/emc/motion/motion.c +++ b/src/emc/motion/motion.c @@ -890,6 +890,8 @@ static int init_comm_buffers(void) ZERO_EMC_POSE(emcmotStatus->carte_pos_cmd); ZERO_EMC_POSE(emcmotStatus->carte_pos_fb); emcmotStatus->vel = 0.0; + emcmotStatus->syncOverrunSpindle = 0; + emcmotStatus->syncOverrunError = 0.0; emcmotConfig->limitVel = 0.0; emcmotStatus->acc = 0.0; emcmotStatus->feed_scale = 1.0; diff --git a/src/emc/motion/motion.h b/src/emc/motion/motion.h index 1312b5e45dd..f31d4dedc0e 100644 --- a/src/emc/motion/motion.h +++ b/src/emc/motion/motion.h @@ -600,6 +600,10 @@ Suggestion: Split this in to an Error and a Status flag register.. emcmot_joint_status_t joint_status[EMCMOT_MAX_JOINTS]; /* all joint status data */ emcmot_axis_status_t axis_status[EMCMOT_MAX_AXIS]; /* all axis status data */ int spindleSync; /* spindle used for synchronised moves. -1 = none */ + int syncOverrunSpindle; /* spindle that outran the axis in a synced move, + plus one; 0 = none. Set by the planner, raised + by the motion controller. */ + double syncOverrunError; /* by how much per second */ spindle_status_t spindle_status[EMCMOT_MAX_SPINDLES]; /* all spindle data */ diff --git a/src/emc/tp/tp.c b/src/emc/tp/tp.c index 85074d4c4ca..12ec0379aaf 100644 --- a/src/emc/tp/tp.c +++ b/src/emc/tp/tp.c @@ -499,6 +499,9 @@ int tpInit(TP_STRUCT * const tp) tp->spindle.offset = 0.0; tp->spindle.revs = 0.0; + tp->spindle.overrun_cycles = 0; + tp->spindle.overrun_revs = 0.0; + tp->spindle.overrun_reported = 0; tp->spindle.waiting_for_index = MOTION_INVALID_ID; tp->spindle.waiting_for_atspeed = MOTION_INVALID_ID; @@ -3568,6 +3571,23 @@ STATIC void tpSyncVelocityMode(TP_STRUCT * const tp, TC_STRUCT * const tc, TC_ST } +/** + * Record a spindle-synchronized overrun for the motion controller to raise. + * The planner is a separate module and can neither report to the operator nor + * abort re-entrantly from inside its own cycle. + */ +STATIC void tpSyncOverrun(TP_STRUCT * const tp, double amount) +{ + if (tp->spindle.overrun_reported) { + return; /* one per move; it keeps slipping while it stops */ + } + emcmotStatus->syncOverrunSpindle = tp->spindle.spindle_num + 1; + emcmotStatus->syncOverrunError = amount; + tp->spindle.overrun_reported = 1; + tp->spindle.overrun_cycles = 0; +} + + /** * Run position mode synchronization. * Updates requested velocity for a trajectory segment to track the spindle's position. @@ -3612,6 +3632,15 @@ STATIC void tpSyncPositionMode(TP_STRUCT * const tp, TC_STRUCT * const tc, tc_debug_print("accelerating in pos_sync\n"); // beginning of move and we are behind: accel as fast as we can tc->target_vel = tc->maxvel; + + /* If the pitch at this speed needs more than the segment can + * deliver, the handoff above never happens and the whole move runs + * here clamped. spindle_vel is revs averaged over the move, so it + * is smooth enough to compare once settled. */ + if (tc->sync_accel * dt > TP_SYNC_OVERRUN_WINDOW && + target_vel > tc->maxvel * TP_SYNC_OVERRUN_MARGIN) { + tpSyncOverrun(tp, target_vel - tc->maxvel); + } } } else { // we have synced the beginning of the move as best we can - @@ -3619,7 +3648,6 @@ STATIC void tpSyncPositionMode(TP_STRUCT * const tp, TC_STRUCT * const tc, tc_debug_print("tracking in pos_sync\n"); spindle_vel = (tp->spindle.revs - oldrevs) / tp->cycleTime; target_vel = spindle_vel * tc->uu_per_rev; - /* Correct the position error without losing the spindle: rise above * the tracking velocity v_0 and come back to it, so the area of the * blip is the error. @@ -3639,9 +3667,41 @@ STATIC void tpSyncPositionMode(TP_STRUCT * const tp, TC_STRUCT * const tc, * taken as zero, so it over-corrected and its gain diverged as the * error went to zero, limit-cycling at the servo rate. * From robEllenberg, PR #581. */ + double dt = fmax(tp->cycleTime, TP_TIME_EPSILON); double a_max = tcGetTangentialMaxAccel(tc); double v_sq = pmSq(target_vel) + pos_error * a_max; tc->target_vel = pmSqrt(fmax(v_sq, 0.0)); + + /* Rigid tap reversals move the target by design, so watch only the + * tapping pass. */ + bool tap_reversing = (tc->motion_type == TC_RIGIDTAP) && + (tc->coords.rigidtap.state != TAPPING); + + /* Only an axis pinned at its ceiling can be outrun. Below it the + * error grows for reasons a correct G33 has anyway: the spindle turns + * while the axis ramps up, and again while it stops on the endpoint. */ + bool saturated = tc->currentvel >= tc->maxvel * TP_SYNC_OVERRUN_CEILING; + int window_cycles = (int)(TP_SYNC_OVERRUN_WINDOW / dt); + if (window_cycles < 1) { + window_cycles = 1; + } + + if (!tap_reversing && saturated) { + if (tp->spindle.overrun_cycles == 0) { + tp->spindle.overrun_revs = tp->spindle.revs; + } + if (++tp->spindle.overrun_cycles >= window_cycles) { + double window = window_cycles * dt; + double demand = fabs(tp->spindle.revs - tp->spindle.overrun_revs) + * fabs(tc->uu_per_rev) / window; + if (demand > tc->maxvel * TP_SYNC_OVERRUN_MARGIN) { + tpSyncOverrun(tp, demand - tc->maxvel); + } + tp->spindle.overrun_cycles = 0; + } + } else { + tp->spindle.overrun_cycles = 0; + } } //Finally, clip requested velocity at zero @@ -4224,6 +4284,9 @@ int tpSetSpindleSync(TP_STRUCT * const tp, int spindle, double sync, int mode) { } tp->uu_per_rev = sync; tp->spindle.spindle_num = spindle; + /* each synced move may report again */ + tp->spindle.overrun_reported = 0; + tp->spindle.overrun_cycles = 0; } else tp->synchronized = 0; diff --git a/src/emc/tp/tp_types.h b/src/emc/tp/tp_types.h index 6687ef3a2d1..0e9ab844322 100644 --- a/src/emc/tp/tp_types.h +++ b/src/emc/tp/tp_types.h @@ -46,6 +46,16 @@ #define TP_MIN_ARC_LENGTH 1e-6 #define TP_BIG_NUM 1e10 +/* The spindle has outrun a position-synchronized move when the feed it asks + * for, averaged over this window, exceeds the segment maximum velocity by this + * margin while the axis is already pinned at that ceiling. Demand is measured + * from the spindle alone, as revolutions turned times the pitch: the tracking + * error carries the v^2/2a lag every G33 picks up while the axis ramps up, and + * a single-cycle spindle velocity is buried in encoder quantization noise. */ +#define TP_SYNC_OVERRUN_WINDOW 0.25 +#define TP_SYNC_OVERRUN_MARGIN 1.02 +#define TP_SYNC_OVERRUN_CEILING 0.99 + /** * TP return codes. * This enum is a catch-all for useful return statuses from TP @@ -83,6 +93,9 @@ typedef struct { double revs; int waiting_for_index; int waiting_for_atspeed; + int overrun_cycles; /* cycles elapsed in the current overrun window */ + double overrun_revs; /* spindle position when that window opened */ + int overrun_reported; /* fault already raised for this synced move */ } tp_spindle_t; /** From 19ec92d13984399e7bb2c598fecaa667b5439ea4 Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:54:17 +1000 Subject: [PATCH 4/4] 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. 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. --- docs/src/gcode/g-code.adoc | 16 ++++++++++++++++ docs/src/gcode/m-code.adoc | 4 ++++ src/emc/rs274ngc/interp_convert.cc | 22 ++++++++++++++++++++++ tests/interp/g76/expected | 2 ++ 4 files changed, 44 insertions(+) diff --git a/docs/src/gcode/g-code.adoc b/docs/src/gcode/g-code.adoc index e110e31d62e..aae0194a8a5 100644 --- a/docs/src/gcode/g-code.adoc +++ b/docs/src/gcode/g-code.adoc @@ -1071,6 +1071,12 @@ K follows the drive line described by 'X- Y- Z-'. K is not parallel to the Z axis if X or Y endpoints are used for example when cutting tapered threads. +[NOTE] +The spindle speed override has no effect during a G33 move, and the previous +setting is restored when the move ends. A thread is cut in several passes over +the same helix, so changing the spindle speed part way through would change the +lead and spoil the thread. + [[gcode:g33-tech-info]] .Technical Info At the beginning of each G33 pass, LinuxCNC uses the spindle speed and @@ -1176,6 +1182,12 @@ M2 (end program) * See <> & <> & <> sections for more information. +[NOTE] +Unlike G33, the spindle speed override stays active during a G33.1 move and the +feed follows whatever the spindle actually does. A tap guides itself in its own +hole, so there is no thread lead to spoil, and being able to slow the spindle +while the tap is in the work is useful. + It is an error if: * All axis words are omitted. @@ -2043,6 +2055,10 @@ It is an error if: * The pitch and the commanded spindle speed together ask an axis to move faster than its maximum velocity. +[NOTE] +The spindle speed override has no effect during the G76 cycle, for the same +reason as G33, and the previous setting is restored when the cycle ends. + .HAL Connections The pins 'spindle.N.at-speed' and the 'encoder._n_.phase-Z' for the spindle must be connected in your HAL file before G76 will work. diff --git a/docs/src/gcode/m-code.adoc b/docs/src/gcode/m-code.adoc index 21a3da5d6a9..ffd215f6a48 100644 --- a/docs/src/gcode/m-code.adoc +++ b/docs/src/gcode/m-code.adoc @@ -307,6 +307,10 @@ no influence, and the spindle speed will have the exact program specified value of the S-word (described in the <> section). +The override is also suspended for the duration of a +<> move or a <> cycle, whatever M48, M49 or M51 +last selected, and that selection is restored afterwards. + [[mcode:m52]] == M52 Adaptive Feed Control diff --git a/src/emc/rs274ngc/interp_convert.cc b/src/emc/rs274ngc/interp_convert.cc index 9784807b19f..d48dfff5972 100644 --- a/src/emc/rs274ngc/interp_convert.cc +++ b/src/emc/rs274ngc/interp_convert.cc @@ -5115,6 +5115,23 @@ int Interp::convert_spindle_mode(int dollar_number, block_pointer block, setup_p return INTERP_OK; } +/* Thread cutting re-enters the same helix each pass, so moving the spindle + speed part way through shifts the lead. G33.1 deliberately keeps the + override: a tap is self-guiding and slowing down is useful. */ + +static void suspend_speed_override(setup_pointer settings) +{ + DISABLE_SPEED_OVERRIDE(settings->active_spindle); +} + +static void restore_speed_override(setup_pointer settings) +{ + /* back to what the program asked for, so an M49 or M51 P0 still holds */ + if (settings->speed_override[settings->active_spindle]) { + ENABLE_SPEED_OVERRIDE(settings->active_spindle); + } +} + /* Displacement of a move, ordered XYZABCUVW. */ static void sync_move_delta(setup_pointer settings, @@ -5550,9 +5567,11 @@ int Interp::convert_straight(int move, //!< either G_0 or G_1 sync_move_delta(settings, end_x, end_y, end_z, AA_end, BB_end, CC_end, u_end, v_end, w_end, delta); CHP(check_spindle_sync_feed(settings, block->k_number, "G33", delta)); + suspend_speed_override(settings); START_SPEED_FEED_SYNCH(settings->active_spindle, block->k_number, 0); STRAIGHT_FEED(block->line_number, end_x, end_y, end_z, AA_end, BB_end, CC_end, u_end, v_end, w_end); STOP_SPEED_FEED_SYNCH(); + restore_speed_override(settings); settings->current_x = end_x; settings->current_y = end_y; settings->current_z = end_z; @@ -5811,6 +5830,8 @@ int Interp::convert_threading_cycle(block_pointer block, CHP(check_spindle_sync_feed(settings, taper_pitch, "G76", taper_pass)); } + suspend_speed_override(settings); + depth = start_depth; zoff = (depth - full_dia_depth) * tan(compound_angle); while (depth < end_depth) { @@ -5829,6 +5850,7 @@ int Interp::convert_threading_cycle(block_pointer block, start_z, zoff, taper_dist, entry_taper, exit_taper, taper_pitch, pitch, full_threadheight, target_z); } + restore_speed_override(settings); STRAIGHT_TRAVERSE(block->line_number, end_x, end_y, end_z, AABBCC); settings->current_x = end_x; settings->current_y = end_y; diff --git a/tests/interp/g76/expected b/tests/interp/g76/expected index c2c4921112c..562e3d39861 100644 --- a/tests/interp/g76/expected +++ b/tests/interp/g76/expected @@ -31,6 +31,7 @@ N..... COMMENT("h = number of spring passes") N..... COMMENT("e = distance along drive line used for tapered start/end") N..... COMMENT("l = which ends get the taper: 0 = neither, 1 = begin, 2 = end, 3 = both") + N..... DISABLE_SPEED_OVERRIDE(0) N..... STRAIGHT_TRAVERSE(0.2370, 0.0000, 0.1955, 0.0000, 0.0000, 0.0000) N..... STRAIGHT_TRAVERSE(0.1170, 0.0000, 0.1955, 0.0000, 0.0000, 0.0000) N..... DISABLE_FEED_OVERRIDE() @@ -381,6 +382,7 @@ N..... STOP_SPEED_FEED_SYNCH() N..... STRAIGHT_TRAVERSE(0.2000, 0.0000, -0.5000, 0.0000, 0.0000, 0.0000) N..... ENABLE_FEED_OVERRIDE() + N..... ENABLE_SPEED_OVERRIDE(0) N..... STRAIGHT_TRAVERSE(0.2000, 0.0000, -0.5000, 0.0000, 0.0000, 0.0000) N..... STRAIGHT_TRAVERSE(0.5000, 0.0000, -0.5000, 0.0000, 0.0000, 0.0000) N..... STRAIGHT_TRAVERSE(0.5000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000)