fix jogging stepping by holding one velocity instead of re-arming #4

Merged
cristhian merged 1 commits from fix/hold-velocity-instead-of-rearming into main 2026-09-16 17:28:57 +00:00
Owner

The problem

Jogging a real Lite6 does not feel like travel. The arm moves in discrete steps for as
long as the direction is held.

The cause is in set_cartesian_velocity, which passed duration=velocity_command_ttl_s
on every message. duration is a maximum, not a hint — the SDK documents it as
"over this time will automatically set the speed to 0" — so each message re-armed a
half-second timed segment and the controller re-planned the same motion again.

What was ruled out first

  • The velocity stream itself. Measured at the consumer: a steady 20 Hz, median gap
    49.9 ms over 83 messages, constant 60.0 mm/s, no gaps. Nothing steppy is being sent.
  • Link or worker saturation. Controller round-trips are ~2 ms median, so neither the
    network nor the job worker is behind at 20 Hz.
  • Sending less often. dora-teleop was changed to send on change plus a keepalive,
    cutting 83 messages to 18 for the same motion. No improvement on the arm. Re-arming
    four times a second steps just as much as twenty. That is what ruled out rate as the
    cause and pointed at re-arming itself.

The change

duration=0 — the SDK's "always effective, will not stop automatically" — and an
unchanged velocity is not sent to the arm at all. One command stands until a different
one replaces it. Verified on the arm at 192.168.1.162: smooth.

The remembered velocity is cleared in the enter-velocity-mode branch, which every route
out of the mode comes back through, so re-entry always re-sends.

The half that keeps it safe

duration was also the controller-side deadman. _expire_jog was relying on it —
it flipped _jogging and reported ready without commanding anything, because the
controller's own timer had already stopped the arm.

With the velocity held indefinitely, that would leave the arm travelling while the node
reported idle: worse than not expiring it at all. So _expire_jog now queues the stop
itself, through the job queue rather than the driver, since it runs on the reporter
thread and the control thread owns the driver.

VELOCITY_COMMAND_TTL_S keeps its value and its meaning to a caller. It is now enforced
here rather than by the controller, and it is a real deadman rather than a description
of one.

This is one commit on purpose: duration=0 without the _expire_jog stop is an arm that
never stops when its sender dies.

Tests

86 to 92. Adds tests/test_driver.py — the driver had no direct coverage at all,
because FakeArm replaces it everywhere else, which is why this was invisible to the
suite. It pins: no duration is ever passed, a held velocity reaches the controller once,
a changed one gets through, and leaving velocity mode makes the next identical velocity
send again.

test_a_sender_that_goes_quiet_actually_stops_the_arm asserts the zero reaches the arm,
not just that the state changed — the assertion whose absence let the old _expire_jog
look correct.

Notes for the reviewer

  • Docs updated for the semantics change: README.md, dora-node.yml, config.py, plus
    two invariants in AGENTS.md so the per-message duration is not reintroduced and the
    unchanged-velocity check is not "simplified" away.
  • Six files in the repo are unformatted under ruff format on main already. Left alone
    rather than mixed into this PR.
  • Not yet exercised on hardware: the _expire_jog stop path. The smooth-jogging half
    was confirmed on the arm; killing a sender mid-jog to watch the new stop fire was not.
    Worth doing before merge.
