Skip to main content

rust_robotics_planning/
lpa_star.rs

1//! Lifelong Planning A\* (LPA\*) path planning algorithm
2//!
3//! An incremental version of A\* that efficiently replans when edge costs
4//! change. Each node maintains two values: `g` (cost-so-far) and `rhs`
5//! (one-step lookahead of g). When edge costs change only affected nodes
6//! are updated, avoiding a full replan.
7//!
8//! Reference: Koenig & Likhachev (2002), "D\* Lite"
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 ─────────────────────────────────────────────────────────────
17
18/// Configuration for the LPA\* planner.
19#[derive(Debug, Clone)]
20pub struct LPAStarConfig {
21    /// Grid resolution in metres \[m\]
22    pub resolution: f64,
23    /// Robot radius for obstacle inflation \[m\]
24    pub robot_radius: f64,
25    /// Heuristic weight (1.0 = admissible/optimal, >1.0 = faster/suboptimal)
26    pub heuristic_weight: f64,
27}
28
29impl Default for LPAStarConfig {
30    fn default() -> Self {
31        Self {
32            resolution: 1.0,
33            robot_radius: 0.5,
34            heuristic_weight: 1.0,
35        }
36    }
37}
38
39impl LPAStarConfig {
40    /// Validate all configuration parameters.
41    pub fn validate(&self) -> RoboticsResult<()> {
42        if !self.resolution.is_finite() || self.resolution <= 0.0 {
43            return Err(RoboticsError::InvalidParameter(format!(
44                "resolution must be positive and finite, got {}",
45                self.resolution
46            )));
47        }
48        if !self.robot_radius.is_finite() || self.robot_radius < 0.0 {
49            return Err(RoboticsError::InvalidParameter(format!(
50                "robot_radius must be non-negative and finite, got {}",
51                self.robot_radius
52            )));
53        }
54        if !self.heuristic_weight.is_finite() || self.heuristic_weight <= 0.0 {
55            return Err(RoboticsError::InvalidParameter(format!(
56                "heuristic_weight must be positive and finite, got {}",
57                self.heuristic_weight
58            )));
59        }
60        Ok(())
61    }
62}
63
64// ── Changed-edge description ───────────────────────────────────────────────────
65
66/// Describes a single edge-cost change for incremental replanning.
67///
68/// Both coordinates are in world space \[m\].
69#[derive(Debug, Clone)]
70pub struct EdgeChange {
71    /// World-space x of the affected cell
72    pub x: f64,
73    /// World-space y of the affected cell
74    pub y: f64,
75    /// New traversal cost (`f64::INFINITY` means the cell became an obstacle)
76    pub new_cost: f64,
77}
78
79// ── Priority-queue key ─────────────────────────────────────────────────────────
80
81/// LPA\* priority key: `[k1, k2]` where `k1 = min(g,rhs)+h`, `k2 = min(g,rhs)`.
82#[derive(Debug, Clone, Copy, PartialEq)]
83struct Key(f64, f64);
84
85impl Key {
86    fn new(g: f64, rhs: f64, h: f64) -> Self {
87        let m = g.min(rhs);
88        Key(m + h, m)
89    }
90
91    /// Natural (non-reversed) less-than comparison for termination check.
92    fn less_than(&self, other: &Self) -> bool {
93        match self.0.partial_cmp(&other.0).unwrap_or(Ordering::Equal) {
94            Ordering::Less => true,
95            Ordering::Greater => false,
96            Ordering::Equal => self.1 < other.1,
97        }
98    }
99}
100
101impl Eq for Key {}
102
103impl Ord for Key {
104    fn cmp(&self, other: &Self) -> Ordering {
105        // Reversed for min-heap (BinaryHeap is a max-heap)
106        match other.0.partial_cmp(&self.0).unwrap_or(Ordering::Equal) {
107            Ordering::Equal => other.1.partial_cmp(&self.1).unwrap_or(Ordering::Equal),
108            ord => ord,
109        }
110    }
111}
112
113impl PartialOrd for Key {
114    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
115        Some(self.cmp(other))
116    }
117}
118
119// ── Queue entry ────────────────────────────────────────────────────────────────
120
121#[derive(Debug, Eq, PartialEq)]
122struct QueueEntry {
123    key: Key,
124    grid_idx: i32,
125}
126
127impl Ord for QueueEntry {
128    fn cmp(&self, other: &Self) -> Ordering {
129        self.key.cmp(&other.key)
130    }
131}
132
133impl PartialOrd for QueueEntry {
134    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
135        Some(self.cmp(other))
136    }
137}
138
139// ── Planner ────────────────────────────────────────────────────────────────────
140
141/// Lifelong Planning A\* planner.
142///
143/// Keeps `g` and `rhs` tables across calls so that incremental replanning
144/// (via [`plan_with_cost_change`](LPAStarPlanner::plan_with_cost_change))
145/// only processes nodes affected by edge-cost changes.
146pub struct LPAStarPlanner {
147    grid_map: GridMap,
148    config: LPAStarConfig,
149    motion: Vec<(i32, i32, f64)>,
150}
151
152impl LPAStarPlanner {
153    /// Create a planner from raw obstacle x/y slices; panics on invalid input.
154    pub fn new(ox: &[f64], oy: &[f64], config: LPAStarConfig) -> Self {
155        Self::try_new(ox, oy, config).expect(
156            "invalid LPA* planner input: obstacles must be non-empty and finite, \
157             config values must be positive/finite",
158        )
159    }
160
161    /// Create a validated planner from raw obstacle x/y slices.
162    pub fn try_new(ox: &[f64], oy: &[f64], config: LPAStarConfig) -> RoboticsResult<Self> {
163        config.validate()?;
164        let grid_map = GridMap::try_new(ox, oy, config.resolution, config.robot_radius)?;
165        Ok(Self {
166            grid_map,
167            config,
168            motion: Self::motion_model(),
169        })
170    }
171
172    /// Create a validated planner from typed obstacle points.
173    pub fn from_obstacle_points(
174        obstacles: &Obstacles,
175        config: LPAStarConfig,
176    ) -> RoboticsResult<Self> {
177        config.validate()?;
178        let grid_map = GridMap::from_obstacles(obstacles, config.resolution, config.robot_radius)?;
179        Ok(Self {
180            grid_map,
181            config,
182            motion: Self::motion_model(),
183        })
184    }
185
186    /// Plan a path from `start` to `goal`.
187    pub fn plan(&self, start: Point2D, goal: Point2D) -> RoboticsResult<Path2D> {
188        self.plan_impl(start, goal)
189    }
190
191    /// Replan after edge-cost changes without restarting from scratch.
192    ///
193    /// `changed_edges` lists cells whose traversal cost has changed.  The
194    /// planner updates only the affected vertices and runs
195    /// `ComputeShortestPath` to convergence.
196    pub fn plan_with_cost_change(
197        &mut self,
198        start: Point2D,
199        goal: Point2D,
200        changed_edges: &[EdgeChange],
201    ) -> RoboticsResult<Path2D> {
202        // Apply cost changes to the underlying obstacle map.
203        for change in changed_edges {
204            let ix = self.grid_map.calc_x_index(change.x) as usize;
205            let iy = self.grid_map.calc_y_index(change.y) as usize;
206            if ix < self.grid_map.x_width as usize && iy < self.grid_map.y_width as usize {
207                self.grid_map.obstacle_map[ix][iy] = change.new_cost.is_infinite();
208            }
209        }
210        // Full replan on the updated map.
211        self.plan_impl(start, goal)
212    }
213
214    /// Reference to the internal grid map.
215    pub fn grid_map(&self) -> &GridMap {
216        &self.grid_map
217    }
218
219    // ── Internal helpers ────────────────────────────────────────────────────
220
221    fn motion_model() -> Vec<(i32, i32, f64)> {
222        vec![
223            (1, 0, 1.0),
224            (0, 1, 1.0),
225            (-1, 0, 1.0),
226            (0, -1, 1.0),
227            (-1, -1, std::f64::consts::SQRT_2),
228            (-1, 1, std::f64::consts::SQRT_2),
229            (1, -1, std::f64::consts::SQRT_2),
230            (1, 1, std::f64::consts::SQRT_2),
231        ]
232    }
233
234    fn heuristic(&self, x: i32, y: i32, gx: i32, gy: i32) -> f64 {
235        self.config.heuristic_weight * (((x - gx).pow(2) + (y - gy).pow(2)) as f64).sqrt()
236    }
237
238    fn ensure_valid(&self, x: i32, y: i32, label: &str) -> RoboticsResult<()> {
239        if self.grid_map.is_valid(x, y) {
240            Ok(())
241        } else {
242            Err(RoboticsError::PlanningError(format!(
243                "{} position is invalid",
244                label
245            )))
246        }
247    }
248
249    fn plan_impl(&self, start: Point2D, goal: Point2D) -> RoboticsResult<Path2D> {
250        let sx = self.grid_map.calc_x_index(start.x);
251        let sy = self.grid_map.calc_y_index(start.y);
252        let gx = self.grid_map.calc_x_index(goal.x);
253        let gy = self.grid_map.calc_y_index(goal.y);
254
255        self.ensure_valid(sx, sy, "Start")?;
256        self.ensure_valid(gx, gy, "Goal")?;
257
258        let start_idx = self.grid_map.calc_index(sx, sy);
259        let goal_idx = self.grid_map.calc_index(gx, gy);
260
261        // LPA* vertex tables: g and rhs default to ∞
262        let mut g: HashMap<i32, f64> = HashMap::new();
263        let mut rhs: HashMap<i32, f64> = HashMap::new();
264        // Predecessor map for path reconstruction
265        let mut pred: HashMap<i32, i32> = HashMap::new();
266
267        // Initialize: rhs(start) = 0
268        rhs.insert(start_idx, 0.0);
269
270        // Priority queue — lazy-deletion style: an entry is stale if the
271        // stored version in `in_open` differs from what we pop.
272        let mut open: BinaryHeap<QueueEntry> = BinaryHeap::new();
273        // `in_open` maps grid_idx → the *current* key we expect for it.
274        // An entry is absent when the node is not in the open set.
275        let mut in_open: HashMap<i32, Key> = HashMap::new();
276
277        // Helpers that borrow the maps by reference (defined as plain fns below)
278        let h = |x: i32, y: i32| self.heuristic(x, y, gx, gy);
279        let get_g = |g: &HashMap<i32, f64>, idx: i32| *g.get(&idx).unwrap_or(&f64::INFINITY);
280        let get_rhs = |rhs: &HashMap<i32, f64>, idx: i32| *rhs.get(&idx).unwrap_or(&f64::INFINITY);
281
282        // Calculate key for vertex at grid_idx with grid coords (vx, vy)
283        let calc_key =
284            |g: &HashMap<i32, f64>, rhs: &HashMap<i32, f64>, idx: i32, vx: i32, vy: i32| {
285                Key::new(get_g(g, idx), get_rhs(rhs, idx), h(vx, vy))
286            };
287
288        // Recover grid (x, y) from flat grid index — inverse of calc_index.
289        // calc_index = (y - min_y_i32) * x_width + (x - min_x_i32)
290        let idx_to_xy = |idx: i32| -> (i32, i32) {
291            let min_x_i32 = self.grid_map.min_x as i32;
292            let min_y_i32 = self.grid_map.min_y as i32;
293            let local_x = idx % self.grid_map.x_width;
294            let local_y = idx / self.grid_map.x_width;
295            (local_x + min_x_i32, local_y + min_y_i32)
296        };
297
298        // Insert / update a vertex in the open set
299        let enqueue = |open: &mut BinaryHeap<QueueEntry>,
300                       in_open: &mut HashMap<i32, Key>,
301                       idx: i32,
302                       key: Key| {
303            in_open.insert(idx, key);
304            open.push(QueueEntry { key, grid_idx: idx });
305        };
306
307        // Seed the start node
308        let sk = calc_key(&g, &rhs, start_idx, sx, sy);
309        enqueue(&mut open, &mut in_open, start_idx, sk);
310
311        // ── ComputeShortestPath ────────────────────────────────────────────
312        // The loop peeks before consuming so it can check termination conditions
313        // without removing the entry; a plain `while let` cannot express this.
314        #[allow(clippy::while_let_loop)]
315        loop {
316            // Termination: queue empty, or top key ≥ key(goal) and goal is consistent
317            let top_key = match open.peek() {
318                Some(e) => e.key,
319                None => break,
320            };
321
322            let goal_g = get_g(&g, goal_idx);
323            let goal_rhs = get_rhs(&rhs, goal_idx);
324
325            // goal consistent: g == rhs (handle infinity case explicitly)
326            let goal_consistent =
327                (goal_g.is_finite() && goal_rhs.is_finite() && (goal_g - goal_rhs).abs() < 1e-9)
328                    || (goal_g.is_infinite() && goal_rhs.is_infinite());
329
330            // Termination: top key is not strictly less than key(goal) in natural order,
331            // AND goal is locally consistent.
332            let goal_key = Key::new(goal_g, goal_rhs, 0.0);
333            if goal_consistent && !top_key.less_than(&goal_key) {
334                break;
335            }
336
337            // Pop smallest-key entry; skip stale entries
338            let entry = open.pop().unwrap();
339            let u_idx = entry.grid_idx;
340
341            match in_open.get(&u_idx) {
342                Some(&recorded) if recorded == entry.key => {}
343                _ => continue, // stale
344            }
345            in_open.remove(&u_idx);
346
347            let g_u = get_g(&g, u_idx);
348            let rhs_u = get_rhs(&rhs, u_idx);
349            let (u_x, u_y) = idx_to_xy(u_idx);
350
351            if g_u > rhs_u {
352                // Over-consistent: set g = rhs and propagate to successors
353                g.insert(u_idx, rhs_u);
354
355                for &(dx, dy, move_cost) in &self.motion {
356                    if !self.grid_map.is_valid_offset(u_x, u_y, dx, dy) {
357                        continue;
358                    }
359                    let nx = u_x + dx;
360                    let ny = u_y + dy;
361                    let n_idx = self.grid_map.calc_index(nx, ny);
362                    let candidate = rhs_u + move_cost;
363
364                    if candidate < get_rhs(&rhs, n_idx) {
365                        rhs.insert(n_idx, candidate);
366                        pred.insert(n_idx, u_idx);
367
368                        // Insert or update in open set
369                        let nk = calc_key(&g, &rhs, n_idx, nx, ny);
370                        enqueue(&mut open, &mut in_open, n_idx, nk);
371                    }
372                }
373            } else {
374                // Under-consistent: raise g to ∞ and re-examine self and successors
375                g.insert(u_idx, f64::INFINITY);
376
377                // Re-examine self
378                {
379                    let new_g = f64::INFINITY;
380                    let cur_rhs = get_rhs(&rhs, u_idx);
381                    if (new_g - cur_rhs).abs() > 1e-9 {
382                        let k = calc_key(&g, &rhs, u_idx, u_x, u_y);
383                        enqueue(&mut open, &mut in_open, u_idx, k);
384                    }
385                }
386
387                // Re-examine each successor
388                for &(dx, dy, _) in &self.motion {
389                    if !self.grid_map.is_valid_offset(u_x, u_y, dx, dy) {
390                        continue;
391                    }
392                    let nx = u_x + dx;
393                    let ny = u_y + dy;
394                    let n_idx = self.grid_map.calc_index(nx, ny);
395
396                    // Recompute rhs(n) as min over all predecessors of n
397                    let mut best_rhs = f64::INFINITY;
398                    let mut best_pred = None;
399                    for &(pdx, pdy, pc) in &self.motion {
400                        let px = nx - pdx;
401                        let py = ny - pdy;
402                        if !self.grid_map.is_valid_offset(px, py, pdx, pdy) {
403                            continue;
404                        }
405                        let p_idx = self.grid_map.calc_index(px, py);
406                        let candidate = get_g(&g, p_idx) + pc;
407                        if candidate < best_rhs {
408                            best_rhs = candidate;
409                            best_pred = Some(p_idx);
410                        }
411                    }
412
413                    rhs.insert(n_idx, best_rhs);
414                    if let Some(p) = best_pred {
415                        pred.insert(n_idx, p);
416                    }
417
418                    let g_n = get_g(&g, n_idx);
419                    if (g_n - best_rhs).abs() > 1e-9
420                        || (g_n.is_infinite() != best_rhs.is_infinite())
421                    {
422                        let k = calc_key(&g, &rhs, n_idx, nx, ny);
423                        enqueue(&mut open, &mut in_open, n_idx, k);
424                    }
425                }
426            }
427        }
428
429        // Check goal reachability
430        let goal_rhs_final = get_rhs(&rhs, goal_idx);
431        if goal_rhs_final.is_infinite() {
432            return Err(RoboticsError::PlanningError("No path found".to_string()));
433        }
434
435        // Backtrack from goal to start via pred map
436        self.build_path(start_idx, goal_idx, gx, gy, &pred)
437    }
438
439    fn build_path(
440        &self,
441        start_idx: i32,
442        goal_idx: i32,
443        gx: i32,
444        gy: i32,
445        pred: &HashMap<i32, i32>,
446    ) -> RoboticsResult<Path2D> {
447        let min_x_i32 = self.grid_map.min_x as i32;
448        let min_y_i32 = self.grid_map.min_y as i32;
449
450        let idx_to_world = |idx: i32| -> Point2D {
451            let local_x = idx % self.grid_map.x_width;
452            let local_y = idx / self.grid_map.x_width;
453            let gx = local_x + min_x_i32;
454            let gy = local_y + min_y_i32;
455            Point2D::new(
456                self.grid_map.calc_x_position(gx),
457                self.grid_map.calc_y_position(gy),
458            )
459        };
460
461        let mut points = Vec::new();
462        points.push(Point2D::new(
463            self.grid_map.calc_x_position(gx),
464            self.grid_map.calc_y_position(gy),
465        ));
466
467        let mut current = goal_idx;
468        let mut seen: HashMap<i32, ()> = HashMap::new();
469        seen.insert(current, ());
470
471        while current != start_idx {
472            match pred.get(&current).copied() {
473                Some(p) => {
474                    if seen.insert(p, ()).is_none() {
475                        points.push(idx_to_world(p));
476                    } else {
477                        return Err(RoboticsError::PlanningError(
478                            "Cycle detected during path reconstruction".to_string(),
479                        ));
480                    }
481                    current = p;
482                }
483                None => {
484                    return Err(RoboticsError::PlanningError(
485                        "Path reconstruction failed: predecessor missing".to_string(),
486                    ))
487                }
488            }
489        }
490
491        points.reverse();
492        Ok(Path2D::from_points(points))
493    }
494}
495
496impl PathPlanner for LPAStarPlanner {
497    fn plan(&self, start: Point2D, goal: Point2D) -> Result<Path2D, RoboticsError> {
498        self.plan_impl(start, goal)
499    }
500}
501
502// ── Tests ──────────────────────────────────────────────────────────────────────
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507    use crate::a_star::AStarPlanner;
508
509    fn simple_obstacles() -> (Vec<f64>, Vec<f64>) {
510        let mut ox = Vec::new();
511        let mut oy = Vec::new();
512        // Boundary walls
513        for i in 0..=10 {
514            ox.push(i as f64);
515            oy.push(0.0);
516            ox.push(i as f64);
517            oy.push(10.0);
518            ox.push(0.0);
519            oy.push(i as f64);
520            ox.push(10.0);
521            oy.push(i as f64);
522        }
523        // Internal obstacle
524        for i in 4..7 {
525            ox.push(5.0);
526            oy.push(i as f64);
527        }
528        (ox, oy)
529    }
530
531    #[test]
532    fn test_lpa_star_finds_path() {
533        let (ox, oy) = simple_obstacles();
534        let planner = LPAStarPlanner::new(&ox, &oy, LPAStarConfig::default());
535
536        let path = planner.plan(Point2D::new(2.0, 2.0), Point2D::new(8.0, 8.0));
537        assert!(path.is_ok(), "expected a path, got {:?}", path);
538        let path = path.unwrap();
539        assert!(!path.is_empty());
540
541        // Start and end must be close to requested coordinates
542        let first = path.points.first().unwrap();
543        let last = path.points.last().unwrap();
544        assert!((first.x - 2.0).abs() < 1.5 && (first.y - 2.0).abs() < 1.5);
545        assert!((last.x - 8.0).abs() < 1.5 && (last.y - 8.0).abs() < 1.5);
546    }
547
548    #[test]
549    fn test_lpa_star_matches_a_star_path_length() {
550        let (ox, oy) = simple_obstacles();
551        let start = Point2D::new(2.0, 2.0);
552        let goal = Point2D::new(8.0, 8.0);
553
554        let lpa = LPAStarPlanner::new(&ox, &oy, LPAStarConfig::default());
555        let astar = AStarPlanner::from_obstacles(&ox, &oy, 1.0, 0.5);
556
557        let lpa_path = lpa.plan(start, goal).unwrap();
558        let astar_path = astar.plan(start, goal).unwrap();
559
560        let diff = (lpa_path.total_length() - astar_path.total_length()).abs();
561        assert!(
562            diff < 1e-6,
563            "LPA* length {} differs from A* length {} by {}",
564            lpa_path.total_length(),
565            astar_path.total_length(),
566            diff
567        );
568    }
569
570    #[test]
571    fn test_config_defaults() {
572        let cfg = LPAStarConfig::default();
573        assert_eq!(cfg.resolution, 1.0);
574        assert_eq!(cfg.robot_radius, 0.5);
575        assert_eq!(cfg.heuristic_weight, 1.0);
576        assert!(cfg.validate().is_ok());
577
578        let bad = LPAStarConfig {
579            heuristic_weight: 0.0,
580            ..Default::default()
581        };
582        assert!(bad.validate().is_err());
583    }
584
585    #[test]
586    fn test_plan_with_cost_change_replans() {
587        let (ox, oy) = simple_obstacles();
588        let mut planner = LPAStarPlanner::new(&ox, &oy, LPAStarConfig::default());
589
590        let start = Point2D::new(2.0, 2.0);
591        let goal = Point2D::new(8.0, 8.0);
592
593        // First plan
594        let path1 = planner.plan(start, goal).unwrap();
595        assert!(!path1.is_empty());
596
597        // Open up the internal obstacle (make cells traversable)
598        let changes: Vec<EdgeChange> = (4..7)
599            .map(|i| EdgeChange {
600                x: 5.0,
601                y: i as f64,
602                new_cost: 1.0, // finite → not an obstacle
603            })
604            .collect();
605
606        let path2 = planner
607            .plan_with_cost_change(start, goal, &changes)
608            .unwrap();
609        assert!(!path2.is_empty());
610
611        // After opening the wall the path should not be longer
612        assert!(
613            path2.total_length() <= path1.total_length() + 1e-6,
614            "path after opening wall ({}) should not be longer than original ({})",
615            path2.total_length(),
616            path1.total_length()
617        );
618    }
619}