Skip to content

RSDK-13382: Velocity Switching Point Search Updates#175

Merged
Nick Franczak (nfranczak) merged 29 commits into
viam-modules:mainfrom
nfranczak:20260209-RSDK-13382-velocity-switching-point-search
Feb 20, 2026
Merged

RSDK-13382: Velocity Switching Point Search Updates#175
Nick Franczak (nfranczak) merged 29 commits into
viam-modules:mainfrom
nfranczak:20260209-RSDK-13382-velocity-switching-point-search

Conversation

@nfranczak

Copy link
Copy Markdown
Contributor

Refactored the original find_velocity_switching_point since it was rather large into sub-functions.
Previously it seems that wrt equation 40 we were blatantly looking for when LHS = RHS.
This does not consider two things.

  1. since we advance by some value we could skip over this equality and miss the first fulfillment of it
  2. LHS = RHS is not enough, it should be LHS-RHS = 0 and we should make sure that LHS(s_previous) - RHS(s_previous) > 0 and LHS(s_previous) - RHS(s_previous) <= 0

The method of searching for the fulfillment of equations 41 and 42 is largely not changed.

We also had Claude add plenty of tests and asked Claude to validate that the changes made to trajectory.cpp are sane.


// No velocity switching point expected — velocity curve rises, not drops.
fixture.expectation_observer_->expect_forward_start(arc_length{0.0}, arc_velocity{0.0})
.expect_backward_start(std::nullopt, arc_velocity{0.0}, trajectory::switching_point_kind::k_path_end)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should investigate if testing of integration would be improved by a limit hit event when you start start following the vel limit curve.


fixture.set_waypoints_rad({{0.0, 0.0}, {1.0, 0.0}, {1.0, 1.0}});

fixture.expectation_observer_->expect_forward_start(arc_length{0.0}, arc_velocity{0.0})

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how do we know that any bisection happened here? How do we know that the test is giving us the result that we expect?


// Two velocity escape switching points (one at each joint-1-to-joint-2 turn).
fixture.expectation_observer_->expect_forward_start(arc_length{0.0}, arc_velocity{0.0})
.expect_hit_limit()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should put in the numbers for this test.
as event observations for all these tests.

// Case 6: Constant velocity curve → no velocity switching point.
//
// Straight-line path with uniform velocity limits. The velocity curve is flat,
// so there is no sign change in Δ(s). Only path-end switching point occurs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove all unicode.
explain this in claude.md to not use unicode.

// Evaluate condition 41 (before-side only) first to avoid computing after-side
// derivatives and acceleration bounds when the before-side already disqualifies.
phase_plane_slope curve_slope_before{0.0};
try {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do not use exceptions to try and control flow of control in the functions.
should it return an optional?

}
const auto& [s_ddot_min_after, s_ddot_max_after] = *accel_after;
const auto trajectory_slope_after = s_ddot_min_after / s_dot_max_vel_after;
const bool condition_42 = opt.epsilon.wrap(trajectory_slope_after) <= opt.epsilon.wrap(curve_slope_after);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

investigate if it is wise to wrap in epsilon for comparison.
it is not used for acceleration limit curve derivations.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm approving this: there are comments, but they are all quite minor. The majority are me pushing back on places where you are doing epsilon wrapped comparisons, so, please do think about those cases.

Please change things as you see fit per these comments. If you want me to take another look I'm happy to do so, and then we should be able to merge today.

// Returns std::nullopt when Eq. 40 is not evaluable at this geometry (degenerate
// velocity limit, acceleration limit below velocity limit, singular derivative,
// or infeasible acceleration bounds).
[[nodiscard]] std::optional<eq40_result> try_compute_eq40_delta(path::cursor& cursor, arc_length s, const trajectory::options& opt) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think you alter the cursor so it can probably be const&

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How did I miss the seek? Fine as is.

const auto accel_bounds = compute_acceleration_bounds(q_prime, q_double_prime, s_dot_max_vel, opt.max_acceleration, opt.epsilon);

const auto trajectory_slope = accel_bounds.s_ddot_min / s_dot_max_vel;
return eq40_result{.delta = trajectory_slope - curve_slope, .s_dot_max_vel = s_dot_max_vel};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can probably omit eq40_result here and just return { .delta = but I'm not 100%

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

our return type is std::optional<eq40_result>, not re40_result
return {.delta = ... would try to construct a std::optional which does not compile.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok

// We can't call `compute_acceleration_bounds` unless this holds; this is a `Divergence 2`
// type check.
if (opt.epsilon.wrap(s_dot_max_accel_before) <= opt.epsilon.wrap(s_dot_max_vel_before)) {
// We can't call compute_acceleration_bounds at s_dot_max_vel if it exceeds s_dot_max_accel,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it no longer true that this is a "Divergence 2" type check? If it is, I think we should keep the comment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated comment.

if (condition_41 && condition_42) {
// Valid discontinuous switching point found
// Switching velocity is the minimum of velocity limits on both sides
if (condition_42) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you have an !if (!condition_41) continue; style above, it'd be maybe more graceful to do the same thing here and then you could reduce the indent of the "valid" case too.

// Switching velocity is the minimum of velocity limits on both sides
if (condition_42) {
// Valid discontinuous switching point found.
// Switching velocity is the minimum of velocity limits on both sides.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Has your work here given you any further clarity on the indicated TODO(RSDK-12848)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think so.
Taking the min is correct in my opinion since the switching point is always the at the smaller of one two since the trajectory needs to be feasible on both sides of the boundary.
I will remove the todo.

.after_s_dot_max_vel = result->s_dot_max_vel};
}
}
// When has_previous_delta is false (first evaluable point or after a non-evaluable gap),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recommend a blank line before starting a block comment.

// not a distance. However, `opt.delta` is configurable and works as a practical
// starting point for now.
const auto next_position = std::min(current_position + arc_length{opt.delta.count()}, search_limit);
if (opt.epsilon.wrap(next_position) == opt.epsilon.wrap(current_position)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you actually need/want the epsilon wrap here? I feel like you want to go all the way out to the limit, not just close to it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed.

// instance of `Divergence 2` type behavior.
if (opt.epsilon.wrap(s_dot_max_acc) < opt.epsilon.wrap(s_dot_max_vel)) {
previous_position = search_cursor.position();
const auto mid = positive_side + ((nonpositive_side - positive_side) / 2.0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have a midpoint function!


// Update previous position for next iteration
previous_position = search_cursor.position();
if (opt.epsilon.wrap(mid_result->delta) < opt.epsilon.wrap(k_zero_delta)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same q re epsilon comparison of slopes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no errors detected when epsillon wrapped is removed.

arc_length search_limit,
const trajectory::options& opt) {
auto search_position = cursor.position();
while (opt.epsilon.wrap(search_position) < opt.epsilon.wrap(search_limit)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another place I'm not sure about epsilon'ing because I think you really do want to go all the way to the limit, not just near it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed epsilon.
i kept them in since it did not break tests and i having them seems more rigourous to me
but now they are removed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ship it!

@nfranczak
Nick Franczak (nfranczak) merged commit 5d70b2a into viam-modules:main Feb 20, 2026
7 checks passed
Nick Franczak (nfranczak) added a commit to nfranczak/universal-robots that referenced this pull request Feb 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants