PlanServer {
.insert(robot_id.to_string(), msg.clone());
}
+ pub fn handle_plan_error(&mut self, robot_id: &str, msg: PlanError) {
+ if msg.error.code == PlanError::CODE_PATH_BLOCKED {
+ rclrs::log_warn!(
+ self.node.logger(),
+ "Received CODE_PATH_BLOCKED for robot {}. Enqueuing replan...",
+ robot_id
+ );
+ if let Some(dest) = self.active_destinations.get(robot_id).cloned() {
+ self.replan_queue.push((robot_id.to_string(), dest));
+ }
+ }
+ }
+
pub fn replan(&mut self) {
// 1. Check if any planning results have arrived from the background thread
if let Ok(receiver) = self.plan_receiver.get_mut() {
@@ -472,6 +485,7 @@ impl PlanServer {
pub struct RobotPathConnections {
pub _destination_subscription: rclrs::WorkerSubscription>,
pub _odom_subscription: rclrs::WorkerSubscription>,
+ pub _plan_error_subscription: rclrs::WorkerSubscription>,
}
pub struct DiscoveryServer {
@@ -607,11 +621,34 @@ pub fn start_path_server(
}
};
+ let robot_id_clone3 = robot_id.to_string();
+ let plan_error_topic = robot_id.to_string() + "/plan/error";
+ let plan_error_sub = match server
+ .destinations_worker
+ .create_subscription::(
+ plan_error_topic.as_str(),
+ move |dest_server: &mut PlanServer, error_msg: PlanError| {
+ dest_server.handle_plan_error(&robot_id_clone3, error_msg);
+ },
+ ) {
+ Ok(sub) => sub,
+ Err(err) => {
+ rclrs::log_error!(
+ server.node.logger(),
+ "Failed to create plan error subscription on DestinationsWorker for {}: {:?}",
+ robot_id,
+ err
+ );
+ return;
+ }
+ };
+
server.active_robots.insert(
robot_id.to_string(),
RobotPathConnections {
_destination_subscription: destination_sub,
_odom_subscription: odom_sub,
+ _plan_error_subscription: plan_error_sub,
},
);
}
diff --git a/path_server/rmf_path_server/src/planner.rs b/path_server/rmf_path_server/src/planner.rs
index 8b2130a..22323cc 100644
--- a/path_server/rmf_path_server/src/planner.rs
+++ b/path_server/rmf_path_server/src/planner.rs
@@ -21,6 +21,8 @@ use std::collections::HashMap;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
+const MIN_PLANNING_RESOLUTION: f32 = 1.0;
+
#[derive(Clone, Debug, Default)]
pub struct Map {
pub grid: OccupancyGrid,
@@ -79,6 +81,44 @@ impl PibtPlanner {
}
}
+fn planning_grid(grid: &OccupancyGrid) -> (usize, usize, f32, f32, f32, Vec>) {
+ let source_width = grid.info.width as usize;
+ let source_height = grid.info.height as usize;
+ let source_resolution = grid.info.resolution;
+ let resolution = source_resolution.max(MIN_PLANNING_RESOLUTION);
+ let width = ((source_width as f32 * source_resolution) / resolution)
+ .ceil()
+ .max(1.0) as usize;
+ let height = ((source_height as f32 * source_resolution) / resolution)
+ .ceil()
+ .max(1.0) as usize;
+ let mut cells = vec![vec![0; height]; width];
+
+ for source_x in 0..source_width {
+ for source_y in 0..source_height {
+ let value = grid
+ .data
+ .get(source_y * source_width + source_x)
+ .copied()
+ .unwrap_or(-1);
+ if value > 50 || value == -1 {
+ let x = ((source_x as f32 * source_resolution) / resolution).floor() as usize;
+ let y = ((source_y as f32 * source_resolution) / resolution).floor() as usize;
+ cells[x.min(width - 1)][y.min(height - 1)] = 1;
+ }
+ }
+ }
+
+ (
+ width,
+ height,
+ resolution,
+ grid.info.origin.position.x as f32,
+ grid.info.origin.position.y as f32,
+ cells,
+ )
+}
+
impl MapfPlanner for PibtPlanner {
fn plan(
&self,
@@ -97,20 +137,7 @@ impl MapfPlanner for PibtPlanner {
map.grid.info.width > 0 && map.grid.info.height > 0 && map.grid.info.resolution > 0.0;
let (width, height, resolution, offset_x, offset_y, grid) = if use_map {
- let w = map.grid.info.width as usize;
- let h = map.grid.info.height as usize;
- let r = map.grid.info.resolution;
- let ox = map.grid.info.origin.position.x as f32;
- let oy = map.grid.info.origin.position.y as f32;
-
- let mut g = vec![vec![0; h]; w];
- for x in 0..w {
- for y in 0..h {
- let ros_val = map.grid.data[y * w + x];
- g[x][y] = if ros_val > 50 || ros_val == -1 { 1 } else { 0 };
- }
- }
- (w, h, r, ox, oy, g)
+ planning_grid(&map.grid)
} else {
let mut min_x = f32::MAX;
let mut min_y = f32::MAX;
@@ -249,3 +276,45 @@ impl MapfPlanner for PibtPlanner {
Ok(trajectories)
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn fine_occupancy_cells_are_conservatively_downsampled() {
+ let mut map = OccupancyGrid::default();
+ map.info.resolution = 0.25;
+ map.info.width = 8;
+ map.info.height = 4;
+ map.info.origin.position.x = -1.0;
+ map.info.origin.position.y = -2.0;
+ map.data = vec![0; 32];
+ map.data[2 * 8 + 5] = 100;
+
+ let (width, height, resolution, offset_x, offset_y, cells) = planning_grid(&map);
+
+ assert_eq!(width, 2);
+ assert_eq!(height, 1);
+ assert_eq!(resolution, 1.0);
+ assert_eq!(offset_x, -1.0);
+ assert_eq!(offset_y, -2.0);
+ assert_eq!(cells, vec![vec![0], vec![1]]);
+ }
+
+ #[test]
+ fn native_grid_is_kept_when_it_is_already_coarse() {
+ let mut map = OccupancyGrid::default();
+ map.info.resolution = 2.0;
+ map.info.width = 2;
+ map.info.height = 2;
+ map.data = vec![0, 100, 0, 0];
+
+ let (width, height, resolution, _, _, cells) = planning_grid(&map);
+
+ assert_eq!(width, 2);
+ assert_eq!(height, 2);
+ assert_eq!(resolution, 2.0);
+ assert_eq!(cells, vec![vec![0, 0], vec![1, 0]]);
+ }
+}
diff --git a/path_server/rmf_path_server_test/test/test_path_server_error.py b/path_server/rmf_path_server_test/test/test_path_server_error.py
new file mode 100644
index 0000000..e9e23f3
--- /dev/null
+++ b/path_server/rmf_path_server_test/test/test_path_server_error.py
@@ -0,0 +1,231 @@
+# Copyright 2026 Open Source Robotics Foundation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import time
+import unittest
+
+from launch import LaunchDescription
+import launch_ros
+import launch_testing
+import pytest
+import rclpy
+from rclpy.qos import DurabilityPolicy, HistoryPolicy, QoSProfile, ReliabilityPolicy
+from rmf_prototype_msgs.msg import (
+ Destination,
+ DestinationConstraints,
+ Participant,
+ ParticipantList,
+ Plan,
+ PlanError,
+ Region,
+ TargetRegion,
+)
+
+
+@pytest.mark.launch_test
+def generate_test_description():
+ path_server = launch_ros.actions.Node(
+ package='rmf_path_server',
+ executable='rmf_path_server',
+ output='screen'
+ )
+
+ robot_1_sim = launch_ros.actions.Node(
+ package='rmf_mock_robot_sim',
+ executable='rmf_mock_robot_sim',
+ output='screen',
+ parameters=[{
+ 'robot_name': 'robot_1',
+ 'speed': 2.0,
+ 'update_rate': 20.0,
+ 'initial_x': 0.0,
+ 'initial_y': 0.0,
+ 'initial_yaw': 0.0,
+ 'publish_discovery': False,
+ }]
+ )
+
+ return LaunchDescription([
+ path_server,
+ robot_1_sim,
+ launch_testing.actions.ReadyToTest(),
+ ]), {
+ 'path_server': path_server,
+ 'robot_1_sim': robot_1_sim,
+ }
+
+
+class TestPathServerError(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ rclpy.init()
+
+ @classmethod
+ def tearDownClass(cls):
+ rclpy.shutdown()
+
+ def setUp(self):
+ self.node = rclpy.create_node('test_path_server_error_node')
+
+ def tearDown(self):
+ self.node.destroy_node()
+
+ def create_destination(self, session_id, x, y, size):
+ msg = Destination()
+ msg.session.uuid = [session_id] * 16
+ constraint = DestinationConstraints()
+ target_region = TargetRegion()
+ target_region.region.hint = Region.HINT_AXIS_ALIGNED_RECTANGLE
+ target_region.region.points = [
+ float(x),
+ float(y),
+ float(x + size),
+ float(y + size),
+ ]
+ constraint.regions.append(target_region)
+ msg.constraints = constraint
+ return msg
+
+ def test_plan_error_behavior(self):
+ received_plans = []
+
+ reliable_transient_qos = QoSProfile(
+ depth=10,
+ durability=DurabilityPolicy.TRANSIENT_LOCAL,
+ history=HistoryPolicy.KEEP_LAST,
+ reliability=ReliabilityPolicy.RELIABLE,
+ )
+
+ def plan_cb(msg):
+ received_plans.append(msg)
+
+ self.node.create_subscription(
+ Plan,
+ 'robot_1/plan',
+ plan_cb,
+ qos_profile=reliable_transient_qos
+ )
+
+ dest_pub = self.node.create_publisher(
+ Destination,
+ 'robot_1/destination',
+ qos_profile=reliable_transient_qos
+ )
+
+ error_pub = self.node.create_publisher(
+ PlanError,
+ 'robot_1/plan/error',
+ qos_profile=reliable_transient_qos
+ )
+
+ discovery_qos = QoSProfile(
+ depth=1,
+ durability=DurabilityPolicy.TRANSIENT_LOCAL,
+ history=HistoryPolicy.KEEP_LAST
+ )
+
+ discovery_pub = self.node.create_publisher(
+ ParticipantList,
+ '/destination/discovery',
+ qos_profile=discovery_qos
+ )
+
+ discovery_msg = ParticipantList()
+ p1 = Participant()
+ p1.name = 'robot_1'
+ p1.components = []
+ discovery_msg.participants.append(p1)
+
+ time.sleep(2.0)
+
+ dest = self.create_destination(1, 5.0, 0.0, 1.0)
+
+ # 1. Publish discovery and destination to receive initial plan
+ for _ in range(5):
+ discovery_pub.publish(discovery_msg)
+ dest_pub.publish(dest)
+ rclpy.spin_once(self.node, timeout_sec=0.1)
+ time.sleep(0.1)
+
+ start_time = time.time()
+ timeout = 10.0
+ while time.time() - start_time < timeout:
+ discovery_pub.publish(discovery_msg)
+ rclpy.spin_once(self.node, timeout_sec=0.1)
+ if received_plans:
+ break
+ time.sleep(0.1)
+
+ self.assertGreater(
+ len(received_plans), 0, 'Did not receive initial plan for robot_1'
+ )
+ initial_plan = received_plans[-1]
+ initial_version = initial_plan.plan_id.plan_version
+
+ # 2. Test publishing an unhandled error code (e.g. CODE_UNRECOGNIZED_ACTION)
+ # Verify that it does NOT trigger a replan
+ unhandled_error_msg = PlanError()
+ unhandled_error_msg.error.code = PlanError.CODE_UNRECOGNIZED_ACTION
+ unhandled_error_msg.error.message = 'Unrecognized action test'
+ unhandled_error_msg.plan_id = initial_plan.plan_id
+
+ error_pub.publish(unhandled_error_msg)
+ spin_start = time.time()
+ while time.time() - spin_start < 1.0:
+ discovery_pub.publish(discovery_msg)
+ rclpy.spin_once(self.node, timeout_sec=0.1)
+ time.sleep(0.1)
+
+ self.assertEqual(
+ received_plans[-1].plan_id.plan_version,
+ initial_version,
+ 'Unhandled error code should not trigger a replan'
+ )
+
+ # 3. Publish CODE_PATH_BLOCKED PlanError to trigger replanning
+ plan_error_msg = PlanError()
+ plan_error_msg.error.code = PlanError.CODE_PATH_BLOCKED
+ plan_error_msg.error.message = 'Path is blocked'
+ plan_error_msg.plan_id = initial_plan.plan_id
+
+ num_plans_before_error = len(received_plans)
+ error_pub.publish(plan_error_msg)
+
+ start_time = time.time()
+ new_plan_received = False
+ while time.time() - start_time < timeout:
+ discovery_pub.publish(discovery_msg)
+ rclpy.spin_once(self.node, timeout_sec=0.1)
+ if len(received_plans) > num_plans_before_error:
+ latest_plan = received_plans[-1]
+ if latest_plan.plan_id.plan_version > initial_version:
+ new_plan_received = True
+ break
+ time.sleep(0.1)
+
+ self.assertTrue(
+ new_plan_received,
+ 'Expected new plan with incremented version after publishing CODE_PATH_BLOCKED'
+ )
+ latest_plan = received_plans[-1]
+ self.assertEqual(
+ latest_plan.plan_id.plan_version,
+ initial_version + 1,
+ f'Plan version should be incremented from {initial_version} to {initial_version + 1}'
+ )
+ self.assertEqual(
+ list(latest_plan.plan_id.destination_session.uuid),
+ list(initial_plan.plan_id.destination_session.uuid),
+ 'Session UUID should remain the same across replans for the same destination session'
+ )
diff --git a/path_server/rmf_plan_executor/src/lib.rs b/path_server/rmf_plan_executor/src/lib.rs
index 601cd5f..d53df13 100644
--- a/path_server/rmf_plan_executor/src/lib.rs
+++ b/path_server/rmf_plan_executor/src/lib.rs
@@ -22,17 +22,23 @@ use ros_env::builtin_interfaces;
use ros_env::geometry_msgs::msg::Pose;
use ros_env::nav2_msgs;
use ros_env::nav2_msgs::msg::Costmap;
-use ros_env::nav_msgs::msg::Odometry;
+use ros_env::nav_msgs::msg::{OccupancyGrid, Odometry};
use ros_env::rmf_prototype_msgs;
use ros_env::rmf_prototype_msgs::msg::{
- DestinationConstraints, Plan, PlanRelease, SafeZone, SafeZoneId, TargetOrientation,
+ DestinationConstraints, Plan, PlanError, PlanId, PlanRelease, SafeZone, SafeZoneId,
+ TargetOrientation,
};
use ros_env::std_msgs;
use std::{
collections::{BTreeMap, HashMap},
sync::Arc,
+ time::{Duration, Instant},
};
+const BLOCKAGE_DEBOUNCE: Duration = Duration::from_millis(300);
+const REPLAN_COOLDOWN: Duration = Duration::from_secs(2);
+const OCCUPIED_THRESHOLD: i8 = 50;
+
pub struct RobotState {
pub radius: f32,
pub latest_odom: Option,
@@ -40,6 +46,48 @@ pub struct RobotState {
pub waypoint_follower: Option,
pub safe_zone_version: u64,
pub last_incremental_target_wp: Option,
+ blockage_monitor: BlockageMonitor,
+}
+
+#[derive(Default)]
+struct BlockageMonitor {
+ blocked_since: Option,
+ reported_plan: Option,
+ last_reported_at: Option,
+}
+
+impl BlockageMonitor {
+ fn begin_plan(&mut self) {
+ self.blocked_since = None;
+ }
+
+ fn observe(&mut self, blocked: bool, plan_id: &PlanId, now: Instant) -> bool {
+ if !blocked {
+ self.blocked_since = None;
+ return false;
+ }
+
+ if self.reported_plan.as_ref() == Some(plan_id) {
+ return false;
+ }
+
+ let blocked_since = self.blocked_since.get_or_insert(now);
+ if now.duration_since(*blocked_since) < BLOCKAGE_DEBOUNCE {
+ return false;
+ }
+
+ if self
+ .last_reported_at
+ .is_some_and(|last| now.duration_since(last) < REPLAN_COOLDOWN)
+ {
+ return false;
+ }
+
+ self.reported_plan = Some(plan_id.clone());
+ self.last_reported_at = Some(now);
+ self.blocked_since = None;
+ true
+ }
}
pub struct PlanExecutor {
@@ -50,11 +98,38 @@ pub struct PlanExecutor {
pub active_robots: BTreeMap,
pub plan_release_publishers: HashMap>,
pub safezone_publishers: HashMap>,
+ pub plan_error_publishers: HashMap>,
pub grid: Arc,
pub grid_width: u32,
pub grid_height: u32,
pub grid_resolution: f32,
pub grid_origin: Pose,
+ pub latest_map: Option,
+}
+
+fn target_yaw(plan: &Plan, target_idx: usize) -> f32 {
+ let Some(target) = plan.waypoints.get(target_idx) else {
+ return 0.0;
+ };
+ let [target_x, target_y] = target.position;
+
+ for waypoint in plan.waypoints[..target_idx].iter().rev() {
+ let dx = target_x - waypoint.position[0];
+ let dy = target_y - waypoint.position[1];
+ if dx.hypot(dy) > 1e-3 {
+ return dy.atan2(dx);
+ }
+ }
+
+ for waypoint in plan.waypoints.iter().skip(target_idx + 1) {
+ let dx = waypoint.position[0] - target_x;
+ let dy = waypoint.position[1] - target_y;
+ if dx.hypot(dy) > 1e-3 {
+ return dy.atan2(dx);
+ }
+ }
+
+ 0.0
}
impl PlanExecutor {
@@ -66,11 +141,13 @@ impl PlanExecutor {
active_robots: BTreeMap::new(),
plan_release_publishers: HashMap::new(),
safezone_publishers: HashMap::new(),
+ plan_error_publishers: HashMap::new(),
grid: Arc::new(Grid2D::new(vec![vec![0; 20]; 20], 1.0)),
grid_width: 20,
grid_height: 20,
grid_resolution: 1.0,
grid_origin: origin,
+ latest_map: None,
}
}
@@ -91,6 +168,7 @@ impl PlanExecutor {
waypoint_follower: None,
safe_zone_version: 0,
last_incremental_target_wp: None,
+ blockage_monitor: BlockageMonitor::default(),
},
);
self.reindex_followers();
@@ -106,6 +184,7 @@ impl PlanExecutor {
);
self.plan_release_publishers.remove(robot_id);
self.safezone_publishers.remove(robot_id);
+ self.plan_error_publishers.remove(robot_id);
self.reindex_followers();
}
}
@@ -164,37 +243,50 @@ impl PlanExecutor {
state.plan = Some(msg);
state.safe_zone_version = 0;
state.last_incremental_target_wp = None;
+ state.blockage_monitor.begin_plan();
// Reindex because we updated the plan
self.reindex_followers();
}
+ pub fn handle_map(&mut self, msg: OccupancyGrid) {
+ self.latest_map = Some(msg);
+ let robot_ids: Vec<_> = self.active_robots.keys().cloned().collect();
+ for robot_id in robot_ids {
+ self.update_route_blockage(&robot_id);
+ }
+ }
+
pub fn handle_odometry(&mut self, robot_id: &str, msg: Odometry) {
let current_x = msg.pose.pose.position.x as f32;
let current_y = msg.pose.pose.position.y as f32;
- let Some(state) = self.active_robots.get_mut(robot_id) else {
- return;
- };
-
- state.latest_odom = Some(msg.clone());
-
- if let Some(fw) = &mut state.waypoint_follower {
- let before = fw.get_semantic_waypoint().trajectory_index;
- let position = Isometry2::new(Vector2::new(current_x, current_y), 0.0);
- fw.update_position_estimate(&position, 0.5);
- let after = fw.get_semantic_waypoint().trajectory_index;
- rclrs::log_debug!(
- self.node.logger(),
- "[Executor Debug] Robot {} pos=({}, {}) index before={}, after={}",
- robot_id,
- current_x,
- current_y,
- before,
- after
- );
+ {
+ let Some(state) = self.active_robots.get_mut(robot_id) else {
+ return;
+ };
+
+ state.latest_odom = Some(msg.clone());
+
+ if let Some(fw) = &mut state.waypoint_follower {
+ let before = fw.get_semantic_waypoint().trajectory_index;
+ let position = Isometry2::new(Vector2::new(current_x, current_y), 0.0);
+ fw.update_position_estimate(&position, 0.5);
+ let after = fw.get_semantic_waypoint().trajectory_index;
+ rclrs::log_debug!(
+ self.node.logger(),
+ "[Executor Debug] Robot {} pos=({}, {}) index before={}, after={}",
+ robot_id,
+ current_x,
+ current_y,
+ before,
+ after
+ );
+ }
}
+ self.update_route_blockage(robot_id);
+
if !self.ready_to_execute() {
return;
}
@@ -412,6 +504,7 @@ impl PlanExecutor {
let target_x = plan.waypoints[released_wp_idx].position[0];
let target_y = plan.waypoints[released_wp_idx].position[1];
+ let target_yaw = target_yaw(plan, released_wp_idx);
let costmap = Self::to_costmap_msg(
&positions,
@@ -433,16 +526,17 @@ impl PlanExecutor {
let safe_zone = SafeZone {
incremental_target: DestinationConstraints {
regions: vec![rmf_prototype_msgs::msg::TargetRegion {
- tolerance: 0.2,
+ tolerance: 0.1,
region: rmf_prototype_msgs::msg::Region {
points: vec![target_x, target_y],
hint: rmf_prototype_msgs::msg::Region::HINT_POINT,
},
orientations: vec![TargetOrientation {
- orientation_radians: 0.0, // TODO(@xiyuoh)
+ // Avoid turning in place at hold points.
+ orientation_radians: target_yaw,
spread_radians: 0.0,
tolerance_radians: 0.0,
- }], // TODO(@xiyuoh) calculate actual orientation
+ }],
}],
nodes: vec![],
},
@@ -474,6 +568,82 @@ impl PlanExecutor {
}
}
+ fn update_route_blockage(&mut self, robot_id: &str) {
+ let Some(map) = self.latest_map.as_ref() else {
+ return;
+ };
+
+ let Some(state) = self.active_robots.get_mut(robot_id) else {
+ return;
+ };
+ let (Some(plan), Some(odom), Some(follower)) = (
+ state.plan.as_ref(),
+ state.latest_odom.as_ref(),
+ state.waypoint_follower.as_mut(),
+ ) else {
+ return;
+ };
+
+ let remaining = follower.remaining_trajectory();
+ let mut route = Vec::with_capacity(remaining.len() + 1);
+ route.push((
+ odom.pose.pose.position.x as f32,
+ odom.pose.pose.position.y as f32,
+ ));
+ route.extend(remaining);
+
+ let blocked = route_intersects_map(map, &route, state.radius);
+ let plan_id = plan.plan_id.clone();
+ if !state
+ .blockage_monitor
+ .observe(blocked, &plan_id, Instant::now())
+ {
+ return;
+ }
+
+ let publisher = match self.plan_error_publishers.entry(robot_id.to_string()) {
+ std::collections::hash_map::Entry::Occupied(entry) => entry.into_mut(),
+ std::collections::hash_map::Entry::Vacant(entry) => {
+ let topic = format!("{robot_id}/plan/error");
+ match self.node.create_publisher(topic.as_str()) {
+ Ok(publisher) => entry.insert(publisher),
+ Err(error) => {
+ rclrs::log_error!(
+ self.node.logger(),
+ "Failed to create plan error publisher for {}: {:?}",
+ robot_id,
+ error
+ );
+ return;
+ }
+ }
+ }
+ };
+
+ let error = PlanError {
+ error: rmf_prototype_msgs::msg::Error {
+ code: PlanError::CODE_PATH_BLOCKED,
+ message: format!("Updated map blocks the remaining route for {robot_id}"),
+ parameters: String::new(),
+ },
+ plan_id,
+ };
+ if let Err(error) = publisher.publish(error) {
+ rclrs::log_error!(
+ self.node.logger(),
+ "Failed to publish path blockage for {}: {:?}",
+ robot_id,
+ error
+ );
+ } else {
+ rclrs::log_warn!(
+ self.node.logger(),
+ "Updated map blocks the remaining route for {}. Requesting a replan.",
+ robot_id
+ );
+ }
+ }
+
fn ready_to_execute(&self) -> bool {
if self.active_robots.is_empty() {
return false;
@@ -527,10 +697,185 @@ impl PlanExecutor {
}
}
+fn route_intersects_map(map: &OccupancyGrid, route: &[(f32, f32)], radius: f32) -> bool {
+ if route.len() < 2 || map.info.resolution <= 0.0 {
+ return false;
+ }
+
+ let width = map.info.width as isize;
+ let height = map.info.height as isize;
+ if width == 0 || height == 0 {
+ return false;
+ }
+
+ let resolution = map.info.resolution;
+ let q = &map.info.origin.orientation;
+ let yaw = (2.0 * (q.w * q.z + q.x * q.y)).atan2(1.0 - 2.0 * (q.y * q.y + q.z * q.z)) as f32;
+ let cos_yaw = yaw.cos();
+ let sin_yaw = yaw.sin();
+ let origin_x = map.info.origin.position.x as f32;
+ let origin_y = map.info.origin.position.y as f32;
+ let clearance = radius.max(0.0) + resolution * std::f32::consts::FRAC_1_SQRT_2;
+ let clearance_squared = clearance * clearance;
+
+ let to_map = |(x, y): (f32, f32)| {
+ let dx = x - origin_x;
+ let dy = y - origin_y;
+ (cos_yaw * dx + sin_yaw * dy, -sin_yaw * dx + cos_yaw * dy)
+ };
+
+ for segment in route.windows(2) {
+ let start = to_map(segment[0]);
+ let end = to_map(segment[1]);
+ let min_x = (((start.0.min(end.0) - clearance) / resolution).floor() as isize).max(0);
+ let max_x =
+ (((start.0.max(end.0) + clearance) / resolution).floor() as isize).min(width - 1);
+ let min_y = (((start.1.min(end.1) - clearance) / resolution).floor() as isize).max(0);
+ let max_y =
+ (((start.1.max(end.1) + clearance) / resolution).floor() as isize).min(height - 1);
+
+ for y in min_y..=max_y {
+ for x in min_x..=max_x {
+ let index = y as usize * width as usize + x as usize;
+ if map.data.get(index).copied().unwrap_or(-1) <= OCCUPIED_THRESHOLD {
+ continue;
+ }
+
+ let center = ((x as f32 + 0.5) * resolution, (y as f32 + 0.5) * resolution);
+ if distance_squared_to_segment(center, start, end) <= clearance_squared {
+ return true;
+ }
+ }
+ }
+ }
+
+ false
+}
+
+fn distance_squared_to_segment(point: (f32, f32), start: (f32, f32), end: (f32, f32)) -> f32 {
+ let segment = (end.0 - start.0, end.1 - start.1);
+ let length_squared = segment.0 * segment.0 + segment.1 * segment.1;
+ if length_squared <= f32::EPSILON {
+ return (point.0 - start.0).powi(2) + (point.1 - start.1).powi(2);
+ }
+
+ let offset = (point.0 - start.0, point.1 - start.1);
+ let t = ((offset.0 * segment.0 + offset.1 * segment.1) / length_squared).clamp(0.0, 1.0);
+ let closest = (start.0 + t * segment.0, start.1 + t * segment.1);
+ (point.0 - closest.0).powi(2) + (point.1 - closest.1).powi(2)
+}
+
#[cfg(test)]
mod tests {
+ use super::{
+ route_intersects_map, target_yaw, BlockageMonitor, BLOCKAGE_DEBOUNCE, REPLAN_COOLDOWN,
+ };
use mapf_post::na::{Isometry2, Vector2};
use mapf_post::{Trajectory, WaypointFollower};
+ use ros_env::{
+ nav_msgs::msg::OccupancyGrid,
+ rmf_prototype_msgs::msg::{Plan, PlanId, Waypoint},
+ };
+ use std::time::Instant;
+
+ fn plan_with_positions(positions: &[[f32; 2]]) -> Plan {
+ Plan {
+ waypoints: positions
+ .iter()
+ .map(|position| Waypoint {
+ position: *position,
+ ..Default::default()
+ })
+ .collect(),
+ ..Default::default()
+ }
+ }
+
+ #[test]
+ fn target_yaw_ignores_stationary_wait_waypoints() {
+ let plan = plan_with_positions(&[[3.0, 4.0], [3.0, 4.0], [3.0, 4.0], [3.0, 5.0]]);
+
+ assert!((target_yaw(&plan, 3) - std::f32::consts::FRAC_PI_2).abs() < 1e-6);
+ }
+
+ #[test]
+ fn target_yaw_uses_departure_direction_at_trajectory_start() {
+ let plan = plan_with_positions(&[[3.0, 4.0], [3.0, 4.0], [2.0, 4.0]]);
+
+ assert!((target_yaw(&plan, 0) - std::f32::consts::PI).abs() < 1e-6);
+ }
+
+ #[test]
+ fn stationary_trajectory_has_neutral_yaw() {
+ let plan = plan_with_positions(&[[3.0, 4.0], [3.0, 4.0]]);
+
+ assert_eq!(target_yaw(&plan, 1), 0.0);
+ }
+
+ #[test]
+ fn occupied_cell_on_remaining_route_is_blocked() {
+ let mut map = OccupancyGrid::default();
+ map.info.resolution = 1.0;
+ map.info.width = 10;
+ map.info.height = 10;
+ map.info.origin.orientation.w = 1.0;
+ map.data = vec![0; 100];
+ map.data[5 * 10 + 5] = 100;
+
+ assert!(route_intersects_map(&map, &[(1.5, 5.5), (8.5, 5.5)], 0.25));
+ assert!(!route_intersects_map(&map, &[(1.5, 8.5), (8.5, 8.5)], 0.25));
+ }
+
+ #[test]
+ fn blockage_must_persist_before_reporting() {
+ let mut monitor = BlockageMonitor::default();
+ let plan_id = PlanId::default();
+ let start = Instant::now();
+
+ assert!(!monitor.observe(true, &plan_id, start));
+ assert!(!monitor.observe(true, &plan_id, start + BLOCKAGE_DEBOUNCE / 2));
+ assert!(monitor.observe(true, &plan_id, start + BLOCKAGE_DEBOUNCE));
+ let later = start + BLOCKAGE_DEBOUNCE + REPLAN_COOLDOWN;
+ assert!(!monitor.observe(true, &plan_id, later));
+ }
+
+ #[test]
+ fn clear_route_resets_the_debounce_window() {
+ let mut monitor = BlockageMonitor::default();
+ let plan_id = PlanId::default();
+ let start = Instant::now();
+
+ assert!(!monitor.observe(true, &plan_id, start));
+ assert!(!monitor.observe(false, &plan_id, start + BLOCKAGE_DEBOUNCE));
+ assert!(!monitor.observe(true, &plan_id, start + BLOCKAGE_DEBOUNCE));
+ let before_debounce = start + BLOCKAGE_DEBOUNCE * 3 / 2;
+ assert!(!monitor.observe(true, &plan_id, before_debounce));
+ assert!(monitor.observe(true, &plan_id, start + BLOCKAGE_DEBOUNCE * 2));
+ }
+
+ #[test]
+ fn cooldown_delays_a_blockage_on_the_next_plan() {
+ let mut monitor = BlockageMonitor::default();
+ let first_plan = PlanId::default();
+ let second_plan = PlanId {
+ plan_version: 1,
+ ..Default::default()
+ };
+ let start = Instant::now();
+
+ assert!(!monitor.observe(true, &first_plan, start));
+ assert!(monitor.observe(true, &first_plan, start + BLOCKAGE_DEBOUNCE));
+ monitor.begin_plan();
+ let during_cooldown = start + BLOCKAGE_DEBOUNCE * 2;
+ assert!(!monitor.observe(true, &second_plan, during_cooldown));
+ let after_debounce = start + BLOCKAGE_DEBOUNCE * 3;
+ assert!(!monitor.observe(true, &second_plan, after_debounce));
+ assert!(monitor.observe(
+ true,
+ &second_plan,
+ start + BLOCKAGE_DEBOUNCE + REPLAN_COOLDOWN,
+ ));
+ }
#[test]
fn test_robot_2_follower() {
diff --git a/path_server/rmf_plan_executor/src/main.rs b/path_server/rmf_plan_executor/src/main.rs
index 1bb2c1d..8533b48 100644
--- a/path_server/rmf_plan_executor/src/main.rs
+++ b/path_server/rmf_plan_executor/src/main.rs
@@ -14,7 +14,7 @@
use rclrs::{Context, CreateBasicExecutor, IntoPrimitiveOptions, SpinOptions};
use rmf_plan_executor::PlanExecutor;
-use ros_env::nav_msgs::msg::Odometry;
+use ros_env::nav_msgs::msg::{OccupancyGrid, Odometry};
use ros_env::rmf_prototype_msgs::msg::{ParticipantList, Plan};
use std::collections::HashMap;
@@ -73,6 +73,13 @@ fn main() -> Result<(), Box> {
},
)?;
+ let _map_subscription = executor_worker.create_subscription::(
+ "/map".transient_local().reliable(),
+ move |executor: &mut PlanExecutor, msg: OccupancyGrid| {
+ executor.handle_map(msg);
+ },
+ )?;
+
// 2. Subscribe to discovery on the discovery worker to manage odom/plan subscriptions
let _discovery_subscription = rmf_participant_discovery::create_discovery_subscription(
&discovery_worker,