Add differentiable time-optimal path parameterization - #723
Yuan-Xinyi wants to merge 3 commits into
Conversation
|
Follow-up to the same change, from review on #723. Each fix has a test that fails on the previous implementation. - A move smaller than `duplicate_tolerance` collapsed to one kept waypoint and was returned as a hold at the start, so it never reached the requested goal. Such a row now starts at its first waypoint and holds its last. - The velocity bound dropped any path derivative at or below the degeneracy threshold. A small but nonzero dq/ds still bounds the speed, and with a tight limit dropping it let the joint run far over the limit at the grid point. The bound now divides by a floored square instead of being skipped. - The square-root floor that keeps gradients finite also reported a small nonzero speed where the profile is at rest, so the first sample had a nonzero velocity. Zero speed is now exactly zero, with the floor confined to the untaken branch and to the segment-time denominator. - A non-finite or negative `duplicate_tolerance` silently classified every path as stationary; it is now rejected. Agreement with `toppra` improves slightly as a side effect: exact zeros at rest match its convention, taking the interpolation case from 2e-7 to 6e-8. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
All four findings were valid; fixed in 1. Sub-tolerance move ended at the start — fixed. Such a row now starts at its first waypoint and holds its last, so it ends exactly at the requested goal. 2. Small nonzero dq/ds dropped from the velocity bound — fixed. The bound is now 3. Nonzero speed at rest — fixed. Zero squared speed now gives exactly zero speed; the floor survives only inside the untaken branch, to keep the gradient finite, and in the segment-time denominator. 4. Non-finite tolerance — fixed. NaN, infinity and negative values are rejected. |
Follow-up to the same change, from a second review on #723. - The previous fix answered a sub-tolerance move by starting at the first waypoint and holding the last, with every interval zero. That moved the pose across a zero-length interval -- the same failure this change otherwise refuses. Only a row whose waypoints are all identical is now stationary; any difference between first and last, however small, is kept as a second knot and timed by the parameterization. - The square-root floor that kept gradients finite also treated a slow but real speed as rest: with dq/ds = 1 against a 5e-7 rad/s limit the squared speed is 2.5e-13, which the 1e-12 floor reported as zero, and segments were then timed from the floor instead of the profile. Rest now means below the dtype's smallest normal number, in both backends, and the same bound guards the segment-time denominator. Each fix has a test that fails on the previous implementation. Agreement with `toppra` and the end-to-end finite-difference checks are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| *, | ||
| sample_interval: float, | ||
| gridpoints: int | None = None, | ||
| discretization: Literal["interpolation", "collocation"] = "interpolation", |
There was a problem hiding this comment.
Tiny moves bypass acceleration limits A float32 move smaller than
duplicate_tolerance is now retained and timed. If its displacement is at most 1e-7, the parameterizer treats its nonzero path derivative as inactive when checking acceleration. It can then return a successful trajectory whose joint acceleration exceeds the caller’s limit. For example, a 5e-8-rad move with a 10-rad/s² limit can exceed that limit.
Prompt To Fix With AI
This is a comment left during a code review.
Path: embodichain/compute/trajectory/topp.py
Line: 593-596
Comment:
**Tiny moves bypass acceleration limits** A float32 move smaller than `duplicate_tolerance` is now retained and timed. If its displacement is at most `1e-7`, the parameterizer treats its nonzero path derivative as inactive when checking acceleration. It can then return a successful trajectory whose joint acceleration exceeds the caller’s limit. For example, a `5e-8`-rad move with a 10-rad/s² limit can exceed that limit.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.TOPP-RA gives the fastest joint timing of a path under velocity and acceleration limits, but the `toppra` library solves a small linear program at every grid point, so nothing downstream can differentiate through it. A policy that emits only path geometry therefore cannot be trained against the timing the planner will actually give it. For joint box limits each stage is a two-variable linear program whose feasible set projects onto the squared path speed in closed form. The backward controllable-set sweep and the forward greedy sweep then reduce to min, max, division and square roots, which autograd and Warp's tape both differentiate almost everywhere. - `parameterize_time_optimal()` solves the rest-to-rest profile on a sampled path, with a Warp backend (one thread per environment for the sequential sweeps, grid-parallel kernels for the constraint rows) and a torch reference. - `retime_time_optimal()` merges repeated waypoints, fits the not-a-knot cubic spline `toppra.SplineInterpolator` uses, parameterizes, and samples on a time grid, differentiable with respect to the waypoints end to end. - Both `toppra` discretizations are supported; interpolation, its default, is the default here. Given the same path and grid, results match the library to its LP tolerance: duration within 1e-6 relative, except near points where the exact speed is zero, where toppra's solver leaves ~1e-7 that the square root amplifies. An earlier formulation missed the bound that maximal acceleration must not force the speed below zero; it only binds where the path nearly stops, which is exactly what a held tail in a batched rollout produces, and it cost 6% there. Warp's reverse mode needs every loop that updates a local to be unrolled, so the joint count is captured as a compile-time constant and each loop is bounded by it; that caps the Warp kernels at 16 joints and `auto` falls back to torch beyond. Deduplication drops a held tail entirely: `ToppraPlanner` keeps one trailing duplicate knot, which changes the spline and makes the retimed path reverse at the goal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follow-up to the same change, from review on #723. Each fix has a test that fails on the previous implementation. - A move smaller than `duplicate_tolerance` collapsed to one kept waypoint and was returned as a hold at the start, so it never reached the requested goal. Such a row now starts at its first waypoint and holds its last. - The velocity bound dropped any path derivative at or below the degeneracy threshold. A small but nonzero dq/ds still bounds the speed, and with a tight limit dropping it let the joint run far over the limit at the grid point. The bound now divides by a floored square instead of being skipped. - The square-root floor that keeps gradients finite also reported a small nonzero speed where the profile is at rest, so the first sample had a nonzero velocity. Zero speed is now exactly zero, with the floor confined to the untaken branch and to the segment-time denominator. - A non-finite or negative `duplicate_tolerance` silently classified every path as stationary; it is now rejected. Agreement with `toppra` improves slightly as a side effect: exact zeros at rest match its convention, taking the interpolation case from 2e-7 to 6e-8. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follow-up to the same change, from a second review on #723. - The previous fix answered a sub-tolerance move by starting at the first waypoint and holding the last, with every interval zero. That moved the pose across a zero-length interval -- the same failure this change otherwise refuses. Only a row whose waypoints are all identical is now stationary; any difference between first and last, however small, is kept as a second knot and timed by the parameterization. - The square-root floor that kept gradients finite also treated a slow but real speed as rest: with dq/ds = 1 against a 5e-7 rad/s limit the squared speed is 2.5e-13, which the 1e-12 floor reported as zero, and segments were then timed from the floor instead of the profile. Rest now means below the dtype's smallest normal number, in both backends, and the same bound guards the segment-time denominator. Each fix has a test that fails on the previous implementation. Agreement with `toppra` and the end-to-end finite-difference checks are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
45ab0cf to
45b4d17
Compare
|
Closing in favor of #724, which solves the same problem with a stronger formulation. Both implement differentiable TOPP-RA with the same closed-form reachability sweeps. The difference is the constraint set: this PR enforces toppra's grid-point constraints, which reproduces the Two things from this PR are worth carrying over, and will follow as separate PRs against #724's branch:
The last automated finding here (tiny float32 moves bypassing the acceleration rows through a degeneracy threshold) does not apply to #724, whose rows have no such threshold. |
Stack
xinyi/nmg-retiming-comparisonDescription
Stacked on #715 only so that #725 can depend on both this solver and
NeuralPlanner's retiming with a clean diff; nothing in this PR uses anything from the layers below.This PR adds a differentiable time-optimal path parameterization to
embodichain.compute.trajectory: the fastest rest-to-rest timing of a joint path under per-joint velocity and acceleration limits — the problem TOPP-RA solves — written so gradients reach the path.The
toppralibrary solves a small linear program at every grid point, so nothing downstream can differentiate through it. That matters for NMG: a policy that emits only path geometry cannot currently be trained against the timing the planner will actually give it, which is why #684 proposes teaching the network about velocity at all. With a differentiable parameterization the limits are met by construction and the policy only has to produce paths that are cheap to time.Why it can be differentiated. With squared path speed
x = sdot²and path accelerationu = sddot, every joint velocity and acceleration limit is linear in(x, u)at each grid point, so each stage is a two-variable linear program. Its feasible set projects ontoxin closed form. The backward controllable-set sweep and the forward greedy sweep then reduce tomin,max, division and square roots, which autograd and Warp's tape both differentiate almost everywhere.What is added:
parameterize_time_optimal()— speed profile on a sampled path. A Warp backend runs grid-parallel kernels for the constraint rows and one thread per environment for the two sequential sweeps; a torch implementation is the reference.retime_time_optimal()— end to end from waypoints: merges repeated waypoints, fits the not-a-knot cubic splinetoppra.SplineInterpolatoruses, parameterizes, and samples on a time grid. Every output, including the sample times, is differentiable with respect to the waypoints.toppradiscretizations;interpolation, its default, is the default here.Dependencies: none new.
toppraandwarpare already core dependencies;toppraandscipyare used only as references in tests.Type of change
Measured evidence
It is TOPP-RA, not an approximation of it
Same path and same grid, against the
toppralibrary, worst case over random paths, a real NMG rollout, and that rollout with repeated samples:The collocation residual is toppra's LP tolerance, located: 98% of it comes from points where the exact speed is zero, where toppra returns 2.8e-7 or 4e-8 instead of 0 and the square root amplifies it. The spline matches
scipy.interpolate.CubicSpline(bc_type="not-a-knot")to 2e-11 for 2 to 20 knots, including scipy's two- and three-knot special cases.An analytic check: two waypoints 0.5 rad apart on every joint, acceleration limit 10 — the optimum is a triangular speed profile lasting 2·√(0.5/10) = 0.4472 s. Measured: 0.4472 s.
The gradients are correct and usable
retime_time_optimal, loss on duration and sampled q, q̇, q̈ (fp64)Individual fp32 entries can differ more: where two joints nearly tie for the binding constraint, operation order decides which receives the gradient. That is the piecewise nature of the problem, not a defect of either backend, and it averages out at the waypoint level as the last row shows.
Usability, using only this API: a seven-joint path with its start, one via-point and its goal held fixed, optimized by Adam on
d(duration)/d(waypoints):Joint position, velocity and acceleration against time, before and after. The optimized path loses most of its direction reversals, joint 7 now reaches its 2.62 rad/s velocity limit, and the acceleration switches far less often while staying within ±10.
A near-stationary stretch was the hard case
The first formulation omitted one bound of the stage projection — that maximal acceleration must not force the speed below zero. It only binds where the path nearly stops, so smooth random paths all matched; on a real NMG rollout with a held tail, the shape a batched rollout produces when an environment converges early, it was 6% off. With the bound, that case matches to 8e-8.
The same case exposed a pre-existing issue in
ToppraPlanner._toppra_solve_one_env: its deduplication keeps one trailing duplicate knot, which changes the whole spline and makes the retimed path reverse at the goal and take 6.6% longer. This module drops a held tail entirely, so holding at the goal leaves the motion unchanged, which a test asserts. The planner fix is left for a separate change.Limits hold at grid points; between them, density decides
Peak acceleration utilization over the resampled output of ten random twelve-waypoint paths:
The default is 100 grid points per retained waypoint with a floor of 1000, keeping the overshoot to about one percent.
Speed
End to end, 12 waypoints × 7 joints, default grid, CUDA fp32, against the production path (
_toppra_solve_one_env, CPU, forward only):The first call for a new joint count, discretization and dtype compiles its Warp kernels, 2–25 s depending on shape; Warp caches them on disk afterwards.
Review follow-up
Automated review found four real defects, fixed in
9b51ae1e; every fix below has a test that fails on the implementation before it:duplicate_tolerancewas returned as a hold at the start, never reaching the goal;duplicate_toleranceclassified every path as stationary — it is now rejected.A second round, in
45ab0cf4, caught that two of those first fixes were themselves wrong:Scope and limitations
toppra; see the density table above.autofalls back to torch beyond.labcalls this;NeuralPlannerretiming and NMG training integration are follow-ups.Validation
pytest tests/compute/test_trajectory_topp.py --run-gpu— 35 passed, CPU and CUDA.pytest tests/compute/— 118 passed.black .— clean.python docs/scripts/check_api_docs.py— 2322/2322; the four new exports are documented on the curatedembodichain.computepage.sphinx -b dummy— no warnings for the touched pages.context.py affected --base origin/main—motion-planning, throughcompute/trajectory/. Updated: the owner table gains a row for this module, and the retiming section states what it solves, its scope limits, the Warp joint cap, and the deduplication difference fromToppraPlanner.context.py check— ok.Checklist
black .command to format the code base.python docs/scripts/check_api_docs.py), if applicable🤖 Generated with Claude Code