Skip to content

Commit 25cea7e

Browse files
kaiaaiclaude
andcommitted
coverage: skip unreachable waypoints fast (no-progress watchdog)
The reported "semi-stuck at a wall for tens of minutes" was Nav2 grinding on an unreachable waypoint: the planner repeatedly "Failed to create plan", the collision-averse backup/spin recoveries aborted ("Collision Ahead") because the footprint sits in costmap-lethal cells in the wedge, and each goal burned the full 30 s goal_timeout (minutes of wall time under a slow GUI) before skipping. Add a no-progress watchdog: while a goal is in flight, if the robot doesn't get meaningfully closer to it (>5 cm) for no_progress_sec (6 s), it's stuck/blocked — cancel and skip immediately (one strike, not a retry: a genuinely unreachable waypoint won't become reachable, and gap-fill revisits later). This short- circuits Nav2's recovery ladder, which can't help in a wedge anyway. Legitimate long transits keep making progress, so they're untouched; goal_timeout stays as a slow absolute backstop for a robot that creeps but never arrives. Verified: no false skips during normal driving, coverage unaffected. (The exact tens-of-minutes stall needs an unreachable waypoint to reproduce, which the test runs didn't hit; the watchdog fires by construction when it recurs.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b0ba012 commit 25cea7e

1 file changed

Lines changed: 44 additions & 9 deletions

File tree

src/oomwoo_coverage/oomwoo_coverage/coverage_planner_node.py

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,15 @@ def __init__(self) -> None:
139139
self.goal_deadline = None # per-waypoint watchdog start
140140
self.declare_parameter('goal_timeout_sec', 30.0)
141141
self.goal_timeout = self.get_parameter('goal_timeout_sec').value
142+
# No-progress skip: if the robot doesn't get meaningfully closer to the
143+
# current waypoint for this long it's stuck/blocked — give up FAST rather
144+
# than waiting out Nav2's collision-averse recovery ladder (backup/spin/
145+
# wait can't move in a costmap-lethal wedge) and the full goal timeout.
146+
# Legitimate long transits keep making progress, so they're untouched.
147+
self.declare_parameter('no_progress_sec', 6.0)
148+
self.no_progress_sec = self.get_parameter('no_progress_sec').value
149+
self._goal_best_dist = None # closest we've gotten to the current goal
150+
self._goal_progress_t = None # last time that distance improved
142151
self.declare_parameter('max_retries', 3)
143152
self.max_retries = self.get_parameter('max_retries').value
144153
self.awaiting = False
@@ -586,6 +595,8 @@ def _send_next(self) -> None:
586595
goal.pose = p
587596
self.awaiting = True
588597
self.goal_deadline = self.get_clock().now()
598+
self._goal_best_dist = None # reset no-progress tracking for this goal
599+
self._goal_progress_t = self.get_clock().now()
589600
self.nav_client.send_goal_async(goal).add_done_callback(
590601
self._on_goal_response)
591602

@@ -815,15 +826,39 @@ def _tick(self) -> None:
815826
+ rclpy.duration.Duration(seconds=self.escape_sec)
816827
return
817828

818-
# per-goal watchdog: cancel a waypoint Nav2 is grinding on
819-
if self.awaiting and self.goal_deadline is not None \
820-
and self._elapsed(self.goal_deadline) >= self.goal_timeout:
821-
self.get_logger().warn(f'waypoint {self.wp_index} timed out')
822-
if self.goal_handle is not None:
823-
self.goal_handle.cancel_goal_async() # -> _on_result(CANCELED)
824-
else:
825-
self.awaiting = False
826-
self.wp_retries = self.max_retries # force skip next result
829+
# per-goal watchdog: give up on a waypoint FAST. Primary trigger is
830+
# NO-PROGRESS — if the robot isn't getting closer, it's stuck/blocked and
831+
# Nav2's recovery ladder can't help, so don't wait it out; goal_timeout is
832+
# only a slow backstop for a robot that creeps but never arrives. Either
833+
# way it's a one-strike skip (not a retry): a genuinely stuck waypoint
834+
# won't become reachable by trying again, and gap-fill revisits later.
835+
if self.awaiting and self.goal_deadline is not None:
836+
stuck = False
837+
if self.robot_xy is not None \
838+
and self.wp_index < len(self.cached_poses):
839+
g = self.cached_poses[self.wp_index].pose.position
840+
dist = ((g.x - self.robot_xy[0]) ** 2
841+
+ (g.y - self.robot_xy[1]) ** 2) ** 0.5
842+
if dist <= 0.3: # basically arrived; let Nav2 finish
843+
self._goal_progress_t = self.get_clock().now()
844+
elif self._goal_best_dist is None \
845+
or dist < self._goal_best_dist - 0.05:
846+
self._goal_best_dist = dist # got closer: progress
847+
self._goal_progress_t = self.get_clock().now()
848+
elif self._goal_progress_t is not None \
849+
and self._elapsed(self._goal_progress_t) \
850+
>= self.no_progress_sec:
851+
stuck = True
852+
if stuck or self._elapsed(self.goal_deadline) >= self.goal_timeout:
853+
self.get_logger().warn(
854+
f'waypoint {self.wp_index} '
855+
+ ('stuck (no progress)' if stuck else 'timed out')
856+
+ '; skipping')
857+
self.wp_retries = self.max_retries # one-strike: skip, not retry
858+
if self.goal_handle is not None:
859+
self.goal_handle.cancel_goal_async() # -> _on_result -> skip
860+
else:
861+
self.awaiting = False
827862
# several skips in a row = wedged in a costmap-lethal pocket; Nav2's
828863
# own recoveries refuse to move there, so recover ourselves
829864
elif not self.awaiting and self.consecutive_skips >= self.escape_after:

0 commit comments

Comments
 (0)