1use 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#[derive(Debug, Clone)]
20pub struct LPAStarConfig {
21 pub resolution: f64,
23 pub robot_radius: f64,
25 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 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#[derive(Debug, Clone)]
70pub struct EdgeChange {
71 pub x: f64,
73 pub y: f64,
75 pub new_cost: f64,
77}
78
79#[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 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 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#[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
139pub struct LPAStarPlanner {
147 grid_map: GridMap,
148 config: LPAStarConfig,
149 motion: Vec<(i32, i32, f64)>,
150}
151
152impl LPAStarPlanner {
153 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 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 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 pub fn plan(&self, start: Point2D, goal: Point2D) -> RoboticsResult<Path2D> {
188 self.plan_impl(start, goal)
189 }
190
191 pub fn plan_with_cost_change(
197 &mut self,
198 start: Point2D,
199 goal: Point2D,
200 changed_edges: &[EdgeChange],
201 ) -> RoboticsResult<Path2D> {
202 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 self.plan_impl(start, goal)
212 }
213
214 pub fn grid_map(&self) -> &GridMap {
216 &self.grid_map
217 }
218
219 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 let mut g: HashMap<i32, f64> = HashMap::new();
263 let mut rhs: HashMap<i32, f64> = HashMap::new();
264 let mut pred: HashMap<i32, i32> = HashMap::new();
266
267 rhs.insert(start_idx, 0.0);
269
270 let mut open: BinaryHeap<QueueEntry> = BinaryHeap::new();
273 let mut in_open: HashMap<i32, Key> = HashMap::new();
276
277 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 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 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 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 let sk = calc_key(&g, &rhs, start_idx, sx, sy);
309 enqueue(&mut open, &mut in_open, start_idx, sk);
310
311 #[allow(clippy::while_let_loop)]
315 loop {
316 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 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 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 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, }
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 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 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 g.insert(u_idx, f64::INFINITY);
376
377 {
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 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 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 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 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(¤t).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#[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 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 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 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 let path1 = planner.plan(start, goal).unwrap();
595 assert!(!path1.is_empty());
596
597 let changes: Vec<EdgeChange> = (4..7)
599 .map(|i| EdgeChange {
600 x: 5.0,
601 y: i as f64,
602 new_cost: 1.0, })
604 .collect();
605
606 let path2 = planner
607 .plan_with_cost_change(start, goal, &changes)
608 .unwrap();
609 assert!(!path2.is_empty());
610
611 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}