diff --git a/catkin_ws/src/pyroscope_navigation/COVERAGE_TUNING.md b/catkin_ws/src/pyroscope_navigation/COVERAGE_TUNING.md
new file mode 100644
index 0000000..d89af86
--- /dev/null
+++ b/catkin_ws/src/pyroscope_navigation/COVERAGE_TUNING.md
@@ -0,0 +1,269 @@
+# Coverage Mission Nav — Tunable Parameters Guide
+
+A complete reference for every parameter you can adjust when testing and tuning
+the `coverage_mission_nav.launch` deployment. Values shown are the current
+defaults; you can override most of them on the `roslaunch` command line.
+
+---
+
+## Quick-start launch template
+
+```bash
+roslaunch pyroscope_navigation coverage_mission_nav.launch \
+ area_width:=10.0 \
+ area_height:=10.0 \
+ row_spacing:=0.5 \
+ waypoint_spacing:=0.5 \
+ origin_x:=0.0 \
+ origin_y:=0.0 \
+ wall_margin:=0.45 \
+ dwell_time:=3.0 \
+ waypoint_timeout:=60.0 \
+ stall_timeout:=12.0 \
+ target_cost_threshold:=253
+```
+
+---
+
+## 1 · Coverage Planner parameters (`coverage_planner.py`)
+
+These are forwarded through `coverage_mission_nav.launch` arguments and set on
+the ROS parameter server under the `coverage_planner` node namespace.
+
+### 1.1 Mission geometry
+
+| Parameter | Default | Unit | What it does |
+|-----------|---------|------|--------------|
+| `area_width` | `10.0` | m | Total width (X-axis extent) of the survey rectangle. Increase for a larger field; decrease for a smaller plot. |
+| `area_height` | `10.0` | m | Total height (Y-axis extent) of the survey rectangle. |
+| `row_spacing` | `0.5` | m | Distance between adjacent sweep rows. Smaller → denser coverage but more waypoints and longer mission. Typical range: 0.3 – 1.0 m. |
+| `waypoint_spacing` | `0.5` | m | Distance between consecutive waypoints along a single row. Smaller → finer sampling within a row. |
+| `origin_x` | `0.0` | m | X-coordinate of the centre of the survey area in the `odom` frame. Shift to place the grid over a different part of the field. |
+| `origin_y` | `0.0` | m | Y-coordinate of the centre of the survey area. |
+| `wall_margin` | `0.45` | m | Safety buffer inset from the area boundary before the first/last waypoints are placed. Keeps the robot away from fences or plot edges. Must be ≥ robot half-width + `inflation_radius`. |
+
+### 1.2 Timing
+
+| Parameter | Default | Unit | What it does |
+|-----------|---------|------|--------------|
+| `dwell_time` | `3.0` | s | How long the robot pauses at each waypoint after arriving (e.g., for a sensor capture). Set to `0` to skip pausing. |
+| `waypoint_timeout` | `60.0` | s | Maximum time allowed to reach one waypoint. If exceeded the planner records a failure and tries the next target. Increase if the robot legitimately needs longer to navigate around obstacles. |
+| `stall_timeout` | `12.0` | s | If the robot makes no progress toward the current goal for this many seconds, the target is abandoned. Increase for slow terrain; decrease for faster failure detection. |
+| `progress_log_interval` | `2.0` | s | How often the planner logs its progress percentage. Cosmetic only. |
+
+### 1.3 Target safety / costmap filtering
+
+| Parameter | Default | Unit | What it does |
+|-----------|---------|------|--------------|
+| `target_cost_threshold` | `253` | 0–255 | Costmap cost above which a target cell is considered occupied/unsafe and will be skipped. `253` is just below `LETHAL_OBSTACLE` (254), so only confirmed lethal obstacles are rejected while high-inflation cells are still treated as reachable. Lower values (e.g. `128`) make the planner more conservative and skip heavily inflated cells too. |
+| `target_check_radius` | `0.05` | m | Radius around a candidate target within which the costmap is sampled. Larger → the robot must find a bigger clear patch before committing to a target. |
+| `max_target_failures` | `4` | count | Number of consecutive navigation failures on a single target before it is permanently skipped. Increase if obstacles are transient; decrease if you want the planner to move on quickly. |
+
+### 1.4 Path planning service
+
+| Parameter | Default | Unit | What it does |
+|-----------|---------|------|--------------|
+| `make_plan_tolerance` | `0.25` | m | Goal tolerance passed to the `move_base/make_plan` service when checking reachability. Larger → the global planner accepts plans that land slightly off the exact target, reducing failures in cluttered areas. |
+| `progress_epsilon` | `0.05` | m | Minimum improvement in distance-to-goal that counts as "progress". If the robot closes the gap by less than this in one `progress_log_interval` the stall timer keeps running. |
+
+### 1.5 No-target retry behaviour
+
+| Parameter | Default | Unit | What it does |
+|-----------|---------|------|--------------|
+| `no_target_retry_limit` | `8` | count | How many times the planner retries finding any reachable target before declaring the mission stuck. Increase if the costmap clears slowly. |
+| `no_target_retry_sleep` | `1.5` | s | Pause between retry attempts when no reachable target can be found. Gives the costmap time to update. |
+
+---
+
+## 2 · DWA local planner (`config/dwa_planner.yaml`)
+
+The DWA planner handles reactive obstacle avoidance and generates velocity
+commands every control cycle.
+
+### 2.1 Velocity limits
+
+| Parameter | Default | Unit | What it does |
+|-----------|---------|------|--------------|
+| `max_vel_x` | `0.25` | m/s | Maximum forward speed. Increasing this makes the robot faster but leaves less reaction time for obstacle avoidance. Start with ≤ 0.3 m/s outdoors. |
+| `min_vel_x` | `0.05` | m/s | Minimum forward speed when moving (prevents the robot stopping mid-trajectory). |
+| `max_vel_theta` | `0.8` | rad/s | Maximum angular (turning) speed. ~46 °/s. Reduce if the robot's odometry diverges during fast turns. |
+| `min_vel_theta` | `-0.8` | rad/s | Maximum reverse angular speed (must be negative to allow turning both ways). |
+| `min_vel_trans` | `0.04` | m/s | Absolute minimum translational speed when any motion is commanded. |
+| `max_vel_y` | `0.0` | m/s | Lateral velocity — keep at 0 for a differential-drive robot. |
+| `min_vel_y` | `0.0` | m/s | Lateral velocity lower bound — keep at 0. |
+
+### 2.2 Acceleration limits
+
+| Parameter | Default | Unit | What it does |
+|-----------|---------|------|--------------|
+| `acc_lim_x` | `0.8` | m/s² | Maximum linear acceleration. Lower values produce gentler starts/stops and reduce wheel slip. |
+| `acc_lim_theta` | `1.5` | rad/s² | Maximum angular acceleration. |
+| `acc_lim_y` | `0.0` | m/s² | Lateral acceleration — keep at 0. |
+
+### 2.3 Goal tolerances
+
+| Parameter | Default | Unit | What it does |
+|-----------|---------|------|--------------|
+| `xy_goal_tolerance` | `0.15` | m | How close the robot must get to the waypoint position to consider it reached. Tighten for precise coverage; loosen if the robot oscillates trying to reach an exact point. |
+| `yaw_goal_tolerance` | `0.30` | rad | Final heading tolerance (~17 °). Coverage missions typically do not require a precise final heading, so this can be relaxed. |
+| `latch_xy_goal_tolerance` | `true` | bool | When `true` the robot stops reversing once it is within `xy_goal_tolerance`, even if it drifts out again. Prevents the robot from backing up to meet its goal. |
+
+### 2.4 Forward simulation
+
+| Parameter | Default | Unit | What it does |
+|-----------|---------|------|--------------|
+| `sim_time` | `2.0` | s | How far ahead the planner simulates candidate trajectories. Longer → smoother paths but higher CPU cost. |
+| `sim_granularity` | `0.05` | s | Time step used inside each simulated trajectory. Smaller → finer collision checking but higher CPU cost. |
+| `vx_samples` | `10` | count | Number of forward-velocity candidates evaluated per cycle. More → better velocity choice but slower. |
+| `vtheta_samples` | `12` | count | Number of angular-velocity candidates evaluated. |
+| `vy_samples` | `1` | count | Lateral samples — 1 is correct for a non-holonomic robot. |
+
+### 2.5 Trajectory scoring weights
+
+These three weights are the most impactful DWA tuning knobs.
+
+| Parameter | Default | What it does |
+|-----------|---------|--------------|
+| `path_distance_bias` | `8.0` | Reward for staying close to the global plan. Higher → robot tracks the planned path tightly; lower → robot cuts corners. |
+| `goal_distance_bias` | `16.0` | Reward for moving toward the goal. Higher → robot charges toward the goal even if it deviates from the plan; lower → robot follows the plan more patiently. |
+| `occdist_scale` | `0.3` | Penalty for trajectories that pass near obstacles. Higher → robot gives obstacles a wider berth (may get stuck in narrow spaces); lower → robot cuts close to obstacles (may clip them). |
+
+### 2.6 Oscillation and collision prevention
+
+| Parameter | Default | Unit | What it does |
+|-----------|---------|------|--------------|
+| `oscillation_reset_dist` | `0.20` | m | How far the robot must travel to reset the oscillation detector. |
+| `forward_point_distance` | `0.10` | m | Distance of the forward look-ahead point used for collision cost. |
+| `stop_time_buffer` | `0.25` | s | Extra time buffer added to deceleration calculations to guarantee a complete stop before an obstacle. |
+
+---
+
+## 3 · Costmap parameters
+
+### 3.1 Shared obstacle & inflation layer (`config/costmap_common.yaml`)
+
+Loaded into both the global and local costmap namespaces.
+
+| Parameter | Default | Unit | What it does |
+|-----------|---------|------|--------------|
+| `footprint` | `[[-0.125,-0.10],[-0.125,0.10],[0.125,0.10],[0.125,-0.10]]` | m | Robot collision polygon (25 cm × 20 cm rectangle). Update if you swap the chassis. |
+| `obstacle_layer/laser_scan/obstacle_range` | `4.0` | m | Maximum range at which LIDAR readings are used to **mark** obstacles. |
+| `obstacle_layer/laser_scan/raytrace_range` | `5.0` | m | Maximum range used to **clear** (raytrace through) free space. Should be ≥ `obstacle_range`. |
+| `obstacle_layer/laser_scan/max_obstacle_height` | `0.5` | m | Points above this height are ignored. |
+| `obstacle_layer/laser_scan/min_obstacle_height` | `0.0` | m | Points below this height are ignored. |
+| `inflation_layer/inflation_radius` | `0.20` | m | Distance to inflate obstacles outward. Effectively adds this buffer around every obstacle. Must be ≥ robot half-width for safe navigation (robot half-width ≈ 0.125 m). |
+| `inflation_layer/cost_scaling_factor` | `2.0` | — | Exponential decay rate of inflation cost with distance. Higher → cost drops off faster (narrower inflation band). |
+| `resolution` | `0.05` | m/cell | Costmap grid resolution (5 cm). |
+
+### 3.2 Global costmap (`config/global_costmap.yaml`)
+
+Used by the global planner (NavFn) for full path planning.
+
+| Parameter | Default | Unit | What it does |
+|-----------|---------|------|--------------|
+| `global_costmap/update_frequency` | `4.0` | Hz | How often the global costmap is rebuilt from sensor data. Lower → less CPU; higher → faster obstacle updates for global planning. |
+| `global_costmap/publish_frequency` | `2.0` | Hz | How often the costmap is published for visualisation/consumers. |
+| `global_costmap/transform_tolerance` | `3.0` | s | Maximum age of TF data before the costmap reports a TF error. |
+| `global_costmap/width` | `15.0` | m | Rolling-window width centred on the robot. Larger → more context for global planning; more RAM and CPU. |
+| `global_costmap/height` | `15.0` | m | Rolling-window height. |
+
+### 3.3 Local costmap (`config/local_costmap.yaml`)
+
+Used by the DWA planner for reactive obstacle avoidance.
+
+| Parameter | Default | Unit | What it does |
+|-----------|---------|------|--------------|
+| `local_costmap/update_frequency` | `5.0` | Hz | Should be ≥ `controller_frequency` so the DWA planner always has fresh obstacle data. |
+| `local_costmap/publish_frequency` | `2.0` | Hz | Publication rate for visualisation. |
+| `local_costmap/transform_tolerance` | `3.0` | s | TF staleness tolerance. |
+| `local_costmap/width` | `4.0` | m | Rolling-window width for immediate surroundings. |
+| `local_costmap/height` | `4.0` | m | Rolling-window height. |
+
+---
+
+## 4 · move_base core parameters (inline in `coverage_mission_nav.launch`)
+
+| Parameter | Default | Unit | What it does |
+|-----------|---------|------|--------------|
+| `planner_frequency` | `1.0` | Hz | How often the global plan is recomputed. Higher → plan updates more quickly around new obstacles; higher CPU cost. |
+| `controller_frequency` | `5.0` | Hz | How often the DWA planner computes and sends a velocity command. Increase for faster obstacle response; must be ≤ `local_costmap/update_frequency`. |
+| `planner_patience` | `8.0` | s | How long move_base waits for the global planner before triggering a recovery behaviour. |
+| `controller_patience` | `8.0` | s | How long move_base waits for the local planner to make progress before triggering a recovery behaviour. |
+| `oscillation_timeout` | `30.0` | s | Time the robot can oscillate (detected by `oscillation_distance`) before a recovery is triggered. |
+| `oscillation_distance` | `0.15` | m | Robot must move this far to reset the oscillation timer. |
+| `NavfnROS/allow_unknown` | `true` | bool | Allows the global planner to plan through unknown (grey) costmap cells. **Must stay `true`** for mapless operation — setting it to `false` prevents planning in unexplored space. |
+| `NavfnROS/default_tolerance` | `0.5` | m | Goal tolerance for the global planner. Increase if NavFn repeatedly fails to find a plan to exact goal positions. |
+| `recovery_behavior_enabled` | `false` | bool | Disabling recovery keeps behaviour deterministic in open outdoor spaces. Enable (`true`) if the robot frequently gets hard-stuck. |
+| `conservative_reset_dist` | `0.50` | m | Radius around the robot that is cleared during a conservative reset recovery. |
+
+---
+
+## 5 · Practical tuning scenarios
+
+### Robot navigates but misses many coverage points
+
+- Decrease `row_spacing` and `waypoint_spacing` for denser waypoints.
+- Decrease `target_cost_threshold` (e.g. to `200`) so the planner rejects unsafe
+ targets earlier, avoiding wasted navigation attempts.
+
+### Robot is too slow
+
+- Increase `max_vel_x` in `dwa_planner.yaml` (try `0.35`).
+- Increase `acc_lim_x` to reach top speed sooner (try `1.2`).
+- Lower `sim_time` slightly (e.g. to `1.5`) to reduce planning latency.
+
+### Robot frequently reports "stalled" or "timeout"
+
+- Increase `stall_timeout` to `20.0` and `waypoint_timeout` to `90.0`.
+- Increase `controller_patience` and `planner_patience` in the launch file.
+- Check that `max_vel_x` is not too low (minimum ~0.1 m/s for outdoor terrain).
+
+### Robot clips or collides with obstacles
+
+- Increase `inflation_radius` in `costmap_common.yaml` (try `0.30` m).
+- Increase `occdist_scale` in `dwa_planner.yaml` (try `0.6`).
+- Decrease `target_cost_threshold` so obstacle-adjacent targets are skipped.
+
+### Robot oscillates or overshoots waypoints
+
+- Increase `xy_goal_tolerance` in `dwa_planner.yaml` (try `0.25` m).
+- Reduce `goal_distance_bias` (try `10.0`) so the planner brakes earlier.
+- Reduce `max_vel_x` and `acc_lim_x` for gentler motion.
+
+### "No reachable target found" messages dominate the log
+
+- Increase `no_target_retry_limit` to `15` and `no_target_retry_sleep` to `3.0`.
+- Increase `global_costmap/update_frequency` to `6.0` Hz so the costmap clears
+ faster after the robot moves.
+- Lower `target_cost_threshold` to allow targets near (but not inside) inflated
+ obstacle zones.
+
+### Robot gets permanently stuck
+
+- Set `recovery_behavior_enabled` to `true` in the launch file.
+- Enable `clearing_rotation_allowed` to let move_base spin in place to clear
+ obstacle cells from the local costmap.
+
+---
+
+## 6 · Should parameter tuning alone be sufficient?
+
+**Yes, for most issues in an obstacle-free or lightly cluttered environment.**
+The navigation stack is fully functional: the costmap-aware target selector,
+DWA planner, and move_base recovery pipeline provide a solid foundation that
+responds well to parameter changes.
+
+**However, some failure modes require code changes, not just tuning:**
+
+| Symptom | Likely root cause | Fix |
+|---------|-------------------|-----|
+| Robot spins at the first waypoint indefinitely | Odometry not publishing or wrong frame name | Check `/odom` topic and TF tree |
+| Global planner always fails (`allow_unknown false` warning) | `NavfnROS/allow_unknown` was accidentally set to `false` | Set back to `true` |
+| Coverage grid is not centred on the physical field | `origin_x` / `origin_y` set to the wrong values | Measure field origin in `odom` coordinates |
+| Robot navigates into inflated obstacle zones | `target_cost_threshold` too permissive (253 accepts all non-lethal costs including high-inflation cells) | Lower to ≤ 200 for conservative operation |
+| Very large areas (>15 m) with no plan found | Global costmap window too small | Increase `global_costmap/width` and `height` |
+| Mission completes but field not fully covered | `row_spacing` too large relative to sensor FOV | Halve `row_spacing` |
+
+In summary: **start with the parameters in Section 5 above**, iterate one change
+at a time, and watch the `/coverage/progress` and `/move_base/status` topics to
+understand what the planner is doing.
diff --git a/catkin_ws/src/pyroscope_navigation/config/costmap_common.yaml b/catkin_ws/src/pyroscope_navigation/config/costmap_common.yaml
index 9b62936..acdc746 100644
--- a/catkin_ws/src/pyroscope_navigation/config/costmap_common.yaml
+++ b/catkin_ws/src/pyroscope_navigation/config/costmap_common.yaml
@@ -1,27 +1,30 @@
# Shared costmap parameters for global and local costmaps
# Tuned for outdoor field operation (larger ranges than indoor transbot_nav)
+# See COVERAGE_TUNING.md for full tuning guidance.
-# Robot footprint (Transbot is ~25cm x 20cm)
+# Robot footprint (Transbot is ~25cm x 20cm).
+# Update this polygon if the chassis changes.
footprint: [[-0.125, -0.10], [-0.125, 0.10], [0.125, 0.10], [0.125, -0.10]]
-# Obstacle layer params (must be under obstacle_layer/ so the plugin finds them)
+# Obstacle layer — marks and clears obstacles from LIDAR returns
obstacle_layer:
observation_sources: laser_scan
laser_scan:
sensor_frame: laser
data_type: LaserScan
topic: /scan
- marking: true
- clearing: true
- max_obstacle_height: 0.5
+ marking: true # mark occupied cells when a return is seen
+ clearing: true # raytrace through free space to clear stale marks
+ max_obstacle_height: 0.5 # ignore returns above 0.5 m (tall posts, etc.)
min_obstacle_height: 0.0
- obstacle_range: 4.0
- raytrace_range: 5.0
+ obstacle_range: 4.0 # mark obstacles up to 4 m away
+ raytrace_range: 5.0 # clear free space up to 5 m (must be ≥ obstacle_range)
-# Inflation layer params
+# Inflation layer — expands each obstacle outward so the robot's centre stays
+# safely away. inflation_radius must be ≥ robot half-width (~0.125 m).
inflation_layer:
- inflation_radius: 0.12
- cost_scaling_factor: 5.0
+ inflation_radius: 0.20 # metres to inflate every obstacle
+ cost_scaling_factor: 2.0 # exponential decay rate; higher = narrower band
-# Map resolution
+# Grid resolution shared between global and local costmaps (5 cm per cell)
resolution: 0.05
diff --git a/catkin_ws/src/pyroscope_navigation/config/dwa_planner.yaml b/catkin_ws/src/pyroscope_navigation/config/dwa_planner.yaml
index 041d94b..9ced536 100644
--- a/catkin_ws/src/pyroscope_navigation/config/dwa_planner.yaml
+++ b/catkin_ws/src/pyroscope_navigation/config/dwa_planner.yaml
@@ -1,38 +1,73 @@
# DWA local planner — tuned for outdoor field operation
# Slower speeds, larger tolerances, longer simulation horizon
+# See COVERAGE_TUNING.md for a full description of every parameter.
DWAPlannerROS:
- # Robot velocity limits (conservative for uneven terrain)
- max_vel_x: 0.45
- min_vel_x: -0.10
- max_vel_y: 0.0
+
+ # ── Velocity limits ──────────────────────────────────────────────────────────
+ # max_vel_x: fastest the robot can move forward (m/s). Increase carefully;
+ # higher speed means less reaction time for obstacle avoidance.
+ max_vel_x: 0.25
+ # min_vel_x: slowest forward speed when moving. Prevents the robot stopping
+ # mid-trajectory in the absence of an explicit stop command.
+ min_vel_x: 0.05
+ max_vel_y: 0.0 # lateral — keep 0 for differential-drive
min_vel_y: 0.0
- max_vel_theta: 1.2
- min_vel_theta: -1.2
- min_vel_trans: 0.08
- acc_lim_x: 1.5
- acc_lim_y: 0.0
- acc_lim_theta: 3.0
-
- # Goal tolerances (relaxed for outdoor GPS/odom drift)
- yaw_goal_tolerance: 0.4
- xy_goal_tolerance: 0.40
+ # max_vel_theta: max angular (turn) speed in rad/s (~46°/s). Reduce if
+ # odometry drifts badly during fast turns.
+ max_vel_theta: 0.8
+ min_vel_theta: -0.8 # must be negative to allow turning both ways
+ min_vel_trans: 0.04 # absolute minimum translational speed when moving
+
+ # ── Acceleration limits ───────────────────────────────────────────────────────
+ # acc_lim_x: max linear acceleration (m/s²). Lower = gentler starts/stops,
+ # less wheel slip on loose terrain.
+ acc_lim_x: 0.8
+ acc_lim_y: 0.0 # lateral — keep 0
+ # acc_lim_theta: max angular acceleration (rad/s²).
+ acc_lim_theta: 1.5
+
+ # ── Goal tolerances ───────────────────────────────────────────────────────────
+ # yaw_goal_tolerance: final heading tolerance (rad, ~17°). Coverage missions
+ # rarely require precise final heading — can be relaxed.
+ yaw_goal_tolerance: 0.30
+ # xy_goal_tolerance: position tolerance (m). Tighten for precise sampling
+ # points; loosen if the robot oscillates around the goal.
+ xy_goal_tolerance: 0.15
+ # latch_xy_goal_tolerance: when true, once inside tolerance the robot stops
+ # even if it drifts slightly out — prevents backing up.
latch_xy_goal_tolerance: true
- # Forward simulation (longer horizon for outdoor planning)
+ # ── Forward simulation ────────────────────────────────────────────────────────
+ # sim_time: how far ahead (seconds) candidate trajectories are simulated.
+ # Longer = smoother paths; higher CPU cost.
sim_time: 2.0
+ # sim_granularity: time step inside each simulated trajectory (s). Smaller =
+ # finer collision checking; higher CPU cost.
sim_granularity: 0.05
- vx_samples: 12
- vy_samples: 1
- vtheta_samples: 20
+ vx_samples: 10 # number of forward-velocity candidates evaluated
+ vy_samples: 1 # 1 = correct for non-holonomic robot
+ vtheta_samples: 12 # number of angular-velocity candidates evaluated
- # Trajectory scoring -- balance reaching goals vs avoiding walls
- path_distance_bias: 20.0
- goal_distance_bias: 28.0
+ # ── Trajectory scoring weights ────────────────────────────────────────────────
+ # These three are the most impactful DWA tuning knobs.
+ # path_distance_bias: reward for staying near the global plan. Higher = robot
+ # tracks the planned path more tightly.
+ path_distance_bias: 8.0
+ # goal_distance_bias: reward for moving toward the goal. Higher = robot charges
+ # toward goal even if it deviates from the plan.
+ goal_distance_bias: 16.0
+ # occdist_scale: penalty for passing near obstacles. Higher = wider berth around
+ # obstacles (may get stuck in narrow gaps); lower = cuts closer.
occdist_scale: 0.3
- # Oscillation prevention
- oscillation_reset_dist: 0.10
+ # ── Oscillation prevention ────────────────────────────────────────────────────
+ # oscillation_reset_dist: robot must travel this far (m) to reset oscillation
+ # detection counter.
+ oscillation_reset_dist: 0.20
- # Stop before hitting things
- forward_point_distance: 0.15
+ # ── Collision prevention ──────────────────────────────────────────────────────
+ forward_point_distance: 0.10 # look-ahead distance for collision cost (m)
+ # stop_time_buffer: extra time buffer added to deceleration calculations so
+ # the robot is guaranteed to stop before hitting an obstacle.
+ stop_time_buffer: 0.25
diff --git a/catkin_ws/src/pyroscope_navigation/config/global_costmap.yaml b/catkin_ws/src/pyroscope_navigation/config/global_costmap.yaml
index 9be8d99..3019c46 100644
--- a/catkin_ws/src/pyroscope_navigation/config/global_costmap.yaml
+++ b/catkin_ws/src/pyroscope_navigation/config/global_costmap.yaml
@@ -1,17 +1,24 @@
# Global costmap — rolling window, no static map
# Operates in odom frame for mapless outdoor navigation
+# See COVERAGE_TUNING.md for full tuning guidance.
global_costmap:
- global_frame: odom
+ global_frame: odom # odometry frame — no SLAM map required
robot_base_frame: base_link
- update_frequency: 2.0
- publish_frequency: 1.0
+ # update_frequency: how often the costmap is rebuilt from sensor data (Hz).
+ # Increase for faster obstacle updates; trades off against CPU load.
+ update_frequency: 4.0
+ publish_frequency: 2.0 # publication rate for rviz / consumers
+ # transform_tolerance: max TF data age before the costmap raises a TF error.
transform_tolerance: 3.0
- static_map: false
- rolling_window: true
+ static_map: false # no pre-built map — must stay false for mapless operation
+ rolling_window: true # window follows the robot as it moves
+ # width / height: rolling-window size in metres. Must be large enough for the
+ # global planner to see the next waypoint from the current position.
+ # Increase if global planning regularly fails on longer waypoint gaps.
width: 15.0
height: 15.0
- resolution: 0.05
+ resolution: 0.05 # 5 cm grid cells — matches costmap_common.yaml
plugins:
- {name: obstacle_layer, type: "costmap_2d::ObstacleLayer"}
diff --git a/catkin_ws/src/pyroscope_navigation/config/local_costmap.yaml b/catkin_ws/src/pyroscope_navigation/config/local_costmap.yaml
index 45e9b3a..b53a1e0 100644
--- a/catkin_ws/src/pyroscope_navigation/config/local_costmap.yaml
+++ b/catkin_ws/src/pyroscope_navigation/config/local_costmap.yaml
@@ -1,13 +1,19 @@
# Local costmap — rolling window for reactive obstacle avoidance
+# Smaller and faster than the global costmap; used only by the DWA planner.
+# See COVERAGE_TUNING.md for full tuning guidance.
local_costmap:
global_frame: odom
robot_base_frame: base_link
+ # update_frequency: should be ≥ controller_frequency so the DWA planner
+ # always has fresh obstacle data (controller_frequency defaults to 5 Hz).
update_frequency: 5.0
publish_frequency: 2.0
transform_tolerance: 3.0
static_map: false
rolling_window: true
+ # width / height: keep small (4 m) for fast update cycles; just large enough
+ # to see obstacles within the DWA sim_time horizon (2 s × 0.25 m/s ≈ 0.5 m).
width: 4.0
height: 4.0
resolution: 0.05
diff --git a/catkin_ws/src/pyroscope_navigation/launch/coverage_mission_nav.launch b/catkin_ws/src/pyroscope_navigation/launch/coverage_mission_nav.launch
index 40ded62..343ec7d 100644
--- a/catkin_ws/src/pyroscope_navigation/launch/coverage_mission_nav.launch
+++ b/catkin_ws/src/pyroscope_navigation/launch/coverage_mission_nav.launch
@@ -1,57 +1,137 @@
-
-
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
-
-
-
+
+
+
+
-
-
+
+
-
-
+
+
+
-
-
-
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
-
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/catkin_ws/src/pyroscope_navigation/scripts/coverage_planner.py b/catkin_ws/src/pyroscope_navigation/scripts/coverage_planner.py
index 800f3f1..31a20a3 100755
--- a/catkin_ws/src/pyroscope_navigation/scripts/coverage_planner.py
+++ b/catkin_ws/src/pyroscope_navigation/scripts/coverage_planner.py
@@ -1,169 +1,145 @@
#!/usr/bin/env python
"""
-Coverage Path Planner for Pyroscope
-Generates a boustrophedon (lawnmower) pattern over a rectangular area
-and sends waypoints to move_base for execution with obstacle avoidance.
-Pauses at each waypoint for thermal camera capture.
+Costmap-aware coverage planner for Pyroscope.
+
+This node does not precommit to a blind lawnmower list. It samples the live
+global costmap inside the requested square, keeps only safe free-space targets,
+and then repeatedly chooses the next uncovered target that has the shortest
+reachable plan from the robot's current pose.
"""
-import rospy
import math
+
import actionlib
+import rospy
import tf
+from geometry_msgs.msg import PoseStamped
from move_base_msgs.msg import MoveBaseAction, MoveBaseGoal
-from geometry_msgs.msg import Twist
from nav_msgs.msg import OccupancyGrid
-from sensor_msgs.msg import LaserScan
-from std_msgs.msg import Bool, String
+from nav_msgs.srv import GetPlan
+from std_msgs.msg import Bool, Int32, String
+
+
+WALL_MARGIN = 0.30 # meters
-# Margin inset from area boundary so waypoints never land on walls.
-# Must be >= robot half-width (0.125m) + inflation_radius (0.15m).
-WALL_MARGIN = 0.20 # meters
+class CoverageTarget(object):
+ def __init__(self, target_id, x, y, row, col):
+ self.target_id = target_id
+ self.x = x
+ self.y = y
+ self.row = row
+ self.col = col
+ self.covered = False
+ self.skipped = False
+ self.failures = 0
+ self.currently_safe = True
-class CoveragePlanner:
+class CoveragePlanner(object):
def __init__(self):
rospy.init_node('coverage_planner', anonymous=False)
+ self.ready = False
- # Area parameters
+ # Mission geometry
self.area_width = rospy.get_param('~area_width', 10.0)
self.area_height = rospy.get_param('~area_height', 10.0)
self.row_spacing = rospy.get_param('~row_spacing', 1.0)
self.waypoint_spacing = rospy.get_param('~waypoint_spacing', 1.0)
self.origin_x = rospy.get_param('~origin_x', 0.0)
self.origin_y = rospy.get_param('~origin_y', 0.0)
+ self.wall_margin = rospy.get_param('~wall_margin', WALL_MARGIN)
- # Timing parameters
+ # Timing and planning
self.dwell_time = rospy.get_param('~dwell_time', 3.0)
self.waypoint_timeout = rospy.get_param('~waypoint_timeout', 60.0)
- self.max_waypoint_failures = int(rospy.get_param('~max_waypoint_failures', 3))
- self.pose_stale_timeout = rospy.get_param('~pose_stale_timeout', 0.75)
- self.scan_stale_timeout = rospy.get_param('~scan_stale_timeout', 1.0)
- self.costmap_stale_timeout = rospy.get_param('~costmap_stale_timeout', 2.0)
-
- # Recovery parameters
- self.recovery_turn_speed = rospy.get_param('~recovery_turn_speed', 0.7)
- self.recovery_turn_angle = rospy.get_param('~recovery_turn_angle', 1.0)
- self.recovery_backup_speed = rospy.get_param('~recovery_backup_speed', -0.12)
- self.recovery_backup_distance = rospy.get_param('~recovery_backup_distance', 0.35)
- self.recovery_clearance = rospy.get_param('~recovery_clearance', 0.30)
- self.waypoint_validation_radius = rospy.get_param('~waypoint_validation_radius', 0.18)
- self.waypoint_cost_threshold = int(rospy.get_param('~waypoint_cost_threshold', 80))
- self.scan_blocked_margin = rospy.get_param('~scan_blocked_margin', 0.20)
- self.scan_blocked_sector = rospy.get_param('~scan_blocked_sector', 0.20)
+ self.pose_stale_timeout = rospy.get_param('~pose_stale_timeout', 2.0)
+ self.costmap_stale_timeout = rospy.get_param('~costmap_stale_timeout', 5.0)
+ self.make_plan_tolerance = rospy.get_param('~make_plan_tolerance', 0.25)
+ self.progress_log_interval = rospy.get_param('~progress_log_interval', 2.0)
+ self.progress_epsilon = rospy.get_param('~progress_epsilon', 0.05)
+ self.stall_timeout = rospy.get_param('~stall_timeout', 30.0)
+ self.no_target_retry_limit = int(rospy.get_param('~no_target_retry_limit', 15))
+ self.no_target_retry_sleep = rospy.get_param('~no_target_retry_sleep', 3.0)
+
+ # Coverage target safety
+ self.target_cost_threshold = int(rospy.get_param('~target_cost_threshold', 85))
+ self.target_check_radius = rospy.get_param('~target_check_radius', 0.10)
+ self.max_target_failures = int(rospy.get_param('~max_target_failures', 8))
+ self.failure_penalty = rospy.get_param('~failure_penalty', 0.75)
+ self.row_change_penalty = rospy.get_param('~row_change_penalty', 0.10)
# State
- self.waypoints = []
- self.current_index = 0
- self.latest_scan = None
- self.latest_scan_stamp = None
+ self.targets = []
+ self.target_lookup = {}
+ self.next_target_id = 0
+ self.sweep_target_ids = []
+ self.sweep_cursor = 0
+ self.sequence_initialized = False
self.latest_costmap = None
self.latest_costmap_stamp = None
+ self.last_selected_row = None
# Publishers
- self.cmd_vel_pub = rospy.Publisher('/cmd_vel', Twist, queue_size=1)
self.capture_ready_pub = rospy.Publisher('/coverage/capture_ready', Bool, queue_size=1)
- self.progress_pub = rospy.Publisher('/coverage/progress', String, queue_size=1)
- self.complete_pub = rospy.Publisher('/coverage/complete', Bool, queue_size=1)
- self.scan_sub = rospy.Subscriber('/scan', LaserScan, self.scan_callback, queue_size=1)
- self.costmap_sub = rospy.Subscriber('/move_base/global_costmap/costmap', OccupancyGrid, self.costmap_callback, queue_size=1)
- self.tf_listener = tf.TransformListener()
+ self.progress_pub = rospy.Publisher('/coverage/progress', String, queue_size=1, latch=True)
+ self.total_points_pub = rospy.Publisher('/coverage/total_points', Int32, queue_size=1, latch=True)
+ self.complete_pub = rospy.Publisher('/coverage/complete', Bool, queue_size=1, latch=True)
+
+ # Subscribers
+ self.costmap_sub = rospy.Subscriber(
+ '/move_base/global_costmap/costmap',
+ OccupancyGrid,
+ self.costmap_callback,
+ queue_size=1,
+ )
- # move_base action client
+ # move_base interfaces
+ self.tf_listener = tf.TransformListener()
self.move_base_client = actionlib.SimpleActionClient('move_base', MoveBaseAction)
+ self.make_plan_srv = None
+ self.clear_costmaps_srv = None
+
+ self.complete_pub.publish(Bool(data=False))
+ self.total_points_pub.publish(Int32(data=0))
+ self.progress_pub.publish(String(data='Waiting for costmap...'))
+
rospy.loginfo("Waiting for move_base action server...")
- if not self.move_base_client.wait_for_server(rospy.Duration(30.0)):
- rospy.logfatal("move_base action server not available -- aborting")
+ if not self.move_base_client.wait_for_server(rospy.Duration(60.0)):
+ rospy.logfatal("move_base action server not available -- inspect move_base startup logs")
rospy.signal_shutdown("move_base unavailable")
return
- rospy.loginfo("Connected to move_base action server")
+ try:
+ rospy.wait_for_service('/move_base/make_plan', timeout=5.0)
+ self.make_plan_srv = rospy.ServiceProxy('/move_base/make_plan', GetPlan)
+ rospy.loginfo("Connected to /move_base/make_plan")
+ except rospy.ROSException:
+ rospy.logwarn("/move_base/make_plan not available; planner will fall back to Euclidean ordering")
- # Generate waypoints
- self.generate_waypoints()
- self.validate_waypoint_distances()
+ try:
+ from std_srvs.srv import Empty
+ rospy.wait_for_service('/move_base/clear_costmaps', timeout=2.0)
+ self.clear_costmaps_srv = rospy.ServiceProxy('/move_base/clear_costmaps', Empty)
+ except Exception:
+ self.clear_costmaps_srv = None
- rospy.loginfo("Coverage Planner initialized")
- rospy.loginfo(" Area: %.1f x %.1f m (margin: %.2fm from walls)",
- self.area_width, self.area_height, WALL_MARGIN)
- rospy.loginfo(" Row spacing: %.1f m, Waypoint spacing: %.1f m",
+ rospy.loginfo("Coverage planner configured")
+ rospy.loginfo(" Area: %.2f x %.2f m centered at (%.2f, %.2f)",
+ self.area_width, self.area_height, self.origin_x, self.origin_y)
+ rospy.loginfo(" Sampling: row_spacing=%.2f m waypoint_spacing=%.2f m",
self.row_spacing, self.waypoint_spacing)
- rospy.loginfo(" Origin: (%.1f, %.1f)", self.origin_x, self.origin_y)
- rospy.loginfo(" Total waypoints: %d", len(self.waypoints))
- rospy.loginfo(" Dwell time: %.1f s", self.dwell_time)
- rospy.loginfo(" Max retries per waypoint: %d", self.max_waypoint_failures)
-
- def generate_waypoints(self):
- """Generate boustrophedon (lawnmower) waypoints with wall margin inset."""
- self.waypoints = []
-
- # Center the grid on the origin, then inset by wall margin
- min_x = self.origin_x - self.area_width / 2.0 + WALL_MARGIN
- max_x = self.origin_x + self.area_width / 2.0 - WALL_MARGIN
- min_y = self.origin_y - self.area_height / 2.0 + WALL_MARGIN
- max_y = self.origin_y + self.area_height / 2.0 - WALL_MARGIN
-
- # If the area is too small for any margin, just use the center
- if max_x <= min_x or max_y <= min_y:
- cx = self.origin_x + self.area_width / 2.0
- cy = self.origin_y + self.area_height / 2.0
- self.waypoints.append((cx, cy))
- rospy.logwarn("Area too small for margin -- using center point only (%.2f, %.2f)", cx, cy)
- return
-
- effective_width = max_x - min_x
- effective_height = max_y - min_y
-
- num_rows = max(1, int(math.ceil(effective_height / self.row_spacing)) + 1)
- num_cols = max(1, int(math.ceil(effective_width / self.waypoint_spacing)) + 1)
-
- for row in range(num_rows):
- y = min_y + row * self.row_spacing
- if y > max_y:
- y = max_y
-
- # Alternate direction for lawnmower pattern
- if row % 2 == 0:
- col_range = range(num_cols)
- else:
- col_range = range(num_cols - 1, -1, -1)
-
- for col in col_range:
- x = min_x + col * self.waypoint_spacing
- if x > max_x:
- x = max_x
-
- self.waypoints.append((x, y))
-
- rospy.loginfo("Generated %d waypoints in %d rows (inset %.2fm from walls)",
- len(self.waypoints), num_rows, WALL_MARGIN)
-
- def validate_waypoint_distances(self):
- """Warn if consecutive waypoints exceed global costmap range"""
- costmap_half = 7.5 # half of 15m rolling window
- for i in range(1, len(self.waypoints)):
- x0, y0 = self.waypoints[i - 1]
- x1, y1 = self.waypoints[i]
- dist = math.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2)
- if dist > costmap_half:
- rospy.logwarn(
- "Waypoints %d->%d are %.1fm apart (exceeds %.1fm costmap radius) "
- "-- move_base may fail to plan",
- i, i + 1, dist, costmap_half
- )
-
- def scan_callback(self, msg):
- self.latest_scan = msg
- self.latest_scan_stamp = msg.header.stamp if msg.header.stamp != rospy.Time() else rospy.Time.now()
+ rospy.loginfo(" Safety: wall_margin=%.2f m target_cost_threshold=%d target_check_radius=%.2f m",
+ self.wall_margin, self.target_cost_threshold, self.target_check_radius)
+ self.ready = True
def costmap_callback(self, msg):
self.latest_costmap = msg
- self.latest_costmap_stamp = msg.header.stamp if msg.header.stamp != rospy.Time() else rospy.Time.now()
-
- def stop_robot(self):
- self.cmd_vel_pub.publish(Twist())
+ if msg.header.stamp != rospy.Time():
+ self.latest_costmap_stamp = msg.header.stamp
+ else:
+ self.latest_costmap_stamp = rospy.Time.now()
def get_robot_pose(self):
try:
@@ -185,60 +161,54 @@ def wait_for_fresh_pose(self, timeout):
rate.sleep()
return False
- def get_scan_age(self):
- if self.latest_scan_stamp is None:
- return None
- return abs((rospy.Time.now() - self.latest_scan_stamp).to_sec())
-
def get_costmap_age(self):
if self.latest_costmap_stamp is None:
return None
return abs((rospy.Time.now() - self.latest_costmap_stamp).to_sec())
- def normalize_angle(self, angle):
- return math.atan2(math.sin(angle), math.cos(angle))
-
- def angle_in_sector(self, angle, start, end):
- angle = self.normalize_angle(angle)
- start = self.normalize_angle(start)
- end = self.normalize_angle(end)
- if start <= end:
- return start <= angle <= end
- return angle >= start or angle <= end
-
- def sector_clearance(self, start_angle, end_angle):
- if self.latest_scan is None:
- return None
+ def costmap_is_fresh(self):
+ costmap_age = self.get_costmap_age()
+ return costmap_age is not None and costmap_age <= self.costmap_stale_timeout
- values = []
- angle = self.latest_scan.angle_min
- for distance in self.latest_scan.ranges:
- if self.angle_in_sector(angle, start_angle, end_angle):
- if math.isinf(distance):
- values.append(self.latest_scan.range_max)
- elif not math.isnan(distance):
- values.append(distance)
- angle += self.latest_scan.angle_increment
-
- return min(values) if values else None
-
- def choose_turn_direction(self):
- left_clearance = self.sector_clearance(math.radians(20), math.radians(110))
- right_clearance = self.sector_clearance(math.radians(-110), math.radians(-20))
-
- if left_clearance is None and right_clearance is None:
- return 1.0
- if right_clearance is None:
- return 1.0
- if left_clearance is None:
- return -1.0
- return 1.0 if left_clearance >= right_clearance else -1.0
+ def wait_for_costmap(self, timeout):
+ deadline = rospy.Time.now() + rospy.Duration(timeout)
+ rate = rospy.Rate(10)
+ while rospy.Time.now() < deadline and not rospy.is_shutdown():
+ if self.latest_costmap is not None and self.costmap_is_fresh():
+ return True
+ rate.sleep()
+ return False
- def costmap_cell(self, mx, my):
- info = self.latest_costmap.info
- if mx < 0 or my < 0 or mx >= info.width or my >= info.height:
- return None
- return self.latest_costmap.data[my * info.width + mx]
+ def build_pose(self, x, y, yaw):
+ pose = PoseStamped()
+ pose.header.frame_id = 'odom'
+ pose.header.stamp = rospy.Time.now()
+ pose.pose.position.x = x
+ pose.pose.position.y = y
+ pose.pose.position.z = 0.0
+ quat = tf.transformations.quaternion_from_euler(0.0, 0.0, yaw)
+ pose.pose.orientation.x = quat[0]
+ pose.pose.orientation.y = quat[1]
+ pose.pose.orientation.z = quat[2]
+ pose.pose.orientation.w = quat[3]
+ return pose
+
+ def axis_points(self, min_value, max_value, spacing):
+ points = []
+ value = min_value
+ while value <= max_value + 1e-6:
+ points.append(min(value, max_value))
+ value += spacing
+ if not points:
+ points = [min_value]
+ elif abs(points[-1] - max_value) > 1e-6:
+ points.append(max_value)
+
+ deduped = []
+ for point in points:
+ if not deduped or abs(point - deduped[-1]) > 1e-6:
+ deduped.append(point)
+ return deduped
def world_to_costmap(self, x, y):
if self.latest_costmap is None:
@@ -247,267 +217,533 @@ def world_to_costmap(self, x, y):
info = self.latest_costmap.info
mx = int(math.floor((x - info.origin.position.x) / info.resolution))
my = int(math.floor((y - info.origin.position.y) / info.resolution))
+
if mx < 0 or my < 0 or mx >= info.width or my >= info.height:
return None
- return mx, my
+ return (mx, my)
- def waypoint_cost_invalid(self, x, y):
- costmap_age = self.get_costmap_age()
- if self.latest_costmap is None or costmap_age is None or costmap_age > self.costmap_stale_timeout:
+ def costmap_cell(self, mx, my):
+ info = self.latest_costmap.info
+ if mx < 0 or my < 0 or mx >= info.width or my >= info.height:
+ return None
+ return self.latest_costmap.data[my * info.width + mx]
+
+ def is_target_safe(self, x, y):
+ if self.latest_costmap is None:
return False
center = self.world_to_costmap(x, y)
if center is None:
return False
+ info = self.latest_costmap.info
+ radius_cells = int(math.ceil(self.target_check_radius / info.resolution))
mx, my = center
- radius_cells = int(math.ceil(self.waypoint_validation_radius / self.latest_costmap.info.resolution))
+ has_known_cells = False
for cx in range(mx - radius_cells, mx + radius_cells + 1):
for cy in range(my - radius_cells, my + radius_cells + 1):
cost = self.costmap_cell(cx, cy)
if cost is None:
+ return False
+ if cost < 0:
continue
- if cost < 0 or cost >= self.waypoint_cost_threshold:
- rospy.logwarn(
- "Skipping waypoint (%.2f, %.2f): costmap cell %d near target exceeds threshold %d",
- x, y, cost, self.waypoint_cost_threshold
- )
- return True
+ has_known_cells = True
+ if cost >= self.target_cost_threshold:
+ return False
+ return has_known_cells
- return False
+ def refresh_targets_from_costmap(self):
+ previous_known = len(self.targets)
+ previous_total = self.count_total_targets()
+ previous_pending = self.count_pending_targets()
+
+ min_x = self.origin_x - self.area_width / 2.0 + self.wall_margin
+ max_x = self.origin_x + self.area_width / 2.0 - self.wall_margin
+ min_y = self.origin_y - self.area_height / 2.0 + self.wall_margin
+ max_y = self.origin_y + self.area_height / 2.0 - self.wall_margin
+
+ if max_x <= min_x or max_y <= min_y:
+ rospy.logwarn_throttle(5.0, "Area too small after wall margin; using center point fallback")
+ key = (0, 0)
+ target = self.target_lookup.get(key)
+ if target is None and self.is_target_safe(self.origin_x, self.origin_y):
+ target = CoverageTarget(self.next_target_id, self.origin_x, self.origin_y, 0, 0)
+ self.next_target_id += 1
+ self.target_lookup[key] = target
+ if target is not None:
+ target.currently_safe = self.is_target_safe(self.origin_x, self.origin_y)
+ target.x = self.origin_x
+ target.y = self.origin_y
+ self.targets = sorted(self.target_lookup.values(), key=lambda existing: (existing.row, existing.col))
+ return
+
+ x_points = self.axis_points(min_x, max_x, self.waypoint_spacing)
+ y_points = self.axis_points(min_y, max_y, self.row_spacing)
+
+ for row_index, y in enumerate(y_points):
+ for col_index, x in enumerate(x_points):
+ key = (row_index, col_index)
+ currently_safe = self.is_target_safe(x, y)
+ target = self.target_lookup.get(key)
+
+ if target is None:
+ target = CoverageTarget(self.next_target_id, x, y, row_index, col_index)
+ self.next_target_id += 1
+ self.target_lookup[key] = target
+
+ target.x = x
+ target.y = y
+ target.currently_safe = currently_safe
+
+ self.targets = sorted(self.target_lookup.values(), key=lambda target: (target.row, target.col))
+ if len(self.targets) != previous_known:
+ self.sequence_initialized = False
+
+ current_total = self.count_total_targets()
+ current_pending = self.count_pending_targets()
+ if current_total != previous_total or current_pending != previous_pending:
+ rospy.loginfo(
+ "Coverage target set refreshed: total=%d pending=%d covered=%d skipped=%d",
+ current_total,
+ current_pending,
+ self.count_covered_targets(),
+ self.count_skipped_targets(),
+ )
+
+ def count_known_targets(self):
+ return len(self.targets)
+
+ def count_total_targets(self):
+ return len([target for target in self.targets if not target.skipped])
+
+ def count_covered_targets(self):
+ return len([target for target in self.targets if target.covered])
+
+ def count_pending_targets(self):
+ return len([target for target in self.targets if not target.covered and not target.skipped])
+
+ def count_active_targets(self):
+ return len([
+ target for target in self.targets
+ if not target.covered and not target.skipped and target.currently_safe
+ ])
+
+ def count_skipped_targets(self):
+ return len([target for target in self.targets if target.skipped])
+
+ def build_sweep_target_ids(self):
+ sequence = []
+ rows = sorted({target.row for target in self.targets})
+ for row in rows:
+ row_targets = [target for target in self.targets if target.row == row]
+ row_targets.sort(key=lambda target: target.col, reverse=bool(row % 2))
+ sequence.extend([target.target_id for target in row_targets])
+ self.sweep_target_ids = sequence
+
+ def initialize_sweep_sequence(self):
+ self.build_sweep_target_ids()
+ self.sequence_initialized = True
+ self.sweep_cursor = 0
+
+ if not self.sweep_target_ids:
+ return
- def waypoint_scan_blocked(self, x, y):
pose = self.get_robot_pose()
- scan_age = self.get_scan_age()
- if pose is None or scan_age is None or scan_age > self.scan_stale_timeout:
- return False
+ if pose is None:
+ return
- dx = x - pose[0]
- dy = y - pose[1]
- target_distance = math.sqrt(dx ** 2 + dy ** 2)
- if target_distance <= 0.05:
- return False
+ best_index = 0
+ best_distance = None
+ for index, target_id in enumerate(self.sweep_target_ids):
+ target = next((candidate for candidate in self.targets if candidate.target_id == target_id), None)
+ if target is None or target.covered or target.skipped:
+ continue
+ distance = math.sqrt((target.x - pose[0]) ** 2 + (target.y - pose[1]) ** 2)
+ if best_distance is None or distance < best_distance:
+ best_distance = distance
+ best_index = index
+ self.sweep_cursor = best_index
+
+ def ordered_pending_targets(self):
+ if not self.sequence_initialized:
+ self.initialize_sweep_sequence()
+
+ if not self.sweep_target_ids:
+ return []
+
+ target_by_id = {target.target_id: target for target in self.targets}
+ ordered = []
+ total = len(self.sweep_target_ids)
+ for offset in range(total):
+ index = (self.sweep_cursor + offset) % total
+ target = target_by_id.get(self.sweep_target_ids[index])
+ if target is None or target.covered or target.skipped:
+ continue
+ ordered.append(target)
+ return ordered
+
+ def advance_sweep_cursor(self, target):
+ if not self.sweep_target_ids:
+ return
+ try:
+ index = self.sweep_target_ids.index(target.target_id)
+ except ValueError:
+ return
+ self.sweep_cursor = (index + 1) % len(self.sweep_target_ids)
- target_bearing = self.normalize_angle(math.atan2(dy, dx) - pose[2])
- clearance = self.sector_clearance(
- target_bearing - self.scan_blocked_sector,
- target_bearing + self.scan_blocked_sector
+ def publish_total_points(self):
+ self.total_points_pub.publish(Int32(data=self.count_total_targets()))
+
+ def publish_progress(self):
+ covered = self.count_covered_targets()
+ total = self.count_total_targets()
+ skipped = self.count_skipped_targets()
+ active = self.count_active_targets()
+ if total > 0:
+ percent = int(round((100.0 * covered) / total))
+ else:
+ percent = 100
+ message = "%d/%d targets covered (%d%%, %d skipped, %d active)" % (
+ covered,
+ total,
+ percent,
+ skipped,
+ active,
)
- if clearance is None:
- return False
+ self.progress_pub.publish(String(data=message))
+
+ def path_length(self, poses):
+ if not poses:
+ return None
+ total = 0.0
+ for i in range(1, len(poses)):
+ p0 = poses[i - 1].pose.position
+ p1 = poses[i].pose.position
+ total += math.sqrt((p1.x - p0.x) ** 2 + (p1.y - p0.y) ** 2)
+ return total
+
+ def plan_length_to_target(self, start_pose, target):
+ if self.make_plan_srv is None:
+ dx = target.x - start_pose.pose.position.x
+ dy = target.y - start_pose.pose.position.y
+ return math.sqrt(dx ** 2 + dy ** 2)
+
+ goal = self.build_pose(target.x, target.y, 0.0)
+ try:
+ response = self.make_plan_srv(start_pose, goal, self.make_plan_tolerance)
+ except rospy.ServiceException:
+ return None
+
+ if not response.plan.poses:
+ return None
+ return self.path_length(response.plan.poses)
+
+ def choose_next_target_once(self):
+ self.refresh_targets_from_costmap()
+ if not self.costmap_is_fresh():
+ rospy.logwarn("Global costmap is stale while selecting the next coverage target")
+ return None
+
+ pose = self.get_robot_pose()
+ if pose is None or pose[3] > self.pose_stale_timeout:
+ rospy.logwarn("Robot pose is stale while selecting the next coverage target")
+ return None
+
+ start_pose = self.build_pose(pose[0], pose[1], pose[2])
+ best_target = None
+ best_score = None
- if clearance + self.scan_blocked_margin < target_distance:
+ pending = self.ordered_pending_targets()
+ fallback_target = None
+ planned_candidate_count = 0
+
+ for target in pending:
+ if not target.currently_safe:
+ continue
+
+ if fallback_target is None:
+ fallback_target = target
+
+ plan_length = self.plan_length_to_target(start_pose, target)
+ if plan_length is None:
+ continue
+ planned_candidate_count += 1
+
+ row_penalty = 0.0
+ if self.last_selected_row is not None and target.row != self.last_selected_row:
+ row_penalty = abs(target.row - self.last_selected_row) * self.row_change_penalty
+
+ score = plan_length + (target.failures * self.failure_penalty) + row_penalty
+ if best_score is None or score < best_score:
+ best_score = score
+ best_target = target
+
+ if best_target is not None:
+ return best_target
+
+ if fallback_target is not None:
rospy.logwarn(
- "Skipping waypoint (%.2f, %.2f): scan shows obstacle at %.2fm before target distance %.2fm",
- x, y, clearance, target_distance
+ "make_plan found no path for ordered safe targets; falling back to sweep target %d at (%.2f, %.2f)",
+ fallback_target.target_id,
+ fallback_target.x,
+ fallback_target.y,
)
- return True
+ return fallback_target
- return False
+ if self.count_pending_targets() > 0:
+ rospy.logwarn(
+ "No active safe targets remain in the live costmap (pending=%d active=%d planned=%d)",
+ self.count_pending_targets(),
+ self.count_active_targets(),
+ planned_candidate_count,
+ )
- def waypoint_is_valid(self, x, y):
- if self.waypoint_cost_invalid(x, y):
- return False
- if self.waypoint_scan_blocked(x, y):
- return False
- return True
+ return best_target
- def rotate_for_recovery(self, turn_direction):
- cmd = Twist()
- cmd.angular.z = turn_direction * abs(self.recovery_turn_speed)
+ def choose_next_target(self):
+ target = self.choose_next_target_once()
+ if target is not None:
+ return target
+ if self.clear_costmaps_srv is not None:
+ try:
+ rospy.logwarn("No reachable target found; clearing costmaps and retrying once")
+ self.clear_costmaps_srv()
+ rospy.sleep(1.0)
+ except Exception:
+ pass
+ return self.choose_next_target_once()
+
+ return None
+
+ def send_move_base_goal(self, target):
pose = self.get_robot_pose()
- if pose is not None and pose[3] <= self.pose_stale_timeout:
- start_yaw = pose[2]
- timeout = rospy.Time.now() + rospy.Duration(4.0)
- rate = rospy.Rate(15)
- while not rospy.is_shutdown() and rospy.Time.now() < timeout:
- pose = self.get_robot_pose()
- if pose is None:
- break
- yaw_delta = abs(self.normalize_angle(pose[2] - start_yaw))
- if yaw_delta >= self.recovery_turn_angle:
- break
- self.cmd_vel_pub.publish(cmd)
- rate.sleep()
- else:
- duration = self.recovery_turn_angle / max(abs(self.recovery_turn_speed), 0.1)
- end_time = rospy.Time.now() + rospy.Duration(duration)
- rate = rospy.Rate(15)
- while not rospy.is_shutdown() and rospy.Time.now() < end_time:
- self.cmd_vel_pub.publish(cmd)
- rate.sleep()
-
- self.stop_robot()
-
- def backup_for_recovery(self):
- cmd = Twist()
- cmd.linear.x = min(self.recovery_backup_speed, -0.05)
- pose = self.get_robot_pose()
- target_distance = abs(self.recovery_backup_distance)
-
- if pose is not None and pose[3] <= self.pose_stale_timeout:
- start_x = pose[0]
- start_y = pose[1]
- timeout = rospy.Time.now() + rospy.Duration(6.0)
- rate = rospy.Rate(15)
- while not rospy.is_shutdown() and rospy.Time.now() < timeout:
- scan_age = self.get_scan_age()
- rear_clearance = self.sector_clearance(math.radians(140), math.radians(-140))
- if scan_age is not None and scan_age <= self.scan_stale_timeout and rear_clearance is not None and rear_clearance < self.recovery_clearance:
- rospy.logwarn("Stopping recovery backup early -- rear clearance only %.2fm", rear_clearance)
- break
-
- pose = self.get_robot_pose()
- if pose is None:
- break
- distance = math.sqrt((pose[0] - start_x) ** 2 + (pose[1] - start_y) ** 2)
- if distance >= target_distance:
- break
- self.cmd_vel_pub.publish(cmd)
- rate.sleep()
- else:
- duration = target_distance / max(abs(cmd.linear.x), 0.05)
- end_time = rospy.Time.now() + rospy.Duration(duration)
- rate = rospy.Rate(15)
- while not rospy.is_shutdown() and rospy.Time.now() < end_time:
- scan_age = self.get_scan_age()
- rear_clearance = self.sector_clearance(math.radians(140), math.radians(-140))
- if scan_age is not None and scan_age <= self.scan_stale_timeout and rear_clearance is not None and rear_clearance < self.recovery_clearance:
- rospy.logwarn("Stopping timed recovery backup early -- rear clearance only %.2fm", rear_clearance)
- break
- self.cmd_vel_pub.publish(cmd)
- rate.sleep()
-
- self.stop_robot()
-
- def perform_escape_recovery(self):
- rospy.logwarn("Running escape recovery: cancel goal, clear costmaps, rotate toward open side, then back up")
- self.move_base_client.cancel_goal()
- self.stop_robot()
- self.clear_costmaps()
- rospy.sleep(0.5)
-
- if self.get_scan_age() is None or self.get_scan_age() > self.scan_stale_timeout:
- rospy.logwarn("Laser scan is stale during recovery; using timed turn")
-
- turn_direction = self.choose_turn_direction()
- self.rotate_for_recovery(turn_direction)
- self.backup_for_recovery()
- self.clear_costmaps()
- rospy.sleep(1.0)
+ if pose is None or pose[3] > self.pose_stale_timeout:
+ rospy.logwarn("Robot pose is stale before goal dispatch")
+ return False
- def send_move_base_goal(self, x, y):
- """Send a goal to move_base and wait for result. Returns True on success."""
- if not self.wait_for_fresh_pose(2.0):
- rospy.logwarn("Robot pose is stale before goal dispatch; refusing to send goal until TF recovers")
+ if not self.costmap_is_fresh():
+ rospy.logwarn("Global costmap is stale before goal dispatch")
return False
+ target.currently_safe = self.is_target_safe(target.x, target.y)
+ if not target.currently_safe:
+ rospy.logwarn("Target %d at (%.2f, %.2f) is no longer safe before dispatch",
+ target.target_id, target.x, target.y)
+ return False
+
+ goal_yaw = pose[2]
+
goal = MoveBaseGoal()
- goal.target_pose.header.frame_id = "odom"
+ goal.target_pose.header.frame_id = 'odom'
goal.target_pose.header.stamp = rospy.Time.now()
- goal.target_pose.pose.position.x = x
- goal.target_pose.pose.position.y = y
+ goal.target_pose.pose.position.x = target.x
+ goal.target_pose.pose.position.y = target.y
goal.target_pose.pose.position.z = 0.0
- goal.target_pose.pose.orientation.w = 1.0
+
+ quat = tf.transformations.quaternion_from_euler(0.0, 0.0, goal_yaw)
+ goal.target_pose.pose.orientation.x = quat[0]
+ goal.target_pose.pose.orientation.y = quat[1]
+ goal.target_pose.pose.orientation.z = quat[2]
+ goal.target_pose.pose.orientation.w = quat[3]
+
+ start_distance = math.sqrt((target.x - pose[0]) ** 2 + (target.y - pose[1]) ** 2)
+ best_distance = start_distance
+ start_time = rospy.Time.now()
+ last_progress_time = start_time
+ last_log_time = start_time
self.move_base_client.send_goal(goal)
- finished = self.move_base_client.wait_for_result(rospy.Duration(self.waypoint_timeout))
+ rospy.loginfo(
+ "Tracking target %d at (%.2f, %.2f): start_distance=%.2f m timeout=%.1f s stall_timeout=%.1f s",
+ target.target_id,
+ target.x,
+ target.y,
+ start_distance,
+ self.waypoint_timeout,
+ self.stall_timeout,
+ )
- if not finished:
- self.move_base_client.cancel_goal()
- rospy.logwarn("move_base timed out reaching (%.2f, %.2f)", x, y)
- return False
+ rate = rospy.Rate(4)
+ while not rospy.is_shutdown():
+ if self.move_base_client.wait_for_result(rospy.Duration(0.0)):
+ break
+
+ now = rospy.Time.now()
+ pose = self.get_robot_pose()
+ if pose is not None and pose[3] <= self.pose_stale_timeout:
+ current_distance = math.sqrt((target.x - pose[0]) ** 2 + (target.y - pose[1]) ** 2)
+ if current_distance < best_distance - self.progress_epsilon:
+ improvement = best_distance - current_distance
+ best_distance = current_distance
+ last_progress_time = now
+ rospy.loginfo(
+ "Target %d progress: distance %.2f m (improved %.2f m, elapsed %.1f s)",
+ target.target_id,
+ current_distance,
+ improvement,
+ (now - start_time).to_sec(),
+ )
+ elif (now - last_log_time).to_sec() >= self.progress_log_interval:
+ rospy.loginfo(
+ "Target %d status: distance %.2f m best %.2f m elapsed %.1f s stalled %.1f s",
+ target.target_id,
+ current_distance,
+ best_distance,
+ (now - start_time).to_sec(),
+ (now - last_progress_time).to_sec(),
+ )
+ last_log_time = now
+
+ elapsed = (now - start_time).to_sec()
+ stalled = (now - last_progress_time).to_sec()
+ if elapsed >= self.waypoint_timeout:
+ self.move_base_client.cancel_goal()
+ rospy.logwarn(
+ "move_base timed out reaching target %d at (%.2f, %.2f): best_distance=%.2f m elapsed=%.1f s",
+ target.target_id,
+ target.x,
+ target.y,
+ best_distance,
+ elapsed,
+ )
+ return False
+
+ if stalled >= self.stall_timeout:
+ self.move_base_client.cancel_goal()
+ rospy.logwarn(
+ "move_base stalled on target %d at (%.2f, %.2f): best_distance=%.2f m stalled=%.1f s",
+ target.target_id,
+ target.x,
+ target.y,
+ best_distance,
+ stalled,
+ )
+ return False
+
+ rate.sleep()
state = self.move_base_client.get_state()
if state == actionlib.GoalStatus.SUCCEEDED:
+ rospy.loginfo(
+ "Reached target %d at (%.2f, %.2f): start_distance=%.2f m best_distance=%.2f m",
+ target.target_id,
+ target.x,
+ target.y,
+ start_distance,
+ best_distance,
+ )
return True
- else:
- rospy.logwarn("move_base failed for (%.2f, %.2f) -- state %d", x, y, state)
- return False
- def clear_costmaps(self):
- """Ask move_base to clear costmaps -- helps recover from stuck states."""
- try:
- from std_srvs.srv import Empty
- rospy.wait_for_service('/move_base/clear_costmaps', timeout=2.0)
- clear = rospy.ServiceProxy('/move_base/clear_costmaps', Empty)
- clear()
- rospy.loginfo("Costmaps cleared")
- except Exception:
- rospy.logwarn("Could not clear costmaps")
+ rospy.logwarn("move_base failed for target %d at (%.2f, %.2f) -- state %d",
+ target.target_id, target.x, target.y, state)
+ return False
- def publish_progress(self):
- """Publish current progress"""
- total = len(self.waypoints)
- msg = "{}/{} waypoints ({}%)".format(
- self.current_index, total,
- int(100.0 * self.current_index / total) if total > 0 else 0
- )
- self.progress_pub.publish(String(data=msg))
+ def capture_target(self):
+ rospy.loginfo("Dwelling for %.1f s and triggering capture", self.dwell_time)
+ self.capture_ready_pub.publish(Bool(data=True))
+ rospy.sleep(self.dwell_time)
+ self.capture_ready_pub.publish(Bool(data=False))
+
+ def complete_mission(self):
+ covered = self.count_covered_targets()
+ skipped = self.count_skipped_targets()
+ total = self.count_total_targets()
+ rospy.loginfo("Coverage mission complete: covered=%d total=%d skipped=%d", covered, total, skipped)
+ self.publish_total_points()
+ self.publish_progress()
+ self.complete_pub.publish(Bool(data=True))
+ rospy.sleep(1.0)
def run(self):
- # Wait for subscribers to connect and costmap to populate
- rospy.loginfo("Waiting 5s for costmap to populate from lidar...")
- rospy.sleep(5.0)
-
- rospy.loginfo("Starting coverage mission!")
-
- while self.current_index < len(self.waypoints) and not rospy.is_shutdown():
- x, y = self.waypoints[self.current_index]
- waypoint_failures = 0
-
- if not self.waypoint_is_valid(x, y):
- rospy.logwarn("Skipping waypoint %d/%d because the target is invalid in the live environment",
- self.current_index + 1, len(self.waypoints))
- self.current_index += 1
- continue
+ if not self.ready or rospy.is_shutdown():
+ return
- while waypoint_failures < self.max_waypoint_failures and not rospy.is_shutdown():
- rospy.loginfo("Waypoint %d/%d attempt %d/%d: (%.2f, %.2f)",
- self.current_index + 1, len(self.waypoints),
- waypoint_failures + 1, self.max_waypoint_failures, x, y)
- self.publish_progress()
+ rospy.loginfo("Waiting for a fresh robot pose...")
+ if not self.wait_for_fresh_pose(10.0):
+ rospy.logfatal("No fresh robot pose available; aborting coverage mission")
+ self.complete_pub.publish(Bool(data=True))
+ return
- if not self.waypoint_is_valid(x, y):
- waypoint_failures = self.max_waypoint_failures
- break
+ rospy.loginfo("Waiting for the global costmap to populate...")
+ if not self.wait_for_costmap(10.0):
+ rospy.logfatal("No fresh global costmap available; aborting coverage mission")
+ self.complete_pub.publish(Bool(data=True))
+ return
- success = self.send_move_base_goal(x, y)
- if success:
- rospy.loginfo("Reached waypoint %d/%d",
- self.current_index + 1, len(self.waypoints))
+ rospy.sleep(1.0)
+ self.refresh_targets_from_costmap()
+ self.publish_total_points()
+ self.publish_progress()
- # Only capture data at successfully reached waypoints
- rospy.loginfo("Dwelling for %.1f s (thermal capture)", self.dwell_time)
- self.capture_ready_pub.publish(Bool(data=True))
- rospy.sleep(self.dwell_time)
- self.capture_ready_pub.publish(Bool(data=False))
- break
+ if self.count_known_targets() == 0:
+ rospy.logwarn("No safe coverage targets found inside the requested square")
+ self.complete_mission()
+ return
- waypoint_failures += 1
- rospy.logwarn("Failed waypoint %d/%d on attempt %d/%d",
- self.current_index + 1, len(self.waypoints),
- waypoint_failures, self.max_waypoint_failures)
+ no_target_retries = 0
- if waypoint_failures < self.max_waypoint_failures:
- self.perform_escape_recovery()
+ while not rospy.is_shutdown():
+ self.refresh_targets_from_costmap()
+ self.publish_total_points()
+ self.publish_progress()
- if waypoint_failures >= self.max_waypoint_failures:
- rospy.logwarn("Skipping waypoint %d/%d after %d failed attempts",
- self.current_index + 1, len(self.waypoints),
- self.max_waypoint_failures)
+ if self.count_pending_targets() == 0:
+ break
- self.current_index += 1
+ target = self.choose_next_target()
+ if target is None:
+ pending_count = self.count_pending_targets()
+ active_count = self.count_active_targets()
+ if pending_count > 0 and no_target_retries < self.no_target_retry_limit:
+ no_target_retries += 1
+ rospy.logwarn(
+ "No selectable target right now (pending=%d active=%d). Waiting %.1fs for costmap/planner recovery before retry %d/%d",
+ pending_count,
+ active_count,
+ self.no_target_retry_sleep,
+ no_target_retries,
+ self.no_target_retry_limit,
+ )
+ rospy.sleep(self.no_target_retry_sleep)
+ continue
- # Mission complete
- rospy.loginfo("Coverage mission complete! %d/%d waypoints visited",
- self.current_index, len(self.waypoints))
- self.complete_pub.publish(Bool(data=True))
- self.publish_progress()
+ rospy.logwarn(
+ "No remaining reachable coverage targets; ending mission after %d retries (pending=%d active=%d)",
+ no_target_retries,
+ pending_count,
+ active_count,
+ )
+ for pending in self.targets:
+ if not pending.covered and not pending.skipped:
+ pending.skipped = True
+ self.publish_total_points()
+ self.publish_progress()
+ break
+
+ no_target_retries = 0
+ rospy.loginfo("Selected target %d at (%.2f, %.2f) row=%d col=%d failures=%d",
+ target.target_id, target.x, target.y,
+ target.row, target.col, target.failures)
+ self.advance_sweep_cursor(target)
+
+ success = self.send_move_base_goal(target)
+ if success:
+ target.covered = True
+ self.last_selected_row = target.row
+ self.publish_progress()
+ self.capture_target()
+ else:
+ target.failures += 1
+ if target.failures >= self.max_target_failures:
+ target.skipped = True
+ self.publish_total_points()
+ rospy.logwarn("Skipping target %d after %d failures",
+ target.target_id, self.max_target_failures)
+ self.publish_progress()
- rospy.spin()
+ self.complete_mission()
if __name__ == '__main__':