Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions beluga_amcl/include/beluga_amcl/amcl_node.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ class AmclNode : public BaseAMCLNode {
/// Callback for laser scan updates.
void laser_callback(sensor_msgs::msg::LaserScan::ConstSharedPtr);

/// Callback for increased propagation timer.
void propagation_timer_callback();

/// Helper function to get base pose in odom frame at specific time.
auto get_base_pose_in_odom(const tf2::TimePoint& time) const -> std::optional<Sophus::SE2d>;

/// Callback for pose (re)initialization.
void do_initial_pose_callback(geometry_msgs::msg::PoseWithCovarianceStamped::SharedPtr) override;

Expand Down Expand Up @@ -143,6 +149,9 @@ class AmclNode : public BaseAMCLNode {
/// Connection for laser scan updates filter and callback.
::message_filters::Connection laser_scan_connection_;

/// Timer for increased propagation rate.
rclcpp::TimerBase::SharedPtr propagation_timer_;

/// Particle filter instance.
std::unique_ptr<beluga_ros::Amcl> particle_filter_;
/// Last known pose estimate, if any.
Expand Down
75 changes: 61 additions & 14 deletions beluga_amcl/src/amcl_node.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,16 @@ AmclNode::AmclNode(const rclcpp::NodeOptions& options) : BaseAMCLNode{"amcl", ""
"and ignore subsequent ones.";
declare_parameter("first_map_only", false, descriptor);
}

{
auto descriptor = rcl_interfaces::msg::ParameterDescriptor();
descriptor.description = "Frequency in Hz for increased propagation rate. Set to 0 to disable.";
descriptor.floating_point_range.resize(1);
descriptor.floating_point_range[0].from_value = 0.0;
descriptor.floating_point_range[0].to_value = 100.0;
descriptor.floating_point_range[0].step = 0.0;
declare_parameter("increase_propagation", rclcpp::ParameterValue(0.0), descriptor);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@fbattocchia nit:

Suggested change
declare_parameter("increase_propagation", rclcpp::ParameterValue(0.0), descriptor);
declare_parameter("propagation_rate", rclcpp::ParameterValue(0.0), descriptor);

perhaps?

}
}

AmclNode::~AmclNode() {
Expand Down Expand Up @@ -251,6 +261,17 @@ void AmclNode::do_activate(const rclcpp_lifecycle::State&) {
std::placeholders::_3),
common_service_qos, common_callback_group_);
RCLCPP_INFO(get_logger(), "Created request_nomotion_update service");

// Setup increased propagation timer if enabled
{
const double propagation_freq = get_parameter("increase_propagation").as_double();
if (propagation_freq > 0.0) {
auto period = std::chrono::duration<double>(1.0 / propagation_freq);
propagation_timer_ =
create_wall_timer(period, std::bind(&AmclNode::propagation_timer_callback, this), common_callback_group_);
RCLCPP_INFO(get_logger(), "Created propagation timer at %.1f Hz", propagation_freq);
}
}
}