## The problem Jogging a real Lite6 does not feel like travel. The arm moves in discrete steps for as long as the direction is held. The cause is in `set_cartesian_velocity`, which passed `duration=velocity_command_ttl_s` on **every** message. `duration` is a maximum, not a hint — the SDK documents it as "over this time will automatically set the speed to 0" — so each message re-armed a half-second timed segment and the controller re-planned the same motion again. ## What was ruled out first - **The velocity stream itself.** Measured at the consumer: a steady 20 Hz, median gap 49.9 ms over 83 messages, constant 60.0 mm/s, no gaps. Nothing steppy is being sent. - **Link or worker saturation.** Controller round-trips are ~2 ms median, so neither the network nor the job worker is behind at 20 Hz. - **Sending less often.** `dora-teleop` was changed to send on change plus a keepalive, cutting 83 messages to 18 for the same motion. **No improvement on the arm.** Re-arming four times a second steps just as much as twenty. That is what ruled out rate as the cause and pointed at re-arming itself. ## The change `duration=0` — the SDK's "always effective, will not stop automatically" — and an unchanged velocity is not sent to the arm at all. One command stands until a different one replaces it. Verified on the arm at 192.168.1.162: smooth. The remembered velocity is cleared in the enter-velocity-mode branch, which every route out of the mode comes back through, so re-entry always re-sends. ## The half that keeps it safe `duration` was also the controller-side deadman. `_expire_jog` was **relying** on it — it flipped `_jogging` and reported `ready` without commanding anything, because the controller's own timer had already stopped the arm. With the velocity held indefinitely, that would leave the arm travelling while the node reported idle: worse than not expiring it at all. So `_expire_jog` now queues the stop itself, through the job queue rather than the driver, since it runs on the reporter thread and the control thread owns the driver. `VELOCITY_COMMAND_TTL_S` keeps its value and its meaning to a caller. It is now enforced here rather than by the controller, and it is a real deadman rather than a description of one. This is one commit on purpose: `duration=0` without the `_expire_jog` stop is an arm that never stops when its sender dies. ## Tests 86 to 92. Adds `tests/test_driver.py` — **the driver had no direct coverage at all**, because `FakeArm` replaces it everywhere else, which is why this was invisible to the suite. It pins: no duration is ever passed, a held velocity reaches the controller once, a changed one gets through, and leaving velocity mode makes the next identical velocity send again. `test_a_sender_that_goes_quiet_actually_stops_the_arm` asserts the zero reaches the arm, not just that the state changed — the assertion whose absence let the old `_expire_jog` look correct. ## Notes for the reviewer - Docs updated for the semantics change: `README.md`, `dora-node.yml`, `config.py`, plus two invariants in `AGENTS.md` so the per-message `duration` is not reintroduced and the unchanged-velocity check is not "simplified" away. - Six files in the repo are unformatted under `ruff format` on `main` already. Left alone rather than mixed into this PR. - **Not yet exercised on hardware:** the `_expire_jog` stop path. The smooth-jogging half was confirmed on the arm; killing a sender mid-jog to watch the new stop fire was not. Worth doing before merge.
cristhian added 1 commit 2026-09-16 17:28:08 +00:00
fix jogging stepping by holding one velocity instead of re-arming
validate / validate (pull_request) Successful in 58m41s
validate / test (pull_request) Successful in 58m50s
validate / markdownlint (pull_request) Successful in 58m39s
a95d804f0b
`set_cartesian_velocity` passed `duration=velocity_command_ttl_s` on every
message, so each one re-armed a timed segment and the controller re-planned
the same motion. On a real Lite6 that is not smooth travel: the arm moves in
steps. Reducing how often the velocity is sent does not help — re-arming four
times a second still steps — so it now uses `duration=0`, the SDK's "always
effective, will not stop automatically", and skips a velocity that has not
changed. One command stands until a different one replaces it.

That removes the controller-side expiry, which `_expire_jog` was relying on:
it flipped `_jogging` and reported `ready` without commanding anything,
because the controller's own timer had already stopped the arm. With the
velocity held indefinitely that would leave an arm travelling while the node
reported idle, so `_expire_jog` now queues the stop itself. It goes through
the job queue rather than the driver, since it runs on the reporter thread
and the control thread owns the driver.

`VELOCITY_COMMAND_TTL_S` is unchanged in value and meaning to a caller, but
it is now enforced here rather than by the controller.

Both halves are one commit on purpose: `duration=0` without the stop in
`_expire_jog` is an arm that never stops when its sender dies.

Adds tests/test_driver.py — the driver had no direct coverage, because
FakeArm replaces it everywhere else, which is why this was invisible to the
suite.
cristhian merged commit 1985659abf into main 2026-09-16 17:28:57 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: liria/dora-ulite6#4