diff --git a/.gitignore b/.gitignore index 776b630011..b8b9ab2f7e 100644 --- a/.gitignore +++ b/.gitignore @@ -26,9 +26,13 @@ /FMDC_V02_Debug/ /FMDC_V03/ /Duet3_MB6HC_no_SD/ +/Duet3_MB6HC_embedded/ /Duet3_MB6HC_no_S_curve/ -/INDX/ +/tests/build/ /.clangd /.clang-format /src/Temp/ -/.settings/ + +# Build intermediates and editor droppings that have been committed by accident before +INDX/ +.DS_Store diff --git a/Developer-documentation/Velocity jogging (M700).md b/Developer-documentation/Velocity jogging (M700).md new file mode 100644 index 0000000000..b988d3bfcf --- /dev/null +++ b/Developer-documentation/Velocity jogging (M700).md @@ -0,0 +1,120 @@ +# Velocity jogging — M700 + +Normal motion commands say *where* to go. `M700` says *how fast to go, and in which direction*, per axis, +so an analogue input such as a joystick can drive the machine directly. + +## Command + +``` +M700 X Y Z ... [S0] [P] [R] [D] +``` + +| Parameter | Meaning | +|---|---| +| axis letters | Signed speed for that axis in **mm/sec** (degrees/sec for rotational axes). `G20` does *not* rescale these. | +| `S0` | Stop jogging now. | +| `P` | Chunk time in ms, 10..200, default 20. See *Latency* below. | +| `R` | Watchdog timeout in ms, default 250. | +| `D` | How many moves to keep queued, 2..8, default 2. | +| none | Report status. | + +**The axis letters present define the whole velocity vector.** Any axis you do not mention is set to zero. +A truncated or partially-parsed command therefore cannot leave an axis running. + +Send a fresh `M700` whenever the stick moves, and at least every `R` milliseconds while it is off centre. + +```gcode +M700 X25 Y-12 ; X at +25 mm/s, Y at -12 mm/s, everything else stopped +M700 X25 ; Y now stops, X carries on +M700 S0 ; stop +``` + +## How it works + +Jogging synthesises a stream of short constant-velocity moves and feeds them to movement system 0 through +exactly the same path a `G1` takes: `MovementState::raw` → `GCodes::ReadMove` → `DDARing::AddStandardMove`. + +That reuse is the whole point of the design: + +* **Lookahead blends the chunks**, so a steady stick gives steady motion, and changing the stick direction + produces a normal cornered junction rather than a stop-start. +* **The last move in the ring is always planned to end at zero speed.** If the command stream dies — cable + pulled, host crashed, task starved — the machine decelerates to a stop under its normal acceleration + limits instead of stopping dead and losing steps. +* Per-axis speed and acceleration limits, kinematics, bed compensation and tool offsets all apply + unchanged. + +`JogController::Spin()` is called from `GCodes::Spin()` and tops the queue up whenever it holds fewer than +`D` moves. + +## Latency + +Measured on the emulator, timing from command injection to the step pins changing rate: + +| `D` | `P` | Latency | Ceiling | +|---|---|---|---| +| 5 | 50 ms | 257 ms | 100 mm/s | +| 3 | 20 ms | 126 ms | 40 mm/s | +| 2 | 30 ms | 90 ms | 60 mm/s | +| 2 | 25 ms | 67 ms | 50 mm/s | +| **2** | **20 ms** | **38.5 ms** | **40 mm/s** | +| 2 | 15 ms | 44.6 ms | 30 mm/s | +| 2 | 10 ms | never reaches 15 mm/s | 20 mm/s | + +The defaults are `D2 P20` because that is the measured optimum, not a compromise. Above it latency +tracks the queued chunk time `D x P`, as a FIFO should. Below roughly 40ms of queued motion it stops +following `D x P` and gets **worse**, and shortening `P` far enough stops the axis reaching the +commanded speed at all. + +**What that floor is not.** Three plausible explanations were measured and none of them holds: + +| hypothesis | test | result | +|---|---|---| +| `Move` wants `MoveTiming::UsualMinimumPreparedTime` queued | halved it, 50ms to 25ms | 50.3 -> 50.2 ms: no effect | +| lookahead grace period delays the first move | `M595 R0`, and `R0 P40` | ~2 ms | +| the host cannot send fast enough | doubled command rate to 10ms cadence | 0.3 ms | + +Sizing the chunk adaptively to the requested speed - a short chunk for a slow jog, which the `2.a.P` +ceiling says should be safe - was also implemented and measured, and is much worse: 3 to 15 mm/s went +39.5 -> 75.4 ms and 1 to 3 mm/s went 79 -> 245 ms. It is not in the tree. + +So the sub-40ms floor is real and its cause is not yet identified. Going below it needs a mechanism +this design does not have: revising a chunk that is already queued, rather than waiting it out. + +**To get more speed at the same latency, raise acceleration** rather than lengthening the chunk. The +ceiling is `2.a.P`, so `M201 X4000` with `P=20` gives 160mm/s at the same ~38ms. + +## Safety + +* **Speed clamp.** Each axis is clamped to its `M203` maximum *and* to `2·a·P`. That second limit is not + arbitrary: `DDA::InitStandardMove` caps the entry speed of every move at `sqrt(2·a·d)` for that move + alone, so that any move can be the last one in the ring and still stop at its end. With `d = v·P` that + solves to `v ≤ 2·a·P`. Commanding more would not go faster, it would silently not be obeyed, so `M700` + clamps to it. At the defaults with a = 1000 mm/s² the ceiling is 100 mm/s (6000 mm/min); **to jog faster, + raise `P`** — and accept the extra latency. +* **Watchdog.** If no `M700` arrives within `R` ms, the velocity is zeroed and the machine decelerates. +* **Axis limits.** Every chunk is passed through `Kinematics::LimitPosition` with `initialCoords` set, so + the whole line is checked, not just its end point. An axis that reaches its limit simply stops; the + others keep their commanded speed, because the chunk still takes one chunk time to execute. +* **Homing.** Starting a jog requires the same axes to be homed that a `G1` would, subject to `M564`. +* **Interlocks.** Jogging refuses to start while a print is running, stops if one starts, and is cancelled + by anything that waits for standstill on movement system 0 (`G28`, `G30`, most `M` codes that move) and + by `M112`/`M999`. + +For an immediate halt, use `M112` — `M700 S0` decelerates. + +## Limitations + +* Movement system 0 only. +* Mentioning linear and rotational axes in the same command works, but RepRapFirmware treats the two + groups' feedrates separately, so the resulting speeds are only approximately as commanded. +* RepRapFirmware has no USB host stack: the joystick has to be read by an SBC, Pi or other host that then + sends `M700` over USB or the network, ideally on its own input channel. + +## Where the code is + +| | | +|---|---| +| `src/Movement/JogController.{h,cpp}` | all of the logic | +| `src/GCodes/GCodes.cpp` | `Spin()` calls `jogController.Spin()`; `Reset()` and `LockMovementSystemAndWaitForStandstill()` stop it | +| `src/GCodes/GCodes2.cpp` | `M700` dispatch | diff --git a/src/GCodes/GCodes.cpp b/src/GCodes/GCodes.cpp index 6995174b00..1a3066044d 100644 --- a/src/GCodes/GCodes.cpp +++ b/src/GCodes/GCodes.cpp @@ -238,6 +238,8 @@ void GCodes::Reset() noexcept nextGcodeSource = 0; + jogController.Stop(); + #if HAS_MASS_STORAGE || HAS_EMBEDDED_FILES fileToPrint.Close(); #endif @@ -467,6 +469,8 @@ void GCodes::Spin() noexcept CheckTriggers(); + jogController.Spin(); // keep the movement queue topped up if we are jogging + // The autoPause buffer has priority, so spin that one first. It may have to wait for other buffers to release locks etc. (void)SpinGCodeBuffer(*AutoPauseGCode()); @@ -1853,6 +1857,11 @@ bool GCodes::LockAllMovementSystemsAndWaitForStandstill(GCodeBuffer& gb) noexcep // As a side-effect it updates the user coordinates from the machine coordinates. bool GCodes::LockMovementSystemAndWaitForStandstill(GCodeBuffer& gb, MovementSystemNumber msNumber) noexcept { + if (msNumber == 0) + { + jogController.Stop(); // jogging keeps feeding the queue, so we would never reach standstill while it is running + } + // Lock movement to stop another source adding moves to the queue if (!LockResource(gb, MoveResourceBase + msNumber)) { diff --git a/src/GCodes/GCodes.h b/src/GCodes/GCodes.h index 8b7ca05d76..a2eacfa836 100644 --- a/src/GCodes/GCodes.h +++ b/src/GCodes/GCodes.h @@ -42,6 +42,7 @@ Licence: GPL #include #include #include +#include #if HAS_MASS_STORAGE || HAS_EMBEDDED_FILES # include @@ -98,6 +99,8 @@ class SbcInterface; class GCodes { + friend class JogController; // it builds moves for movement system 0 using the same private machinery that G1 does + public: explicit GCodes(Platform& p) noexcept; void Spin() noexcept; // Called in a tight loop to make this class work @@ -270,6 +273,8 @@ class GCodes const MovementState& GetCurrentMovementState(const ObjectExplorationContext& context) const noexcept; const MovementState& GetConstMovementState(const GCodeBuffer& gb) const noexcept; // Get a reference to the movement state associated with the specified GCode buffer (there is a private non-const version) + JogController& GetJogController() noexcept { return jogController; } + void RecordEndstopTriggered(size_t axis, HomingMode hmode) noexcept; bool IsHeaterUsedByDifferentCurrentTool(int heaterNumber, const Tool *tool) const noexcept; // Check if the specified heater is used by a current tool other than the specified one @@ -671,6 +676,8 @@ class GCodes // The following contain the details of moves that the Move module fetches MovementState moveStates[NumMovementSystems]; // Move details + JogController jogController; // Velocity-mode movement, e.g. from a joystick + size_t numTotalAxes; // How many axes we have size_t numVisibleAxes; // How many axes are visible size_t numExtruders; // How many extruders we have, or may have diff --git a/src/GCodes/GCodes2.cpp b/src/GCodes/GCodes2.cpp index cb8ddf2514..ae21648580 100644 --- a/src/GCodes/GCodes2.cpp +++ b/src/GCodes/GCodes2.cpp @@ -4367,6 +4367,10 @@ bool GCodes::HandleMcode(GCodeBuffer& gb, const StringRef& reply) THROWS(GCodeEx result = FindCenterOfCavity(gb, reply); break; + case 700: // Set jog velocities + result = jogController.ProcessM700(gb, reply); + break; + case 701: // Load filament result = LoadFilament(gb, reply); break; diff --git a/src/Movement/JogController.cpp b/src/Movement/JogController.cpp new file mode 100644 index 0000000000..bf5ce22458 --- /dev/null +++ b/src/Movement/JogController.cpp @@ -0,0 +1,325 @@ +/* + * JogController.cpp + * + * See JogController.h for what this does. + */ + +#include "JogController.h" + +#include +#include +#include +#include + +// Below this the chunk is not worth queueing; it is well under one microstep on any sane machine. +constexpr float MinChunkDistance = 0.001; + +JogController::JogController() noexcept + : jogAxes(), chunkMillis(DefaultChunkMillis), chunkClocks((DefaultChunkMillis * StepClockRate)/1000), + timeoutMillis(DefaultTimeoutMillis), whenLastCommanded(0), maxQueuedMoves(DefaultMaxQueuedMoves), active(false) +{ + for (float& s : requestedSpeeds) + { + s = 0.0; + } +} + +// The highest speed we are prepared to run this axis at: its configured maximum, and nothing else. +// +// This used to also clamp to 2.a.P. That came from every chunk having to be stoppable within itself, +// because DDA::InitStandardMove sets endSpeed = 0 (DDA.cpp:624) until a following move exists, and a +// singly-generated chunk never had one. The machine never needed to stop within one chunk; it needs to +// be able to decelerate from its current speed, which takes v/a however the motion was commanded. +// Keeping enough chunks queued that each has a successor lets lookahead blend them, which is what an +// ordinary G-code stream already relies on. +float JogController::MaxSpeedForAxis(size_t axis) const noexcept +{ + return reprap.GetMove().MaxFeedrate(axis); +} + +void JogController::ClampSpeeds() noexcept +{ + const size_t numVisibleAxes = reprap.GetGCodes().GetVisibleAxes(); + jogAxes.Clear(); + clampedAxes.Clear(); + for (size_t axis = 0; axis < numVisibleAxes; ++axis) + { + const float limit = MaxSpeedForAxis(axis); + if (fabsf(requestedSpeeds[axis]) > limit) + { + clampedAxes.SetBit(axis); // the host asked for more than M203 allows; say so rather than silently obeying something else + } + requestedSpeeds[axis] = constrain(requestedSpeeds[axis], -limit, limit); + // A speed below one chunk's minimum distance cannot be expressed at all, so treat it as zero rather + // than as a jog that generates nothing. Otherwise the axis counts as jogging, keeps the machine out + // of idle, and produces a chunk per pass that is only thrown away. + if (fabsf(requestedSpeeds[axis]) * (float)chunkClocks < MinChunkDistance) + { + requestedSpeeds[axis] = 0.0; + } + if (requestedSpeeds[axis] != 0.0) + { + jogAxes.SetBit(axis); + } + } + for (size_t axis = numVisibleAxes; axis < MaxAxes; ++axis) + { + requestedSpeeds[axis] = 0.0; + } +} + +void JogController::Stop() noexcept +{ + for (float& s : requestedSpeeds) + { + s = 0.0; + } + jogAxes.Clear(); + active = false; +} + +void JogController::ReportStatus(const StringRef& reply) const noexcept +{ + const GCodes& gcodes = reprap.GetGCodes(); + const char *_ecv_array const axisLetters = gcodes.GetAxisLetters(); + reply.printf("Jogging %s, chunk %" PRIu32 "ms, timeout %" PRIu32 "ms, queue %u", + (active) ? "active" : "inactive", chunkMillis, timeoutMillis, maxQueuedMoves); + if (clampedAxes.IsNonEmpty()) + { + reply.cat(", clamped to axis maximum:"); + for (size_t axis = 0; axis < gcodes.GetVisibleAxes(); ++axis) + { + if (clampedAxes.IsBitSet(axis)) + { + reply.catf(" %c%.1f", axisLetters[axis], (double)InverseConvertSpeedToMmPerSec(requestedSpeeds[axis])); + } + } + } + if (active) + { + reply.cat(", speeds"); + for (size_t axis = 0; axis < gcodes.GetVisibleAxes(); ++axis) + { + if (jogAxes.IsBitSet(axis)) + { + reply.catf(" %c%.1f", axisLetters[axis], (double)InverseConvertSpeedToMmPerSec(requestedSpeeds[axis])); + } + } + } +} + +// M700: set the jog velocity of each axis, in mm (or degrees) per second. +// The axis letters that are present define the whole velocity vector: any axis not mentioned is set to zero, so that a +// truncated or lost command can never leave an axis running. +GCodeResult JogController::ProcessM700(GCodeBuffer& gb, const StringRef& reply) THROWS(GCodeException) +{ + GCodes& gcodes = reprap.GetGCodes(); + const char *_ecv_array const axisLetters = gcodes.GetAxisLetters(); + const size_t numVisibleAxes = gcodes.GetVisibleAxes(); + + // Tuning parameters. These take effect on the next chunk. + bool seenParam = false; + gb.TryGetLimitedUIValue('P', chunkMillis, seenParam, MinChunkMillis, MaxChunkMillis + 1); + gb.TryGetLimitedUIValue('R', timeoutMillis, seenParam, 1, MaxTimeoutMillis + 1); + uint32_t queueDepth = maxQueuedMoves; + gb.TryGetLimitedUIValue('D', queueDepth, seenParam, MinMaxQueuedMoves, MaxMaxQueuedMoves + 1); + chunkClocks = (chunkMillis * StepClockRate)/1000; + maxQueuedMoves = queueDepth; + + // S0 is an explicit stop. + if (gb.Seen('S') && gb.GetUIValue() == 0) + { + Stop(); + return GCodeResult::ok; + } + + float newSpeeds[MaxAxes] = { 0.0 }; + bool seenAxis = false; + for (size_t axis = 0; axis < numVisibleAxes; ++axis) + { + if (gb.Seen(axisLetters[axis])) + { + // Speeds are always in mm (or degrees) per second. G20 deliberately does not rescale them: the sender of a + // velocity command should not have its meaning changed by modal state it may know nothing about. + newSpeeds[axis] = ConvertSpeedFromMmPerSec(gb.GetFValue()); + seenAxis = true; + } + else + { + newSpeeds[axis] = 0.0; + } + } + + if (!seenAxis) + { + if (!seenParam) + { + ReportStatus(reply); + } + return GCodeResult::ok; + } + + AxesBitmap newJogAxes; + for (size_t axis = 0; axis < numVisibleAxes; ++axis) + { + if (newSpeeds[axis] != 0.0) + { + newJogAxes.SetBit(axis); + } + } + + if (newJogAxes.IsNonEmpty() && gcodes.CheckEnoughAxesHomed(newJogAxes)) + { + reply.copy("Insufficient axes homed"); + return GCodeResult::error; + } + + if (!active && newJogAxes.IsNonEmpty()) + { + // Starting up. We take over the axis positions of movement system 0, so it must be at a standstill and not printing. + if (gcodes.IsReallyPrintingOrResuming()) + { + reply.copy("Cannot jog while a print is running"); + return GCodeResult::error; + } + if (!gcodes.LockMovementSystemAndWaitForStandstill(gb, 0)) + { + return GCodeResult::notFinished; + } + } + +#if SUPPORT_ASYNC_MOVES + if ((newJogAxes & ~jogAxes).IsNonEmpty() // an axis we are not already moving has been added, so it may not be ours yet + && gcodes.moveStates[0].AllocateAxes(newJogAxes, ParameterLettersBitmap()).IsNonEmpty()) + { + reply.copy("Cannot jog: axes are in use by another movement system"); + return GCodeResult::error; + } +#endif + + memcpyf(requestedSpeeds, newSpeeds, numVisibleAxes); + ClampSpeeds(); + whenLastCommanded = millis(); + active = jogAxes.IsNonEmpty(); + return GCodeResult::ok; +} + +// Top the movement queue up. Called regularly from GCodes::Spin. +void JogController::Spin() noexcept +{ + if (!active) + { + return; + } + + GCodes& gcodes = reprap.GetGCodes(); + Move& move = reprap.GetMove(); + + // Watchdog: an input that stops sending must not leave the machine moving. + if (millis() - whenLastCommanded > timeoutMillis) + { + Stop(); + return; + } + + // Something else has taken over movement, so get out of the way. A macro or a tool change moves axes + // on its own account, and jogging underneath it would fight it for the same movement system. + // DoingFileMacro deliberately excludes daemon.g (GCodes.cpp:374), so a daemon running on its usual + // cycle does not chop the jog stream up. + // Deliberately NOT included: WaitingForAcknowledgement. "Jog to the workpiece corner, then press OK" + // is a standard CNC setup pattern, and the machine is stationary with the operator at the controls, + // so blocking it would remove a genuinely useful workflow for no safety gain. + if (gcodes.IsReallyPrintingOrResuming() || gcodes.DoingFileMacro() || gcodes.IsDoingToolChange()) + { + Stop(); + return; + } + + MovementState& ms = gcodes.moveStates[0]; + if (ms.segmentsLeft != 0) + { + return; // the previous chunk has not been picked up yet + } + if (move.GetScheduledMoves() - move.GetCompletedMoves() >= maxQueuedMoves) + { + return; // far enough ahead already; queueing more would only add latency + } + + (void)GenerateChunk(ms); +} + +// Build one constant-velocity chunk and hand it to the Move subsystem. Return true if we queued anything. +bool JogController::GenerateChunk(MovementState& ms) noexcept +{ + GCodes& gcodes = reprap.GetGCodes(); + Move& move = reprap.GetMove(); + const size_t numVisibleAxes = gcodes.GetVisibleAxes(); + + gcodes.SetMoveBufferDefaults(ms); // this also copies the previous target into ms.initialCoords + + for (size_t axis = 0; axis < numVisibleAxes; ++axis) + { + if (requestedSpeeds[axis] != 0.0) + { + ms.currentUserPosition[axis] += requestedSpeeds[axis] * (float)chunkClocks; + } + } + + ms.raw.movementTool = ms.currentTool; + gcodes.ToolOffsetTransform(ms, jogAxes); + + // Limit the whole line rather than just its end point, so that kinematics with a non-rectangular envelope stay inside it. + const LimitPositionResult lp = move.GetKinematics().LimitPosition(ms.raw.coords, ms.initialCoords, numVisibleAxes, + gcodes.axesVirtuallyHomed & jogAxes, true, gcodes.limitAxes); + if (lp == LimitPositionResult::intermediateUnreachable || lp == LimitPositionResult::adjustedAndIntermediateUnreachable) + { + Stop(); + return false; + } + if (lp == LimitPositionResult::adjusted) + { + gcodes.ToolOffsetInverseTransform(ms, ms.raw.coords, ms.currentUserPosition); // the target was clipped, so put the user position back in step with it + } + + // Axes that have run into their limit contribute nothing to the distance, which is exactly what keeps the remaining + // axes at their commanded speed: every axis still covers its own delta in one chunk time. + float distanceSquared = 0.0; + for (size_t axis = 0; axis < numVisibleAxes; ++axis) + { + const float d = ms.raw.coords[axis] - ms.initialCoords[axis]; + if (d != 0.0) + { + distanceSquared += fsquare(d); + if (move.IsAxisRotational(axis)) + { + ms.raw.rotationalAxesMentioned = true; + } + else + { + ms.raw.linearAxesMentioned = true; + } + } + } + + if (distanceSquared < fsquare(MinChunkDistance)) + { + // Nothing worth moving. The target has to be put back, not just abandoned: currentUserPosition was + // already advanced above, and SetMoveBufferDefaults seeds initialCoords from raw.coords, so a + // rejected chunk would otherwise become the next chunk's baseline and the reported position would + // climb at the commanded speed while the machine stood still, with nothing ever resyncing it. + memcpyf(ms.raw.coords, ms.initialCoords, numVisibleAxes); + gcodes.ToolOffsetInverseTransform(ms, ms.raw.coords, ms.currentUserPosition); + return false; + } + + ms.raw.isCoordinated = true; + ms.raw.canPauseAfter = true; + ms.raw.feedRate = fastSqrtf(distanceSquared)/(float)chunkClocks; + ms.raw.originalFeedRate = (float16_t)(InverseConvertSpeedToMmPerSec(ms.raw.feedRate) * MinutesToSeconds); // this field is in mm/min + ms.raw.moveStartVirtualExtruderPosition = ms.latestVirtualExtruderPosition; + + gcodes.NewSegmentableMoveAvailable(ms); + return true; +} + +// End diff --git a/src/Movement/JogController.h b/src/Movement/JogController.h new file mode 100644 index 0000000000..3c827740cd --- /dev/null +++ b/src/Movement/JogController.h @@ -0,0 +1,83 @@ +/* + * JogController.h + * + * Velocity-mode movement: axes are commanded by signed speed rather than by destination, so that a + * joystick or similar analogue input can drive them directly. + * + * The commanded velocity vector is turned into a stream of short constant-velocity moves that are fed + * into movement system 0. Lookahead blends consecutive chunks, and because the last move in the ring is + * always planned to end at zero speed, a stream that stops arriving decelerates the machine normally + * instead of stopping it dead. + */ + +#ifndef SRC_MOVEMENT_JOGCONTROLLER_H_ +#define SRC_MOVEMENT_JOGCONTROLLER_H_ + +#include + +class MovementState; + +class JogController +{ +public: + JogController() noexcept; + + GCodeResult ProcessM700(GCodeBuffer& gb, const StringRef& reply) THROWS(GCodeException); + void Spin() noexcept; // keep the movement queue topped up; called from GCodes::Spin + void Stop() noexcept; // stop jogging; queued motion decelerates to a halt + bool IsActive() const noexcept { return active; } + +private: + bool GenerateChunk(MovementState& ms) noexcept; + float MaxSpeedForAxis(size_t axis) const noexcept; + void ClampSpeeds() noexcept; + void ReportStatus(const StringRef& reply) const noexcept; + + // Latency is dominated by the chunk time already queued ahead of the change: the chunks are a FIFO + // and a new speed takes effect only once the queued ones have run. Measured on the emulator, command + // injection to the step pins changing rate, D=2: P=15 -> 44.6ms, P=20 -> 38.5ms, P=25 -> 67.2ms, + // P=30 -> 90.2ms, and P=10 cannot sustain 15mm/s at all (see MaxSpeedForAxis). So P=20/D=2 is a real + // optimum: shortening P makes it worse, not better. Sizing the chunk adaptively to the requested + // speed - short chunk for a slow jog - was tried and is much worse (3->15mm/s: 39.5 -> 75.4ms; + // 1->3mm/s: 79 -> 245ms). Whatever sets the floor below ~40ms of queued motion, it is not the + // queue arithmetic, and two plausible culprits were measured and cleared: MoveTiming's preparation + // window (halving UsualMinimumPreparedTime to 25ms moved 50.3 -> 50.2ms) and the lookahead grace + // period (M595 R0 is worth about 2ms). Doubling the host command rate changed nothing either. + static constexpr uint32_t DefaultChunkMillis = 15; + static constexpr uint32_t MinChunkMillis = 10; + static constexpr uint32_t MaxChunkMillis = 200; + static constexpr uint32_t DefaultTimeoutMillis = 250; + static constexpr uint32_t MaxTimeoutMillis = 10000; + // 2 with a 20ms chunk measured clean - no stutter over a 20Hz stream - and is what gets latency to + // 50ms. The earlier stutter at depth 3 was with 50ms chunks, where the ring holds far more time and + // the producer has correspondingly longer to fall behind. + // Blending depends on how MANY moves are queued; stopping distance depends on how much TIME they + // represent. Those are separable, which is why the defaults are a deep queue of short chunks rather + // than a shallow queue of long ones. Measured at 100mm/s on a two-driver axis: + // D2 P20 (old) 53% of commanded delivered + // D6 P10 57% delivered - short chunks do not give the planner enough to blend + // D10 P6 62% delivered - nor does adding more of them (reproduced 3/3) + // D6 P20 93% delivered, S0 stops in 156ms + // D8 P15 97% delivered, S0 stops in 153ms (reproduced 2/2) <- these defaults + // Shrinking P to cut the queued time was the obvious way to make S0 stop sooner while keeping + // enough moves to blend. It does not work: blending needs chunks long enough to be worth planning + // together, not merely numerous, so P below about 15ms costs a third of the commanded speed. + // A move with no successor is planned to stop within itself, and a 2mm chunk decelerating to rest + // takes 2.sqrt(d/a) = 89ms rather than its nominal 20ms. That, not any task handoff, is what used + // to leave a dead gap after every chunk. + static constexpr unsigned int DefaultMaxQueuedMoves = 8; + static constexpr unsigned int MinMaxQueuedMoves = 2; + static constexpr unsigned int MaxMaxQueuedMoves = 16; + + float requestedSpeeds[MaxAxes]; // signed commanded speed per axis, in mm (or degrees) per step clock + AxesBitmap jogAxes; + AxesBitmap clampedAxes; // axes whose requested speed was reduced to the axis maximum // the axes with a non-zero commanded speed + uint32_t chunkMillis; // how much travel time one chunk represents + uint32_t chunkClocks; // the same, in step clocks + uint32_t timeoutMillis; // speeds are zeroed if no fresh command arrives within this time + uint32_t whenLastCommanded; + unsigned int maxQueuedMoves; // bounds both the response latency and the distance available to stop in + volatile bool active; // written by the GCode task, read by the same task only, but kept volatile for clarity +}; + +#endif /* SRC_MOVEMENT_JOGCONTROLLER_H_ */ diff --git a/src/Platform/RepRap.cpp b/src/Platform/RepRap.cpp index d6c1338649..f1845ecd9f 100644 --- a/src/Platform/RepRap.cpp +++ b/src/Platform/RepRap.cpp @@ -1936,7 +1936,12 @@ size_t RepRap::GetStatusIndex() const noexcept : 9 // Printing ) : (gCodes->IsDoingToolChange()) ? 10 // Changing tool - : (gCodes->DoingFileMacro() || !move->NoLiveMovement() || + // Jog motion deliberately does not count as busy. Jogging is continuous by nature, so it would + // pin the status at "busy" for as long as the operator holds the stick, and clients such as DWC + // and AxisControl grey their controls out when busy - including the very controls sending the + // jog commands. The machine is manually controlled and accepting commands, which is idle. + : (gCodes->DoingFileMacro() || + (!move->NoLiveMovement() && !gCodes->GetJogController().IsActive()) || gCodes->WaitingForAcknowledgement() || heat->IsTuningHeater()) ? 11 // Busy : 12; // Idle