void AmclNode::do_deactivate(const rclcpp_lifecycle::State&) {
Expand All @@ -259,6 +280,7 @@ void AmclNode::do_deactivate(const rclcpp_lifecycle::State&) {
laser_scan_filter_.reset();
laser_scan_sub_.reset();
global_localization_server_.reset();
propagation_timer_.reset();
if (likelihood_field_pub_) {
likelihood_field_pub_->on_deactivate();
}
Expand Down Expand Up @@ -466,26 +488,51 @@ void AmclNode::do_periodic_timer_callback() {
}
}

void AmclNode::laser_callback(sensor_msgs::msg::LaserScan::ConstSharedPtr laser_scan) {
if (!particle_filter_) {
RCLCPP_WARN_THROTTLE(
get_logger(), *get_clock(), 2000, "Ignoring laser data because the particle filter has not been initialized");
return;
}

auto AmclNode::get_base_pose_in_odom(const tf2::TimePoint& time) const -> std::optional<Sophus::SE2d> {
auto base_pose_in_odom = Sophus::SE2d{};
try {
// Use the lookupTransform overload with no timeout since we're not using a dedicated
// tf thread. The message filter we are using avoids the need for it.
tf2::convert(
tf_buffer_
->lookupTransform(
get_parameter("odom_frame_id").as_string(), get_parameter("base_frame_id").as_string(),
tf2_ros::fromMsg(laser_scan->header.stamp))
get_parameter("odom_frame_id").as_string(), get_parameter("base_frame_id").as_string(), time)
.transform,
base_pose_in_odom);
return base_pose_in_odom;
} catch (const tf2::TransformException& error) {
RCLCPP_ERROR(get_logger(), "Could not transform from odom to base: %s", error.what());
Comment on lines -479 to -488

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Good call wrapping this in a function.

RCLCPP_WARN(get_logger(), "Could not get base pose in odom: %s", error.what());
return std::nullopt;
}
}

void AmclNode::propagation_timer_callback() {
if (!particle_filter_) {
RCLCPP_WARN_THROTTLE(
get_logger(), *get_clock(), 2000, "Ignoring propagation because the particle filter has not been initialized");
return;
}

// Get current base pose in odom frame (latest available)
auto base_pose_in_odom = get_base_pose_in_odom(tf2::TimePointZero);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@fbattocchia nit: can be const

if (!base_pose_in_odom.has_value()) {
return;
}

// Force a propagation-only update (without sensor data)
particle_filter_->update_propagation(base_pose_in_odom.value());

RCLCPP_INFO(get_logger(), "Forced propagation update executed");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Maybe this log will be too noisy, logging this message at high rate?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

+1. Consider demoting it to DEBUG.

}

void AmclNode::laser_callback(sensor_msgs::msg::LaserScan::ConstSharedPtr laser_scan) {
if (!particle_filter_) {
RCLCPP_WARN_THROTTLE(
get_logger(), *get_clock(), 2000, "Ignoring laser data because the particle filter has not been initialized");
return;
}

// Get base pose in odom frame at laser scan timestamp
auto base_pose_in_odom = get_base_pose_in_odom(tf2_ros::fromMsg(laser_scan->header.stamp));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I wonder if it can happen that we get a lidar message with a timestamp that is previous to a get_base_pose_in_odom() update.

Say, we execute get_base_pose_in_odom() at 10, 20, 30, 40, 50 and then we get a lidar message with timestamp 45. We might be adding a little bit more noise then.

Maybe when we update the motion model from a separate thread then we should either

  1. Only update if the time stamp is later (no noise added then). If we don't, particle state might be up to one timer event behind sensor data.
  2. Repeat the value of the latest get_base_pose_in_odom() odom value we updated (so that no extra noise is ever added. We will always be up to one timer event behind sensor timestamp.
  3. Always update. If we get "late" lidar messages, we might "roll back" motion from the particle state in between timer activations and in the end, end up adding more noise.

I tend to like more 1 or 2. 3 might add a lot more noise if the late arrivers are recurrent.
Say we update motion at 40hz, get lidar messages at 10hz, and lidar messages always end up arriving after the timer for a later timestamp already updated the filter. 1 in four message will add false motion and increase noise.

Thoughts @fbattocchia @hidmic ?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Say, we execute get_base_pose_in_odom() at 10, 20, 30, 40, 50 and then we get a lidar message with timestamp 45. We might be adding a little bit more noise then.

I mean, we would be "rolling back" motion from 50 to 45, then forwarding again from 45 to 60 in the next timer event, thus adding extra "motion" and thus noise (proportional to motion).

@hidmic hidmic Aug 13, 2025

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I wonder if it can happen that we get a lidar message with a timestamp that is previous to a get_base_pose_in_odom() update.

That's a very good point, and yes, this can absolutely happen. It's an out of sequence measurement (OOSM) case. A principled, general solution is probably out of scope for your purposes though (e.g. https://ieeexplore.ieee.org/document/8455401, but there is quite a bit of research on the topic).

I tend to like more 1 or 2. 3 might add a lot more noise if the late arrivers are recurrent.

馃 What if we don't touch the filter itself? If we let it evolve at lidar rates, we can still propagate the latest distribution up to current time on timer callback. Our control deltas wouldn't be any larger than they are at lidar rates. You could even do that deterministically on the latest estimate by applying an unscented transform with the motion nonlinear function (which we don't have, we just have a sampler, but it could be added) and spare the cost of sampled propagation.

@glpuga glpuga Aug 13, 2025

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

What if we don't touch the filter itself? If we let it evolve at lidar rates...

I'm not sure I follow. Can you provide an example of what it would look like?


Another option is to do the the updates lazily:

We don't update the filter in the timer callback, we just take note of the latest odometry motion at that time and queue it for later. When the lidar message arrives, we process the queue all the way up to the last timestamp that is before the lidar timestamp and leave any remaining one in the queue for later.

We transfer the work of updating the filter to the lidar callback and force an ordered update sequence.

@hidmic hidmic Aug 13, 2025

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Ahh, I see now. I thought you wanted estimates at a rate higher than the lidar rate, but this is about processing odometry at a higher rate than lidar rate. If it were the former, what I meant is that you can always predict forward from the latest estimate (which is almost the same as relying on odometry given a fixed map to odom transform but for the explicit uncertainty modelling). But I sense it's the latter, so +100 to lazy updates. Maybe we can collect all odometry updates in a bigger control window.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I think it's a good idea to implement lazily updates.

if (!base_pose_in_odom.has_value()) {
return;
}

Expand All @@ -505,7 +552,7 @@ void AmclNode::laser_callback(sensor_msgs::msg::LaserScan::ConstSharedPtr laser_

const auto update_start_time = std::chrono::high_resolution_clock::now();
const auto new_estimate = particle_filter_->update(
base_pose_in_odom, //
base_pose_in_odom.value(), //
beluga_ros::LaserScan{
laser_scan,
laser_pose_in_base,
Expand All @@ -518,7 +565,7 @@ void AmclNode::laser_callback(sensor_msgs::msg::LaserScan::ConstSharedPtr laser_

if (new_estimate.has_value()) {
const auto& [base_pose_in_map, _] = new_estimate.value();
last_known_odom_transform_in_map_ = base_pose_in_map * base_pose_in_odom.inverse();
last_known_odom_transform_in_map_ = base_pose_in_map * base_pose_in_odom.value().inverse();
last_known_estimate_ = new_estimate;

RCLCPP_INFO(
Expand Down
81 changes: 81 additions & 0 deletions beluga_amcl/test/test_amcl_node.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ class AmclNodeUnderTest : public beluga_amcl::AmclNode {

/// Return the last known estimate. Throws if there is no estimate.
const auto& estimate() { return last_known_estimate_.value(); }

/// Check if propagation timer is created
bool has_propagation_timer() const { return propagation_timer_ != nullptr; }

/// Expose propagation timer callback for testing
void propagation_timer_callback() { AmclNode::propagation_timer_callback(); }
};

/// Base node fixture class with common utilities.
Expand Down Expand Up @@ -698,6 +704,81 @@ TEST_F(TestNode, TransformValue) {
EXPECT_NEAR(transform.so2().log(), 0.0, 0.01);
}

TEST_F(TestNode, PropagationTimerNotCreatedWhenDisabled) {
amcl_node_->set_parameter(rclcpp::Parameter{"increase_propagation", 0.0});
amcl_node_->configure();
amcl_node_->activate();
tester_node_->publish_map();
ASSERT_TRUE(wait_for_initialization());

// Timer should not be created when frequency is 0
EXPECT_FALSE(amcl_node_->has_propagation_timer());
}

TEST_F(TestNode, PropagationTimerCreatedWhenEnabled) {
amcl_node_->set_parameter(rclcpp::Parameter{"increase_propagation", 10.0});
amcl_node_->configure();
amcl_node_->activate();
tester_node_->publish_map();
ASSERT_TRUE(wait_for_initialization());

// Timer should be created when frequency > 0
EXPECT_TRUE(amcl_node_->has_propagation_timer());
}

TEST_F(TestNode, PropagationTimerIntegrationTest) {
amcl_node_->set_parameter(rclcpp::Parameter{"increase_propagation", 5.0});
amcl_node_->set_parameter(rclcpp::Parameter{"set_initial_pose", true});
amcl_node_->configure();
amcl_node_->activate();
tester_node_->publish_map();
ASSERT_TRUE(wait_for_initialization());

// Configure TF so get_base_pose_in_odom works by publishing a laser scan with transform
tester_node_->publish_laser_scan_with_odom_to_base(Sophus::SE2d{});

// Wait for several timer executions
spin_for(500ms, amcl_node_, tester_node_);

// Verify particle filter still exists and has particles
EXPECT_TRUE(amcl_node_->particle_filter() != nullptr);
EXPECT_GT(amcl_node_->particle_filter()->particles().size(), 0UL);
}

TEST_F(TestNode, PropagationTimerWithoutParticles) {
// Call propagation timer callback when particle filter is not initialized
// This should trigger early return due to !particle_filter_ check
amcl_node_->propagation_timer_callback();

// Verify that particle filter remains uninitialized
// The callback should have returned early without creating the filter
EXPECT_FALSE(amcl_node_->particle_filter() != nullptr);
}

TEST_F(TestNode, PropagationTimerWithoutBaseToOdom) {
amcl_node_->set_parameter(rclcpp::Parameter{"set_initial_pose", true});
amcl_node_->configure();
amcl_node_->activate();
tester_node_->publish_map();
ASSERT_TRUE(wait_for_initialization());

// Particle filter is initialized but no odom->base transform is available
EXPECT_TRUE(amcl_node_->is_initialized());
EXPECT_TRUE(amcl_node_->particle_filter() != nullptr);

// Capture the current particles before calling the propagation callback
const auto particles_before = amcl_node_->particle_filter()->particles();
EXPECT_GT(particles_before.size(), 0UL);

// Call propagation callback without any transform data
// This should return early because get_base_pose_in_odom() will fail
amcl_node_->propagation_timer_callback();

// Verify particles remain unchanged since no propagation occurred
const auto particles_after = amcl_node_->particle_filter()->particles();
EXPECT_EQ(particles_before.size(), particles_after.size());
}

class TestParameterValue : public ::testing::TestWithParam<rclcpp::Parameter> {};

INSTANTIATE_TEST_SUITE_P(
Expand Down
10 changes: 10 additions & 0 deletions beluga_ros/include/beluga_ros/amcl.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,16 @@ class Amcl {
/// Update the map used for localization.
void update_map(beluga_ros::OccupancyGrid map);

/// Update particles based on motion only (propagation only).
/**
* This method only performs the propagation step of the particle filter update,
* applying the motion model without any sensor correction. Useful for forced
* propagation at regular intervals without waiting for sensor data.
*
* \param base_pose_in_odom Base pose in the odometry frame.
*/
void update_propagation(Sophus::SE2d base_pose_in_odom);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@fbattocchia consider making this just another update overload.

Suggested change
void update_propagation(Sophus::SE2d base_pose_in_odom);
void update(const Sophus::SE2d& base_pose_in_odom);

It is updating after all, it just uses less data.


/// Update particles based on motion and sensor information.
/**
* This method performs a particle filter update step using motion and sensor data. It evaluates whether
Expand Down
14 changes: 14 additions & 0 deletions beluga_ros/src/amcl.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,20 @@ void Amcl::update_map(beluga_ros::OccupancyGrid map) {
std::visit([&](auto& sensor_model) { sensor_model.update_map(std::move(map)); }, sensor_model_);
}

void Amcl::update_propagation(Sophus::SE2d base_pose_in_odom) {
if (particles_.empty()) {
return;
}

// Force propagation without checking update_policy_
std::visit(
[&, this](auto& policy, auto& motion_model) {
particles_ |= beluga::actions::propagate(policy, motion_model(control_action_window_ << base_pose_in_odom)) |
beluga::actions::normalize(policy);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We don't need to normalize, since we only affected distribution density, but we left the weights unchanged.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

+1

},
execution_policy_, motion_model_);
}

auto Amcl::update(Sophus::SE2d base_pose_in_odom, beluga_ros::LaserScan laser_scan)
-> std::optional<std::pair<Sophus::SE2d, Sophus::Matrix3d>> {
if (particles_.empty()) {
Expand Down
6 changes: 6 additions & 0 deletions beluga_ros/test/test_amcl.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -149,4 +149,10 @@ TEST(TestAmcl, UpdateWithParticlesForced) {
ASSERT_TRUE(estimate.has_value());
}

TEST(TestAmcl, UpdatePropagationWithNoParticles) {
auto amcl = make_amcl();
amcl.update_propagation(Sophus::SE2d{});
ASSERT_EQ(amcl.particles().size(), 0);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I get that this manages to make the coverage checker think that the function was tested, but it does not look like its functionally testing anything. Let's do a propagation step with a few particles in the set.

@fbattocchia-ekumen fbattocchia-ekumen Aug 17, 2025

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yes, I did the test to achieve coverage, I agree with it doesn't make much sense. What I need to test is that it doesn't do anything because there are no particles. I changed the function's logic so I wouldn't have to do that check. (Another option would be to return a bool and verify the return in the test.)

} // namespace
Loading