Skip to content

Trajectory blending with new trajectory deferral#2401

Open
vedh1234 wants to merge 10 commits into
ros-controls:masterfrom
vedh1234:feat--trajectory-blending-with-deferring-the-new-traj
Open

Trajectory blending with new trajectory deferral#2401
vedh1234 wants to merge 10 commits into
ros-controls:masterfrom
vedh1234:feat--trajectory-blending-with-deferring-the-new-traj

Conversation

@vedh1234

@vedh1234 vedh1234 commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Description

Partial port of the ROS 1 "update existing trajectory" behavior to ROS 2 joint_trajectory_controller.

Related to #84

What this PR does:

  • Adds a new parameter allow_trajectory_blending (default: true)
  • When enabled, a trajectory with a future header.stamp is deferred: the currently active trajectory continues executing until that start time arrives, then the new trajectory is installed
  • This eliminates the legacy behavior where a future-stamped trajectory would immediately kill the active trajectory and create a slow spline ramp to the first point
  • Deferred trajectories are correctly dropped on goal cancellation, controller deactivation, and controller reset
  • Action Server feedback/tolerance checking is suppressed during the deferral period to prevent spurious aborts
  • Immediate trajectories (stamp = 0 or stamp <= now) behave exactly as before (no behavioral change)

What this PR does NOT target: ( Compared to ROS1 blending feature )

  • Does not allow omitted joints to continue their old trajectory after the new one starts (ROS 1 full blend). Omitted joints freeze at their current position via fill_partial_goal(), same as legacy ROS 2 behavior
  • Does not handle speed scaling during the deferral period. The trigger uses wall-clock time, so if speed_scaling != 1.0, there will be a positional mismatch at handoff
  • Does not track multiple action goals simultaneously. The new goal immediately preempts the old one at the Action Server level; only the physical trajectory execution is deferred

Is this user-facing behavior change?

Yes. When allow_trajectory_blending is true (default), the active trajectory will continue running until the new trajectory's start time.

Did you use Generative AI?

Yes, Google Gemini and Claude were used to make improvements in manually written code, architectural analysis, edge-case review.

Additional Information

TODOs

