Skip to content

fix(trajectory_planner): resume from the applied acceleration after a stop; per-axis jerk; reverse speed limit (#850, #852) - #851

Merged
finger563 merged 3 commits into
mainfrom
fix/trajectory-planner-stale-accel
Oct 20, 2026
Merged

finger563 merged 3 commits into
mainfrom
fix/trajectory-planner-stale-accel

Conversation

@finger563

@finger563 finger563 commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes #850 and #852, both in TrajectoryPlanner::update() / set_target().

#850: stale acceleration across the profile hand-off

update() only wrote state_.a_v / a_w in the jerk-limited branch, so after a trapezoidal stop the S-curve resumed from the last driving acceleration: the first step after a stop jumped straight to it, and a reversal from standstill first accelerated the wrong way. Reading the same code turned up a second problem: a profile jerk-limited on one axis only fed the other axis through the S-curve logic with a huge fake jerk, and that logic's decision block then never left zero acceleration.

  • The per-axis maths moves to include/detail/trajectory_planner_math.hpp (standard-library only), and update() steps each axis with step_axis(): the S-curve where that axis' jerk is > 0, the trapezoidal rate limiter where it is 0. In both cases the acceleration actually applied, (v_new - v_old) / dt after the velocity clamp, is kept in the state, so a jerk-limited step always resumes from the real value (0 once a stop has completed, the deceleration in force if the stop is interrupted). This is the reporter's proposed fix, applied to both branches.
  • An acceleration inherited from a profile with a higher limit (a firm trapezoidal stop interrupted by the stick) is wound back into the new profile's range at that profile's jerk limit, instead of being clamped from e.g. 1.6 to 1.0 m/s² in one step.
  • State::a_v / a_w and the MotionProfile docs plus the README describe the per-axis choice and the continuous hand-off.

#852: separate maximum reverse linear velocity

Config::max_reverse_linear_velocity (m/s at full reverse stick; 0 = the same as max_linear_velocity, so nothing changes by default). set_target() scales a negative linear input by it, get_target() normalizes by it (so -1 is full reverse stick whatever the limits), and the linear axis is clamped to [-reverse limit, +max_linear_velocity] through the per-axis step's new asymmetric bounds. Validated (>= 0), shown by the Config formatter, documented in the README and the set_target() docs.

Host platform / python

lib/espp.cmake already compiled the planner and added its include dir, so the new detail/ header needs no lib changes. The python bindings (pybind_espp.cpp) and the stub carry the new Config field and constructor argument, mirrored by hand in struct order (litgen is not installed here; the generated file's own docstrings are updated to match).

Test plan

🤖 Generated with Claude Code

https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU

… hand-offs; per-axis jerk

update() only wrote state_.a_v / a_w in the jerk-limited branch, so after a
trapezoidal stop the S-curve resumed from the last driving acceleration: the
first step after a stop jumped straight to it, and a reversal from standstill
first accelerated the wrong way (issue #850). A profile jerk-limited on one
axis only also fed the other axis through the S-curve logic with a huge fake
jerk, whose decision block then never left zero acceleration.

The per-axis maths now lives in detail/trajectory_planner_math.hpp
(standard-library only) and update() steps each axis with step_axis(): the
S-curve where that axis' jerk is > 0, the trapezoidal rate limiter where it is
0, and in both cases the acceleration actually applied, (v_new - v_old) / dt
after the velocity clamp, is kept in the state. An acceleration inherited from
a profile with a higher limit (a firm stop interrupted by the stick) is wound
back into the new profile's range at its jerk limit instead of being clamped
in one step.

Host test (components/trajectory_planner/test) runs the issue's scenario --
0.5 s driving, 0.5 s trapezoidal stop, reverse -- and checks the stop leaves
a = 0, the first reverse step is at the jerk limit and backwards, the jerk
limit holds over whole stop-and-go cycles including an interrupted stop, the
all-trapezoidal case is unchanged, and the mixed-axis profile moves.

Fixes #850

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
@github-actions

Copy link
Copy Markdown

✅Static analysis result - no issues found! ✅

@finger563
finger563 requested a review from guo-max October 20, 2026 20:35
Powered wheelchair controllers run reverse slower than forward (the R-Net
recording in #852: 71 %), and scaling the stick outside the planner puts
chassis behaviour where it does not belong. Config gains
max_reverse_linear_velocity (m/s at full reverse stick; 0 = the same as
max_linear_velocity, so nothing changes by default): set_target() scales a
negative linear input by it, get_target() normalizes by it, and the linear
axis is clamped to [-reverse limit, +max_linear_velocity] through the
per-axis step's new asymmetric bounds. Validated (>= 0), shown by the Config
formatter, documented in the README; the python bindings and stub carry the
new field and constructor argument (mirrored by hand, litgen is not
installed here; same order as the struct).

Verified through the python package against the planner's own timer: full
reverse settles at the reverse limit and reads back as -1, full forward at
the forward limit, the default stays symmetric.

Fixes #852

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
@finger563 finger563 changed the title fix(trajectory_planner): resume from the applied acceleration after a trapezoidal stop; per-axis jerk (#850) fix(trajectory_planner): resume from the applied acceleration after a stop; per-axis jerk; reverse speed limit (#850, #852) Oct 20, 2026
@finger563 finger563 linked an issue Oct 20, 2026 that may be closed by this pull request
@alexchunlin

Copy link
Copy Markdown
Contributor

zoomin on the espp fixes

…n bit-exact; reject a stopping profile with no acceleration

Self-review of the per-axis step: recomputing the S-curve acceleration from
the velocity delta even when the velocity clamp did not bite fed float
rounding noise into update_accel()'s step-count truncation, which can flip a
boundary case. The planned value is now kept bit for bit unless the clamp
engaged (the velocity sits on a bound: the acceleration applied is what the
bound allowed, 0 once pinned), with a host test for both.

set_config() validated only the driving profile's accelerations; a stopping
profile with 0 acceleration could never bring the chassis to rest, so it is
rejected the same way.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
@finger563

finger563 commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor Author

Test evidence (at 88aef29)

Host test (c++ -std=c++23 -Wall -Wextra -Werror -I components/trajectory_planner/include components/trajectory_planner/test/trajectory_planner_host_test.cpp), Apple clang, macOS:

trajectory_planner_host_test: all checks passed

Checks: the #850 scenario with the report's numbers (v = 0.410 / a = 1.0 after 0.5 s driving; a = 0 after the 0.5 s trapezoidal stop; first reverse step a = -0.1 and v < 0; v never > 0 over 0.5 s of reverse; first forward step after a stop a = 0.1), jerk limit held over whole stop-and-go cycles including a stop interrupted after 0.1 s (a goes -1.6 -> -1.5 -> ... never a 1.6 -> 1.0 jump), all-trapezoidal unchanged, mixed-axis profile moves (it used to stay at 0), an unclamped S-curve step keeps the planned acceleration bit for bit, an S-curve pinned at v_max records a = 0 and leaves the bound from 0, asymmetric bounds (#852) settle full reverse at the reverse limit, the S-curve alone lands without overshoot.

ESP32 build of components/trajectory_planner/example (IDF 6.1): BUILD EXIT 0

Host lib + python package (lib/, cmake + pybind11, Python 3.15, macOS): LIB BUILD EXIT 0

Python, #850 scenario through the public API against the planner's own 50 Hz timer (S-curve driving 1.0 m/s^2 / 5 m/s^3, trapezoidal stop 1.6 m/s^2; drive 0.5 s, stop 0.5 s, full reverse 0.5 s, stop, forward):

after driving 0.5 s: v = 0.413 m/s (host test: 0.410)
after stopping 0.5 s: v = 0.000 m/s
reverse: first output -0.0021, peak over 0.5 s -0.0021 (must stay <= 0)
forward after a stop: first velocity step 0.0018 m/s (jerk-limited: ~0.002 = 0.1 m/s^2 * 0.02 s; stale would be ~0.020)
PASS

Python, #852 (forward 1.8 m/s, reverse limit 1.0 m/s):

reverse limit 1.0: full reverse -> v = -1.000 (target reads -1.00); full forward -> v = 1.800 (target reads 1.00); half reverse -> v = -0.500
reverse limit 0 (default): full reverse -> v = -1.800 (symmetric, expect -1.8)
PASS

cppcheck (--enable=all --inconclusive) on the changed files: no findings (the functionStatic notes in the untouched trajectory_planner_formatters.hpp are pre-existing on main).

Self-review notes

Changes made as a result of re-reading the diff and the rest of the planner:

  • The S-curve branch recomputed the acceleration from the velocity delta even when the clamp did not bite; that feeds float rounding into update_accel()'s int(a / step) truncation and could flip a boundary case. It now keeps the planned value bit for bit unless the velocity was clamped (test added).
  • set_config() validated only the driving profile's accelerations; a stopping profile with 0 acceleration can never bring the chassis to rest and is now rejected the same way.
  • A Config constructed positionally from Python with two or more arguments shifts by one (max_reverse_linear_velocity sits after max_linear_velocity, in struct order, as litgen would place it); keyword and attribute use, which the examples do, is unaffected.

Pre-existing, left alone: get_config() returns a reference to the member after releasing the lock (a concurrent set_config() can race a reader); update() picks the stopping profile on an exact (0, 0) target, which set_target(0, 0) / stop() satisfy; trajectory_planner.py in the component is a standalone model of the S-curve decision block and has no profiles, so it is unaffected. From Python, a running TrajectoryPlanner that is still alive at interpreter shutdown makes the teardown hang or segfault (its destructor joins the planner / callback tasks while the GIL is held, the known gap in the bindings' _GIL_RELEASE_METHODS pass); the scenario scripts above therefore end with os._exit(). Not introduced here, worth its own issue.

@finger563 finger563 self-assigned this Oct 20, 2026
@finger563 finger563 added bug Something isn't working enhancement New feature or request trajectory planner labels Oct 20, 2026
@finger563
finger563 requested a review from alexchunlin October 20, 2026 21:27
@finger563
finger563 merged commit d1c768d into main Oct 20, 2026
163 of 178 checks passed
@finger563
finger563 deleted the fix/trajectory-planner-stale-accel branch October 20, 2026 21:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working enhancement New feature or request trajectory planner

Projects

None yet

3 participants