Skip to main content

rust_robotics_planning/
ara_star.rs

1//! Anytime Repairing A\* (ARA\*) path planning algorithm
2//!
3//! ARA\* finds a suboptimal solution quickly using an inflated heuristic
4//! (epsilon > 1), then iteratively decreases epsilon and improves the
5//! solution, reusing previous search effort.
6//!
7//! Reference: Likhachev et al., "ARA\*: Anytime A\* with Provable Bounds on
8//! Sub-Optimality", NIPS 2003.
9
10use std::cmp::Ordering;
11use std::collections::{BinaryHeap, HashMap};
12
13use crate::grid::GridMap;
14use rust_robotics_core::{Obstacles, Path2D, PathPlanner, Point2D, RoboticsError, RoboticsResult};
15
16/// Configuration for ARA\* planner
17#[derive(Debug, Clone)]
18pub struct ARAStarConfig {
19    /// Grid resolution in meters \[m\]
20    pub resolution: f64,
21    /// Robot radius for obstacle inflation \[m\]
22    pub robot_radius: f64,
23    /// Initial inflation factor (epsilon). Values > 1 yield suboptimal but
24    /// faster solutions.
25    pub initial_epsilon: f64,
26    /// Amount by which epsilon is decreased each iteration
27    pub epsilon_step: f64,
28    /// Minimum (final) epsilon. At 1.0 the algorithm is optimal A\*.
29    pub final_epsilon: f64,
30}
31
32impl Default for ARAStarConfig {
33    fn default() -> Self {
34        Self {
35            resolution: 1.0,
36            robot_radius: 0.5,
37            initial_epsilon: 3.0,
38            epsilon_step: 0.5,
39            final_epsilon: 1.0,
40        }
41    }
42}
43
44impl ARAStarConfig {
45    /// Validate all configuration parameters.
46    pub fn validate(&self) -> RoboticsResult<()> {
47        if !self.resolution.is_finite() || self.resolution <= 0.0 {
48            return Err(RoboticsError::InvalidParameter(format!(
49                "resolution must be positive and finite, got {}",
50                self.resolution
51            )));
52        }
53        if !self.robot_radius.is_finite() || self.robot_radius < 0.0 {
54            return Err(RoboticsError::InvalidParameter(format!(
55                "robot_radius must be non-negative and finite, got {}",
56                self.robot_radius
57            )));
58        }
59        if !self.initial_epsilon.is_finite() || self.initial_epsilon < 1.0 {
60            return Err(RoboticsError::InvalidParameter(format!(
61                "initial_epsilon must be >= 1.0 and finite, got {}",
62                self.initial_epsilon
63            )));
64        }
65        if !self.epsilon_step.is_finite() || self.epsilon_step <= 0.0 {
66            return Err(RoboticsError::InvalidParameter(format!(
67                "epsilon_step must be positive and finite, got {}",
68                self.epsilon_step
69            )));
70        }
71        if !self.final_epsilon.is_finite() || self.final_epsilon < 1.0 {
72            return Err(RoboticsError::InvalidParameter(format!(
73                "final_epsilon must be >= 1.0 and finite, got {}",
74                self.final_epsilon
75            )));
76        }
77        if self.final_epsilon > self.initial_epsilon {
78            return Err(RoboticsError::InvalidParameter(format!(
79                "final_epsilon ({}) must be <= initial_epsilon ({})",
80                self.final_epsilon, self.initial_epsilon
81            )));
82        }
83        Ok(())
84    }
85}
86
87// ── Priority node for the min-heap open set ──────────────────────────────────
88
89#[derive(Debug)]
90struct PriorityNode {
91    grid_idx: i32,
92    g: f64,
93    f: f64,
94}
95
96impl Eq for PriorityNode {}
97
98impl PartialEq for PriorityNode {
99    fn eq(&self, other: &Self) -> bool {
100        self.f == other.f
101    }
102}
103
104impl Ord for PriorityNode {
105    fn cmp(&self, other: &Self) -> Ordering {
106        // Reverse for min-heap
107        other.f.partial_cmp(&self.f).unwrap_or(Ordering::Equal)
108    }
109}
110
111impl PartialOrd for PriorityNode {
112    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
113        Some(self.cmp(other))
114    }
115}
116
117// ── ARA\* planner ─────────────────────────────────────────────────────────────
118
119/// ARA\* (Anytime Repairing A\*) path planner.
120///
121/// Produces a sequence of improving solutions by repeatedly running weighted
122/// A\* with a decreasing inflation factor epsilon.
123pub struct ARAStarPlanner {
124    grid_map: GridMap,
125    config: ARAStarConfig,
126    motion: Vec<(i32, i32, f64)>,
127}
128
129impl ARAStarPlanner {
130    /// Create a new ARA\* planner. Panics on invalid input.
131    pub fn new(ox: &[f64], oy: &[f64], config: ARAStarConfig) -> Self {
132        Self::try_new(ox, oy, config).expect(
133            "invalid ARA* planner input: obstacles must be non-empty and valid, config values must be positive/finite",
134        )
135    }
136
137    /// Create a validated ARA\* planner.
138    pub fn try_new(ox: &[f64], oy: &[f64], config: ARAStarConfig) -> RoboticsResult<Self> {
139        config.validate()?;
140        let grid_map = GridMap::try_new(ox, oy, config.resolution, config.robot_radius)?;
141        Ok(Self {
142            grid_map,
143            motion: Self::motion_model(),
144            config,
145        })
146    }
147
148    /// Create a validated ARA\* planner from typed obstacle points.
149    pub fn from_obstacle_points(
150        obstacles: &Obstacles,
151        config: ARAStarConfig,
152    ) -> RoboticsResult<Self> {
153        config.validate()?;
154        let grid_map = GridMap::from_obstacles(obstacles, config.resolution, config.robot_radius)?;
155        Ok(Self {
156            grid_map,
157            motion: Self::motion_model(),
158            config,
159        })
160    }
161
162    /// Plan a path from `start` to `goal`, returning the best (lowest epsilon)
163    /// solution found.
164    pub fn plan(&self, start: Point2D, goal: Point2D) -> RoboticsResult<Path2D> {
165        let solutions = self.plan_anytime(start, goal);
166        solutions
167            .into_iter()
168            .last()
169            .map(|(_, path)| path)
170            .ok_or_else(|| RoboticsError::PlanningError("No path found".to_string()))
171    }
172
173    /// Run the full ARA\* anytime loop, returning every improving solution as
174    /// a `(epsilon, path)` pair ordered from highest (first, fast) to lowest
175    /// (last, most optimal) epsilon.
176    pub fn plan_anytime(&self, start: Point2D, goal: Point2D) -> Vec<(f64, Path2D)> {
177        let start_x = self.grid_map.calc_x_index(start.x);
178        let start_y = self.grid_map.calc_y_index(start.y);
179        let goal_x = self.grid_map.calc_x_index(goal.x);
180        let goal_y = self.grid_map.calc_y_index(goal.y);
181
182        if !self.grid_map.is_valid(start_x, start_y) || !self.grid_map.is_valid(goal_x, goal_y) {
183            return Vec::new();
184        }
185
186        let start_idx = self.grid_map.calc_index(start_x, start_y);
187        let goal_idx = self.grid_map.calc_index(goal_x, goal_y);
188
189        // g-value table shared across iterations: grid_idx -> g
190        let mut g_table: HashMap<i32, f64> = HashMap::new();
191        // parent table: grid_idx -> (parent_grid_idx, node x, node y)
192        let mut parent: HashMap<i32, Option<i32>> = HashMap::new();
193        // node coordinate lookup: grid_idx -> (x, y)
194        let mut coords: HashMap<i32, (i32, i32)> = HashMap::new();
195
196        g_table.insert(start_idx, 0.0);
197        parent.insert(start_idx, None);
198        coords.insert(start_idx, (start_x, start_y));
199        coords.insert(goal_idx, (goal_x, goal_y));
200
201        let mut solutions: Vec<(f64, Path2D)> = Vec::new();
202        let mut epsilon = self.config.initial_epsilon;
203
204        loop {
205            if let Some(path) = self.weighted_a_star(
206                start_idx,
207                goal_idx,
208                start_x,
209                start_y,
210                goal_x,
211                goal_y,
212                epsilon,
213                &mut g_table,
214                &mut parent,
215                &mut coords,
216            ) {
217                solutions.push((epsilon, path));
218            }
219
220            if epsilon <= self.config.final_epsilon + f64::EPSILON {
221                break;
222            }
223            epsilon = (epsilon - self.config.epsilon_step).max(self.config.final_epsilon);
224        }
225
226        solutions
227    }
228
229    /// Get a reference to the underlying grid map.
230    pub fn grid_map(&self) -> &GridMap {
231        &self.grid_map
232    }
233
234    // ── Internal helpers ──────────────────────────────────────────────────────
235
236    fn motion_model() -> Vec<(i32, i32, f64)> {
237        vec![
238            (1, 0, 1.0),
239            (0, 1, 1.0),
240            (-1, 0, 1.0),
241            (0, -1, 1.0),
242            (-1, -1, std::f64::consts::SQRT_2),
243            (-1, 1, std::f64::consts::SQRT_2),
244            (1, -1, std::f64::consts::SQRT_2),
245            (1, 1, std::f64::consts::SQRT_2),
246        ]
247    }
248
249    fn heuristic(&self, x: i32, y: i32, gx: i32, gy: i32) -> f64 {
250        (((x - gx).pow(2) + (y - gy).pow(2)) as f64).sqrt()
251    }
252
253    /// Run one pass of weighted A\* with the given epsilon, seeding the search
254    /// from the current g-value table (ARA\* "warm start").
255    ///
256    /// Returns the reconstructed path if the goal is reachable.
257    #[allow(clippy::too_many_arguments)]
258    fn weighted_a_star(
259        &self,
260        start_idx: i32,
261        goal_idx: i32,
262        start_x: i32,
263        start_y: i32,
264        goal_x: i32,
265        goal_y: i32,
266        epsilon: f64,
267        g_table: &mut HashMap<i32, f64>,
268        parent: &mut HashMap<i32, Option<i32>>,
269        coords: &mut HashMap<i32, (i32, i32)>,
270    ) -> Option<Path2D> {
271        let start_g = *g_table.get(&start_idx).unwrap_or(&0.0);
272        let start_h = epsilon * self.heuristic(start_x, start_y, goal_x, goal_y);
273
274        let mut open: BinaryHeap<PriorityNode> = BinaryHeap::new();
275        // incons holds nodes that were improved while already in closed
276        let mut incons: HashMap<i32, f64> = HashMap::new();
277        let mut closed: HashMap<i32, f64> = HashMap::new();
278
279        open.push(PriorityNode {
280            grid_idx: start_idx,
281            g: start_g,
282            f: start_g + start_h,
283        });
284
285        // Seed open set with all previously discovered nodes that are not
286        // in closed — their f-values may have changed with the new epsilon.
287        for (&idx, &g) in g_table.iter() {
288            if idx == start_idx {
289                continue;
290            }
291            if let Some(&(x, y)) = coords.get(&idx) {
292                if self.grid_map.is_valid(x, y) {
293                    let f = g + epsilon * self.heuristic(x, y, goal_x, goal_y);
294                    open.push(PriorityNode {
295                        grid_idx: idx,
296                        g,
297                        f,
298                    });
299                }
300            }
301        }
302
303        while let Some(current) = open.pop() {
304            // Skip stale entries (lazy deletion)
305            if let Some(&best_g) = g_table.get(&current.grid_idx) {
306                if current.g > best_g + f64::EPSILON {
307                    continue;
308                }
309            }
310            if closed.contains_key(&current.grid_idx) {
311                continue;
312            }
313
314            closed.insert(current.grid_idx, current.g);
315
316            if current.grid_idx == goal_idx {
317                return Some(self.reconstruct_path(goal_idx, start_idx, parent, coords));
318            }
319
320            let (cx, cy) = match coords.get(&current.grid_idx) {
321                Some(&c) => c,
322                None => continue,
323            };
324
325            for &(dx, dy, move_cost) in &self.motion {
326                if !self.grid_map.is_valid_offset(cx, cy, dx, dy) {
327                    continue;
328                }
329
330                let nx = cx + dx;
331                let ny = cy + dy;
332                let n_idx = self.grid_map.calc_index(nx, ny);
333                let new_g = current.g + move_cost;
334
335                let old_g = g_table.get(&n_idx).copied().unwrap_or(f64::INFINITY);
336
337                if new_g < old_g - f64::EPSILON {
338                    g_table.insert(n_idx, new_g);
339                    coords.insert(n_idx, (nx, ny));
340                    parent.insert(n_idx, Some(current.grid_idx));
341
342                    if closed.contains_key(&n_idx) {
343                        // Node already expanded — put in incons for next pass
344                        incons.insert(n_idx, new_g);
345                    } else {
346                        let f = new_g + epsilon * self.heuristic(nx, ny, goal_x, goal_y);
347                        open.push(PriorityNode {
348                            grid_idx: n_idx,
349                            g: new_g,
350                            f,
351                        });
352                    }
353                }
354            }
355        }
356
357        // Move incons into g_table so next iteration can pick them up
358        for (idx, g) in incons {
359            g_table.insert(idx, g);
360        }
361
362        None
363    }
364
365    fn reconstruct_path(
366        &self,
367        goal_idx: i32,
368        start_idx: i32,
369        parent: &HashMap<i32, Option<i32>>,
370        coords: &HashMap<i32, (i32, i32)>,
371    ) -> Path2D {
372        let mut points = Vec::new();
373        let mut current = goal_idx;
374
375        loop {
376            if let Some(&(x, y)) = coords.get(&current) {
377                points.push(Point2D::new(
378                    self.grid_map.calc_x_position(x),
379                    self.grid_map.calc_y_position(y),
380                ));
381            }
382            if current == start_idx {
383                break;
384            }
385            match parent.get(&current) {
386                Some(Some(p)) => current = *p,
387                _ => break,
388            }
389        }
390
391        points.reverse();
392        Path2D::from_points(points)
393    }
394}
395
396impl PathPlanner for ARAStarPlanner {
397    fn plan(&self, start: Point2D, goal: Point2D) -> Result<Path2D, RoboticsError> {
398        ARAStarPlanner::plan(self, start, goal)
399    }
400}
401
402// ── Tests ─────────────────────────────────────────────────────────────────────
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    fn simple_obstacles() -> (Vec<f64>, Vec<f64>) {
409        let mut ox = Vec::new();
410        let mut oy = Vec::new();
411        for i in 0..=10 {
412            ox.push(i as f64);
413            oy.push(0.0);
414            ox.push(i as f64);
415            oy.push(10.0);
416            ox.push(0.0);
417            oy.push(i as f64);
418            ox.push(10.0);
419            oy.push(i as f64);
420        }
421        // Internal wall with a gap
422        for i in 3..8 {
423            ox.push(5.0);
424            oy.push(i as f64);
425        }
426        (ox, oy)
427    }
428
429    #[test]
430    fn test_ara_star_finds_path() {
431        let (ox, oy) = simple_obstacles();
432        let planner = ARAStarPlanner::new(&ox, &oy, ARAStarConfig::default());
433
434        let path = planner.plan(Point2D::new(2.0, 2.0), Point2D::new(8.0, 8.0));
435        assert!(path.is_ok(), "ARA* should find a path: {:?}", path.err());
436        assert!(!path.unwrap().is_empty());
437    }
438
439    #[test]
440    fn test_plan_anytime_returns_multiple_solutions() {
441        let (ox, oy) = simple_obstacles();
442        let config = ARAStarConfig {
443            initial_epsilon: 3.0,
444            epsilon_step: 0.5,
445            final_epsilon: 1.0,
446            ..Default::default()
447        };
448        let planner = ARAStarPlanner::new(&ox, &oy, config);
449
450        let solutions = planner.plan_anytime(Point2D::new(2.0, 2.0), Point2D::new(8.0, 8.0));
451        // With epsilon going 3.0 -> 2.5 -> 2.0 -> 1.5 -> 1.0 we expect 5 iterations
452        assert!(
453            solutions.len() >= 2,
454            "plan_anytime should return at least 2 solutions, got {}",
455            solutions.len()
456        );
457    }
458
459    #[test]
460    fn test_first_solution_cost_not_less_than_final() {
461        let (ox, oy) = simple_obstacles();
462        let config = ARAStarConfig {
463            initial_epsilon: 3.0,
464            epsilon_step: 0.5,
465            final_epsilon: 1.0,
466            ..Default::default()
467        };
468        let planner = ARAStarPlanner::new(&ox, &oy, config);
469
470        let solutions = planner.plan_anytime(Point2D::new(2.0, 2.0), Point2D::new(8.0, 8.0));
471        assert!(solutions.len() >= 2, "need at least 2 solutions to compare");
472
473        let first_cost = solutions.first().unwrap().1.total_length();
474        let last_cost = solutions.last().unwrap().1.total_length();
475        println!("first_cost={first_cost:.4} last_cost={last_cost:.4}");
476
477        // Inflated heuristic can only produce solutions >= optimal length
478        assert!(
479            first_cost >= last_cost - 1e-9,
480            "first solution ({first_cost}) should be >= final solution ({last_cost})"
481        );
482    }
483
484    #[test]
485    fn test_config_defaults() {
486        let cfg = ARAStarConfig::default();
487        assert_eq!(cfg.resolution, 1.0);
488        assert_eq!(cfg.robot_radius, 0.5);
489        assert_eq!(cfg.initial_epsilon, 3.0);
490        assert_eq!(cfg.epsilon_step, 0.5);
491        assert_eq!(cfg.final_epsilon, 1.0);
492        assert!(cfg.validate().is_ok());
493    }
494}