To send us a pull request, please:

  • Fork the repository.
  • Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change.
  • Ensure local tests pass. (colcon test and pre-commit run (requires you to install pre-commit by pip3 install pre-commit)
  • Commit to your fork using clear commit messages.
  • Send a pull request, answering any default questions in the pull request interface.
  • Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation.

@codecov

codecov Bot commented Jun 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.46018% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.83%. Comparing base (a466e1a) to head (c81e361).

Files with missing lines Patch % Lines
...ectory_controller/test/test_trajectory_actions.cpp 96.55% 0 Missing and 5 partials ⚠️
...ory_controller/src/joint_trajectory_controller.cpp 97.29% 0 Missing and 1 partial ⚠️
...ory_controller/test/test_trajectory_controller.cpp 97.50% 0 Missing and 1 partial ⚠️
...ntroller/test/test_trajectory_controller_utils.hpp 0.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #2401      +/-   ##
==========================================
+ Coverage   86.70%   86.83%   +0.13%     
==========================================
  Files         148      148              
  Lines       15858    16091     +233     
  Branches     1351     1361      +10     
==========================================
+ Hits        13749    13973     +224     
+ Misses       1613     1612       -1     
- Partials      496      506      +10     
Flag Coverage Δ
unittests 86.83% <96.46%> (+0.13%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...jectory_controller/joint_trajectory_controller.hpp 62.50% <100.00%> (+22.50%) ⬆️
...ory_controller/src/joint_trajectory_controller.cpp 85.23% <97.29%> (+0.54%) ⬆️
...ory_controller/test/test_trajectory_controller.cpp 98.81% <97.50%> (-0.04%) ⬇️
...ntroller/test/test_trajectory_controller_utils.hpp 83.72% <0.00%> (-0.60%) ⬇️
...ectory_controller/test/test_trajectory_actions.cpp 97.50% <96.55%> (-0.20%) ⬇️

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@christophfroehlich christophfroehlich left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Partial port of the ROS 1 "update existing trajectory" behavior to ROS 2 joint_trajectory_controller.

Fixes #84

For documentation here, what is different from the ROS 1 behavior/what is maybe missing to port?

Please also update the release_notes and update the documentation.

Comment thread doc/release_notes.rst Outdated
@saikishor

Copy link
Copy Markdown
Member

I'll review the logic and tests soon. I need time

@vedh1234 vedh1234 force-pushed the feat--trajectory-blending-with-deferring-the-new-traj branch from 498c4ed to e7db781 Compare June 17, 2026 20:09

@saikishor saikishor left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The main logic looks good.

I still need to verify the tests though

Comment thread doc/release_notes.rst Outdated
Comment thread joint_trajectory_controller/doc/trajectory.rst Outdated
Comment thread joint_trajectory_controller/doc/trajectory.rst
Comment thread joint_trajectory_controller/doc/trajectory.rst Outdated
Comment thread joint_trajectory_controller/doc/trajectory.rst Outdated

@saikishor saikishor left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Some comments on tests

Comment on lines +1522 to +1529
std::this_thread::sleep_for(std::chrono::milliseconds(150)); // well before 500 ms fire time
auto goal_handle_b = gh_b.get();
ASSERT_TRUE(goal_handle_b);
action_client_->async_cancel_goal(goal_handle_b);

controller_hw_thread_.join();

EXPECT_EQ(rclcpp_action::ResultCode::CANCELED, resultcode_b);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

once the goal_b is sent you are not verifying the status of goal_a handle

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.

That had a separate test testing it, but I'll add it here as well.

Comment on lines +1593 to +1594
EXPECT_EQ(rclcpp_action::ResultCode::ABORTED, resultcode_a);
EXPECT_EQ(rclcpp_action::ResultCode::SUCCEEDED, resultcode_b);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I see that this is being triggered, but in this test shouldn't the A start immediately instead of future timestamp and then when B enter it is aborted. This is a nice test

Maybe it is interesting to get the time_now and verify it continuously during the cycle?

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.

Goal A already starts immediately (no header.stamp set). Goal B is the future-stamped one that gets deferred. So the behaviour is exactly as you described: A starts immediately, then when B is accepted, A gets preempted and ABORTED while B's trajectory is deferred until its stamp

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.

The final ABORTED/SUCCEEDED check already proves the essential behaviour: A was preempted when B was accepted, and B successfully executed after its deferred start time. Happy to add intermediate checks in a follow-up if you feel it's important for the coverage.

/**
* @brief A stamp=0 topic trajectory preempts a deferred action goal at the trajectory level:
* the topic trajectory installs immediately and the deferred goal never fires.
* NOTE: the action goal handle is not formally canceled on topic preemption — that

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Maybe it should cancel?

We shall discuss about that behaviour today

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.

This is a pre-existing issue in JTC, topic_callback has never explicitly aborted the active action goal on topic preemption, even before this PR. The goal handle survives and eventually gets ABORTED maybe via tolerance violation when the robot follows the topic trajectory instead.

should we abort it when a topic traj comes on top of action goal ?
If Yes we can open a separate PR resolving this.

Comment on lines +1624 to +1646
{
std::vector<JointTrajectoryPoint> points_init(1);
points_init[0].time_from_start = rclcpp::Duration::from_seconds(2.0);
points_init[0].positions = {4.0, 5.0, 6.0};
control_msgs::action::FollowJointTrajectory_Goal goal_init;
goal_init.goal_time_tolerance = rclcpp::Duration::from_seconds(1.0);
goal_init.trajectory.joint_names = joint_names_;
goal_init.trajectory.points = points_init;
action_client_->async_send_goal(goal_init, goal_options_);
}
std::this_thread::sleep_for(std::chrono::milliseconds(200));

{
std::vector<JointTrajectoryPoint> points_a(1);
points_a[0].time_from_start = rclcpp::Duration::from_seconds(0.5);
points_a[0].positions = {7.0, 8.0, 9.0};
control_msgs::action::FollowJointTrajectory_Goal goal_a;
goal_a.goal_time_tolerance = rclcpp::Duration::from_seconds(1.0);
goal_a.trajectory.joint_names = joint_names_;
goal_a.trajectory.points = points_a;
goal_a.trajectory.header.stamp =
traj_controller_->get_node()->now() + rclcpp::Duration::from_seconds(1.0);
action_client_->async_send_goal(goal_a, goal_options_);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Any reasons for sending 2 goals for this test?

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.

goal_a creates the active trajectory that causes goal_b to be deferred. Without goal_a running, goal_b (future-stamped) would install immediately since has_active_trajectory() would be false.

Comment on lines +1678 to +1692
{
auto clock = rclcpp::Clock(RCL_ROS_TIME);
auto now_time = clock.now();
auto last_time = now_time;
const auto end_time = now_time + rclcpp::Duration::from_seconds(0.3);
while (clock.now() < end_time)
{
now_time = clock.now();
traj_controller_->update(now_time, now_time - last_time);
last_time = now_time;
}
traj_controller_->get_node()->deactivate();
std::this_thread::sleep_for(std::chrono::milliseconds(200));
});
std::this_thread::sleep_for(std::chrono::milliseconds(100));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This may end up flaky test. Increase the time and reduce the sleep?

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.

Okay will do that.

Comment on lines +1694 to +1717
{
std::vector<JointTrajectoryPoint> points_a(1);
points_a[0].time_from_start = rclcpp::Duration::from_seconds(1.0);
points_a[0].positions = {4.0, 5.0, 6.0};
control_msgs::action::FollowJointTrajectory_Goal goal_a;
goal_a.goal_time_tolerance = rclcpp::Duration::from_seconds(1.0);
goal_a.trajectory.joint_names = joint_names_;
goal_a.trajectory.points = points_a;
action_client_->async_send_goal(goal_a, goal_options_);
}
std::this_thread::sleep_for(std::chrono::milliseconds(50));

{
std::vector<JointTrajectoryPoint> points_b(1);
points_b[0].time_from_start = rclcpp::Duration::from_seconds(0.4);
points_b[0].positions = {7.0, 8.0, 9.0};
control_msgs::action::FollowJointTrajectory_Goal goal_b;
goal_b.goal_time_tolerance = rclcpp::Duration::from_seconds(1.0);
goal_b.trajectory.joint_names = joint_names_;
goal_b.trajectory.points = points_b;
goal_b.trajectory.header.stamp =
traj_controller_->get_node()->now() + rclcpp::Duration::from_seconds(0.5);
action_client_->async_send_goal(goal_b, goal_options_);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Again why 2 goals here? I don't understand the reasoning behind 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.

Same reason: goal_a creates the active trajectory that causes goal_b to be deferred. Without goal_a running, goal_b (future-stamped) would install immediately since has_active_trajectory() would be false.

Comment on lines +3301 to +3302
EXPECT_NEAR(4.55, state.positions[2], 0.25);
EXPECT_GT(state.positions[2], 4.1); // clearly above receive-time value ≈3.73

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Isn't this condition redundant?

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.

Okay right, it was just to check if the joint moved beyond that point. But agreed, it's redundant, I'll remove the EXPECT_GT.

@vedh1234

Copy link
Copy Markdown
Contributor Author

@saikishor I have addressed your comments and also made requested changes. While going through I also realised that there is a helper API for sending goals. I've committed that as well.

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.

4 participants