1use std::cmp::Ordering;
8use std::collections::{BinaryHeap, HashMap};
9
10use crate::grid::{GridMap, Node};
11use rust_robotics_core::{Obstacles, Path2D, PathPlanner, Point2D, RoboticsError, RoboticsResult};
12
13#[derive(Debug)]
15struct PriorityNode {
16 grid_index: i32,
17 x: i32,
18 y: i32,
19 cost: f64,
20 priority: f64, storage_index: usize,
22}
23
24impl Eq for PriorityNode {}
25
26impl PartialEq for PriorityNode {
27 fn eq(&self, other: &Self) -> bool {
28 self.priority == other.priority
29 }
30}
31
32impl Ord for PriorityNode {
33 fn cmp(&self, other: &Self) -> Ordering {
34 other
36 .priority
37 .partial_cmp(&self.priority)
38 .unwrap_or(Ordering::Equal)
39 }
40}
41
42impl PartialOrd for PriorityNode {
43 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
44 Some(self.cmp(other))
45 }
46}
47
48#[derive(Debug, Clone)]
50pub struct BidirectionalAStarConfig {
51 pub resolution: f64,
53 pub robot_radius: f64,
55 pub heuristic_weight: f64,
57}
58
59impl Default for BidirectionalAStarConfig {
60 fn default() -> Self {
61 Self {
62 resolution: 1.0,
63 robot_radius: 0.5,
64 heuristic_weight: 1.0,
65 }
66 }
67}
68
69impl BidirectionalAStarConfig {
70 pub fn validate(&self) -> RoboticsResult<()> {
71 if !self.resolution.is_finite() || self.resolution <= 0.0 {
72 return Err(RoboticsError::InvalidParameter(format!(
73 "resolution must be positive and finite, got {}",
74 self.resolution
75 )));
76 }
77 if !self.robot_radius.is_finite() || self.robot_radius < 0.0 {
78 return Err(RoboticsError::InvalidParameter(format!(
79 "robot_radius must be non-negative and finite, got {}",
80 self.robot_radius
81 )));
82 }
83 if !self.heuristic_weight.is_finite() || self.heuristic_weight <= 0.0 {
84 return Err(RoboticsError::InvalidParameter(format!(
85 "heuristic_weight must be positive and finite, got {}",
86 self.heuristic_weight
87 )));
88 }
89
90 Ok(())
91 }
92}
93
94pub struct BidirectionalAStarPlanner {
96 grid_map: GridMap,
97 config: BidirectionalAStarConfig,
98 motion: Vec<(i32, i32, f64)>,
99}
100
101impl BidirectionalAStarPlanner {
102 pub fn new(ox: &[f64], oy: &[f64], config: BidirectionalAStarConfig) -> Self {
104 Self::try_new(ox, oy, config).expect(
105 "invalid Bidirectional A* planner input: obstacle list must be non-empty and valid, and config values must be positive/finite",
106 )
107 }
108
109 pub fn try_new(
111 ox: &[f64],
112 oy: &[f64],
113 config: BidirectionalAStarConfig,
114 ) -> RoboticsResult<Self> {
115 config.validate()?;
116 let grid_map = GridMap::try_new(ox, oy, config.resolution, config.robot_radius)?;
117 let motion = Self::get_motion_model();
118
119 Ok(BidirectionalAStarPlanner {
120 grid_map,
121 config,
122 motion,
123 })
124 }
125
126 pub fn from_obstacles(ox: &[f64], oy: &[f64], resolution: f64, robot_radius: f64) -> Self {
128 let config = BidirectionalAStarConfig {
129 resolution,
130 robot_radius,
131 ..Default::default()
132 };
133 Self::new(ox, oy, config)
134 }
135
136 pub fn from_obstacle_points(
138 obstacles: &Obstacles,
139 config: BidirectionalAStarConfig,
140 ) -> RoboticsResult<Self> {
141 config.validate()?;
142 let grid_map = GridMap::from_obstacles(obstacles, config.resolution, config.robot_radius)?;
143 let motion = Self::get_motion_model();
144
145 Ok(BidirectionalAStarPlanner {
146 grid_map,
147 config,
148 motion,
149 })
150 }
151
152 #[deprecated(note = "use plan() or plan_xy() instead")]
154 pub fn planning(&self, sx: f64, sy: f64, gx: f64, gy: f64) -> Option<(Vec<f64>, Vec<f64>)> {
155 match self.plan_xy(sx, sy, gx, gy) {
156 Ok(path) => Some((path.x_coords(), path.y_coords())),
157 Err(_) => None,
158 }
159 }
160
161 pub fn plan(&self, start: Point2D, goal: Point2D) -> RoboticsResult<Path2D> {
163 self.plan_impl(start, goal)
164 }
165
166 pub fn plan_xy(&self, sx: f64, sy: f64, gx: f64, gy: f64) -> RoboticsResult<Path2D> {
168 self.plan_impl(Point2D::new(sx, sy), Point2D::new(gx, gy))
169 }
170
171 pub fn grid_map(&self) -> &GridMap {
173 &self.grid_map
174 }
175
176 fn calc_heuristic(&self, n1_x: i32, n1_y: i32, n2_x: i32, n2_y: i32) -> f64 {
177 self.config.heuristic_weight * (((n1_x - n2_x).pow(2) + (n1_y - n2_y).pow(2)) as f64).sqrt()
178 }
179
180 fn get_motion_model() -> Vec<(i32, i32, f64)> {
181 vec![
183 (1, 0, 1.0),
184 (0, 1, 1.0),
185 (-1, 0, 1.0),
186 (0, -1, 1.0),
187 (-1, -1, std::f64::consts::SQRT_2),
188 (-1, 1, std::f64::consts::SQRT_2),
189 (1, -1, std::f64::consts::SQRT_2),
190 (1, 1, std::f64::consts::SQRT_2),
191 ]
192 }
193
194 fn best_known_index(
197 g_values: &HashMap<i32, (f64, usize)>,
198 closed_set: &HashMap<i32, usize>,
199 node_storage: &[Node],
200 grid_index: i32,
201 ) -> Option<usize> {
202 match (g_values.get(&grid_index), closed_set.get(&grid_index)) {
203 (Some(&(g_cost, open_idx)), Some(&closed_idx)) => {
204 if g_cost <= node_storage[closed_idx].cost {
205 Some(open_idx)
206 } else {
207 Some(closed_idx)
208 }
209 }
210 (Some(&(_, open_idx)), None) => Some(open_idx),
211 (None, Some(&closed_idx)) => Some(closed_idx),
212 (None, None) => None,
213 }
214 }
215
216 fn update_best_meeting(
217 best_meeting: &mut Option<(usize, usize, f64)>,
218 forward_index: usize,
219 forward_cost: f64,
220 backward_index: usize,
221 backward_cost: f64,
222 ) {
223 let total_cost = forward_cost + backward_cost;
224 if best_meeting
225 .as_ref()
226 .map_or(true, |(_, _, best_cost)| total_cost < *best_cost)
227 {
228 *best_meeting = Some((forward_index, backward_index, total_cost));
229 }
230 }
231
232 fn trace_path(&self, start_index: usize, node_storage: &[Node]) -> Vec<Point2D> {
235 let mut points = Vec::new();
236 let mut current = Some(start_index);
237
238 while let Some(idx) = current {
239 let node = &node_storage[idx];
240 points.push(Point2D::new(
241 self.grid_map.calc_x_position(node.x),
242 self.grid_map.calc_y_position(node.y),
243 ));
244 current = node.parent_index;
245 }
246
247 points
248 }
249
250 fn build_meeting_path(
251 &self,
252 forward_index: usize,
253 backward_index: usize,
254 node_storage_a: &[Node],
255 node_storage_b: &[Node],
256 ) -> Path2D {
257 let mut forward_points = self.trace_path(forward_index, node_storage_a);
258 forward_points.reverse();
259
260 let backward_points = self.trace_path(backward_index, node_storage_b);
261
262 let mut all_points = forward_points;
263 all_points.extend(backward_points.into_iter().skip(1));
264 Path2D::from_points(all_points)
265 }
266
267 fn ensure_query_is_valid(&self, x: i32, y: i32, label: &str) -> RoboticsResult<()> {
268 if self.grid_map.is_valid(x, y) {
269 return Ok(());
270 }
271
272 Err(RoboticsError::PlanningError(format!(
273 "{} position is invalid",
274 label
275 )))
276 }
277
278 fn plan_impl(&self, start: Point2D, goal: Point2D) -> RoboticsResult<Path2D> {
279 let start_x = self.grid_map.calc_x_index(start.x);
280 let start_y = self.grid_map.calc_y_index(start.y);
281 let goal_x = self.grid_map.calc_x_index(goal.x);
282 let goal_y = self.grid_map.calc_y_index(goal.y);
283
284 self.ensure_query_is_valid(start_x, start_y, "Start")?;
285 self.ensure_query_is_valid(goal_x, goal_y, "Goal")?;
286
287 let mut node_storage_a: Vec<Node> = Vec::new();
289 let mut open_set_a: BinaryHeap<PriorityNode> = BinaryHeap::new();
290 let mut g_values_a: HashMap<i32, (f64, usize)> = HashMap::new(); let mut closed_set_a: HashMap<i32, usize> = HashMap::new();
292
293 let mut node_storage_b: Vec<Node> = Vec::new();
295 let mut open_set_b: BinaryHeap<PriorityNode> = BinaryHeap::new();
296 let mut g_values_b: HashMap<i32, (f64, usize)> = HashMap::new();
297 let mut closed_set_b: HashMap<i32, usize> = HashMap::new();
298
299 node_storage_a.push(Node::new(start_x, start_y, 0.0, None));
301 let start_grid_index = self.grid_map.calc_index(start_x, start_y);
302 let start_priority = self.calc_heuristic(start_x, start_y, goal_x, goal_y);
303 open_set_a.push(PriorityNode {
304 grid_index: start_grid_index,
305 x: start_x,
306 y: start_y,
307 cost: 0.0,
308 priority: start_priority,
309 storage_index: 0,
310 });
311 g_values_a.insert(start_grid_index, (0.0, 0));
312
313 node_storage_b.push(Node::new(goal_x, goal_y, 0.0, None));
315 let goal_grid_index = self.grid_map.calc_index(goal_x, goal_y);
316 let goal_priority = self.calc_heuristic(goal_x, goal_y, start_x, start_y);
317 open_set_b.push(PriorityNode {
318 grid_index: goal_grid_index,
319 x: goal_x,
320 y: goal_y,
321 cost: 0.0,
322 priority: goal_priority,
323 storage_index: 0,
324 });
325 g_values_b.insert(goal_grid_index, (0.0, 0));
326
327 let mut best_meeting: Option<(usize, usize, f64)> = None;
328
329 loop {
330 let min_f_a = open_set_a.peek().map(|n| n.priority);
332 let min_f_b = open_set_b.peek().map(|n| n.priority);
333
334 if min_f_a.is_none() || min_f_b.is_none() {
336 return best_meeting
337 .map(|(forward_idx, backward_idx, _)| {
338 self.build_meeting_path(
339 forward_idx,
340 backward_idx,
341 &node_storage_a,
342 &node_storage_b,
343 )
344 })
345 .ok_or_else(|| RoboticsError::PlanningError("No path found".to_string()));
346 }
347
348 let min_f_a = min_f_a.unwrap();
349 let min_f_b = min_f_b.unwrap();
350
351 if let Some((forward_idx, backward_idx, best_cost)) = best_meeting {
353 if min_f_a >= best_cost && min_f_b >= best_cost {
354 return Ok(self.build_meeting_path(
355 forward_idx,
356 backward_idx,
357 &node_storage_a,
358 &node_storage_b,
359 ));
360 }
361 }
362
363 if min_f_a <= min_f_b {
364 let current = open_set_a.pop().unwrap();
366 let c_id_a = current.grid_index;
367
368 if closed_set_a.contains_key(&c_id_a) {
370 continue;
371 }
372
373 if let Some(&(best_g, _)) = g_values_a.get(&c_id_a) {
375 if current.cost > best_g {
376 continue;
377 }
378 }
379
380 let current_a_storage_idx = current.storage_index;
381 let current_a_x = current.x;
382 let current_a_y = current.y;
383 let current_a_cost = current.cost;
384
385 if let Some(other_idx) =
387 Self::best_known_index(&g_values_b, &closed_set_b, &node_storage_b, c_id_a)
388 {
389 Self::update_best_meeting(
390 &mut best_meeting,
391 current_a_storage_idx,
392 current_a_cost,
393 other_idx,
394 node_storage_b[other_idx].cost,
395 );
396 }
397
398 closed_set_a.insert(c_id_a, current_a_storage_idx);
399 g_values_a.remove(&c_id_a);
400
401 for &(dx, dy, move_cost) in &self.motion {
402 let new_a_x = current_a_x + dx;
403 let new_a_y = current_a_y + dy;
404 let new_a_grid_index = self.grid_map.calc_index(new_a_x, new_a_y);
405 let new_cost = current_a_cost + move_cost;
406
407 if !self
408 .grid_map
409 .is_valid_offset(current_a_x, current_a_y, dx, dy)
410 {
411 continue;
412 }
413
414 if let Some(&closed_idx) = closed_set_a.get(&new_a_grid_index) {
415 if node_storage_a[closed_idx].cost <= new_cost {
416 continue;
417 }
418 }
419
420 if let Some(&(existing_g, _)) = g_values_a.get(&new_a_grid_index) {
421 if existing_g <= new_cost {
422 continue;
423 }
424 }
425
426 node_storage_a.push(Node::new(
427 new_a_x,
428 new_a_y,
429 new_cost,
430 Some(current_a_storage_idx),
431 ));
432 let new_idx = node_storage_a.len() - 1;
433 let priority = new_cost + self.calc_heuristic(new_a_x, new_a_y, goal_x, goal_y);
434 open_set_a.push(PriorityNode {
435 grid_index: new_a_grid_index,
436 x: new_a_x,
437 y: new_a_y,
438 cost: new_cost,
439 priority,
440 storage_index: new_idx,
441 });
442 g_values_a.insert(new_a_grid_index, (new_cost, new_idx));
443
444 if let Some(other_idx) = Self::best_known_index(
445 &g_values_b,
446 &closed_set_b,
447 &node_storage_b,
448 new_a_grid_index,
449 ) {
450 Self::update_best_meeting(
451 &mut best_meeting,
452 new_idx,
453 node_storage_a[new_idx].cost,
454 other_idx,
455 node_storage_b[other_idx].cost,
456 );
457 }
458 }
459 } else {
460 let current = open_set_b.pop().unwrap();
462 let c_id_b = current.grid_index;
463
464 if closed_set_b.contains_key(&c_id_b) {
466 continue;
467 }
468
469 if let Some(&(best_g, _)) = g_values_b.get(&c_id_b) {
471 if current.cost > best_g {
472 continue;
473 }
474 }
475
476 let current_b_storage_idx = current.storage_index;
477 let current_b_x = current.x;
478 let current_b_y = current.y;
479 let current_b_cost = current.cost;
480
481 if let Some(other_idx) =
483 Self::best_known_index(&g_values_a, &closed_set_a, &node_storage_a, c_id_b)
484 {
485 Self::update_best_meeting(
486 &mut best_meeting,
487 other_idx,
488 node_storage_a[other_idx].cost,
489 current_b_storage_idx,
490 current_b_cost,
491 );
492 }
493
494 closed_set_b.insert(c_id_b, current_b_storage_idx);
495 g_values_b.remove(&c_id_b);
496
497 for &(dx, dy, move_cost) in &self.motion {
498 let new_b_x = current_b_x + dx;
499 let new_b_y = current_b_y + dy;
500 let new_b_grid_index = self.grid_map.calc_index(new_b_x, new_b_y);
501 let new_cost = current_b_cost + move_cost;
502
503 if !self
504 .grid_map
505 .is_valid_offset(current_b_x, current_b_y, dx, dy)
506 {
507 continue;
508 }
509
510 if let Some(&closed_idx) = closed_set_b.get(&new_b_grid_index) {
511 if node_storage_b[closed_idx].cost <= new_cost {
512 continue;
513 }
514 }
515
516 if let Some(&(existing_g, _)) = g_values_b.get(&new_b_grid_index) {
517 if existing_g <= new_cost {
518 continue;
519 }
520 }
521
522 node_storage_b.push(Node::new(
523 new_b_x,
524 new_b_y,
525 new_cost,
526 Some(current_b_storage_idx),
527 ));
528 let new_idx = node_storage_b.len() - 1;
529 let priority =
530 new_cost + self.calc_heuristic(new_b_x, new_b_y, start_x, start_y);
531 open_set_b.push(PriorityNode {
532 grid_index: new_b_grid_index,
533 x: new_b_x,
534 y: new_b_y,
535 cost: new_cost,
536 priority,
537 storage_index: new_idx,
538 });
539 g_values_b.insert(new_b_grid_index, (new_cost, new_idx));
540
541 if let Some(other_idx) = Self::best_known_index(
542 &g_values_a,
543 &closed_set_a,
544 &node_storage_a,
545 new_b_grid_index,
546 ) {
547 Self::update_best_meeting(
548 &mut best_meeting,
549 other_idx,
550 node_storage_a[other_idx].cost,
551 new_idx,
552 node_storage_b[new_idx].cost,
553 );
554 }
555 }
556 }
557 }
558 }
559}
560
561impl PathPlanner for BidirectionalAStarPlanner {
562 fn plan(&self, start: Point2D, goal: Point2D) -> Result<Path2D, RoboticsError> {
563 self.plan_impl(start, goal)
564 }
565}
566
567#[cfg(test)]
568mod tests {
569 use super::*;
570 use crate::moving_ai::{MovingAiMap, MovingAiScenario};
571
572 use rust_robotics_core::Obstacles;
573
574 fn create_simple_obstacles() -> (Vec<f64>, Vec<f64>) {
575 let mut ox = Vec::new();
576 let mut oy = Vec::new();
577
578 for i in 0..11 {
580 ox.push(i as f64);
581 oy.push(0.0);
582 ox.push(i as f64);
583 oy.push(10.0);
584 ox.push(0.0);
585 oy.push(i as f64);
586 ox.push(10.0);
587 oy.push(i as f64);
588 }
589
590 for i in 4..7 {
592 ox.push(5.0);
593 oy.push(i as f64);
594 }
595
596 (ox, oy)
597 }
598
599 #[test]
600 fn test_bidirectional_a_star_finds_path() {
601 let (ox, oy) = create_simple_obstacles();
602 let planner = BidirectionalAStarPlanner::from_obstacles(&ox, &oy, 1.0, 0.5);
603
604 let start = Point2D::new(2.0, 2.0);
605 let goal = Point2D::new(8.0, 8.0);
606
607 let result = planner.plan(start, goal);
608 assert!(result.is_ok());
609
610 let path = result.unwrap();
611 assert!(!path.is_empty());
612 }
613
614 #[test]
615 #[allow(deprecated)]
616 fn test_bidirectional_a_star_legacy_interface() {
617 let (ox, oy) = create_simple_obstacles();
618 let planner = BidirectionalAStarPlanner::from_obstacles(&ox, &oy, 1.0, 0.5);
619
620 let result = planner.planning(2.0, 2.0, 8.0, 8.0);
621 assert!(result.is_some());
622
623 let (rx, ry) = result.unwrap();
624 assert!(!rx.is_empty());
625 assert_eq!(rx.len(), ry.len());
626 }
627
628 #[test]
629 fn test_bidirectional_a_star_from_obstacle_points() {
630 let (ox, oy) = create_simple_obstacles();
631 let obstacles = Obstacles::try_from_xy(&ox, &oy).unwrap();
632 let planner = BidirectionalAStarPlanner::from_obstacle_points(
633 &obstacles,
634 BidirectionalAStarConfig::default(),
635 )
636 .unwrap();
637
638 let path = planner.plan_xy(2.0, 2.0, 8.0, 8.0).unwrap();
639 assert!(!path.is_empty());
640 }
641
642 #[test]
643 fn test_bidirectional_a_star_try_new_rejects_invalid_config() {
644 let (ox, oy) = create_simple_obstacles();
645 let config = BidirectionalAStarConfig {
646 heuristic_weight: 0.0,
647 ..Default::default()
648 };
649
650 let err = match BidirectionalAStarPlanner::try_new(&ox, &oy, config) {
651 Ok(_) => panic!("expected invalid config to be rejected"),
652 Err(err) => err,
653 };
654 assert!(matches!(err, RoboticsError::InvalidParameter(_)));
655 }
656
657 #[test]
658 fn test_bidirectional_a_star_path_is_collision_free() {
659 let (ox, oy) = create_simple_obstacles();
660 let planner = BidirectionalAStarPlanner::from_obstacles(&ox, &oy, 1.0, 0.5);
661
662 let path = planner.plan_xy(2.0, 2.0, 8.0, 8.0).unwrap();
663
664 let grid = planner.grid_map();
666 for pt in &path.points {
667 let gx = grid.calc_x_index(pt.x);
668 let gy = grid.calc_y_index(pt.y);
669 assert!(
670 grid.is_valid(gx, gy),
671 "Path point ({}, {}) maps to invalid grid cell ({}, {})",
672 pt.x,
673 pt.y,
674 gx,
675 gy
676 );
677 }
678 }
679
680 #[test]
681 fn test_bidirectional_a_star_start_and_goal_correct() {
682 let (ox, oy) = create_simple_obstacles();
683 let planner = BidirectionalAStarPlanner::from_obstacles(&ox, &oy, 1.0, 0.5);
684
685 let path = planner.plan_xy(2.0, 2.0, 8.0, 8.0).unwrap();
686
687 let first = path.points.first().unwrap();
688 let last = path.points.last().unwrap();
689 assert!((first.x - 2.0).abs() < 1e-6);
690 assert!((first.y - 2.0).abs() < 1e-6);
691 assert!((last.x - 8.0).abs() < 1e-6);
692 assert!((last.y - 8.0).abs() < 1e-6);
693 }
694
695 #[test]
696 fn test_bidirectional_a_star_preserves_asymmetric_world_coordinates() {
697 let mut obstacles = Obstacles::new();
698
699 for x in 10..=20 {
700 obstacles.push(Point2D::new(x as f64, -4.0));
701 obstacles.push(Point2D::new(x as f64, 6.0));
702 }
703 for y in -4..=6 {
704 obstacles.push(Point2D::new(10.0, y as f64));
705 obstacles.push(Point2D::new(20.0, y as f64));
706 }
707
708 let planner = BidirectionalAStarPlanner::from_obstacle_points(
709 &obstacles,
710 BidirectionalAStarConfig::default(),
711 )
712 .unwrap();
713 let path = planner.plan_xy(12.0, -2.0, 18.0, 4.0).unwrap();
714
715 let first = path.points.first().unwrap();
716 let last = path.points.last().unwrap();
717 assert!((first.x - 12.0).abs() < 1e-6);
718 assert!((first.y + 2.0).abs() < 1e-6);
719 assert!((last.x - 18.0).abs() < 1e-6);
720 assert!((last.y - 4.0).abs() < 1e-6);
721 }
722
723 #[test]
724 #[ignore = "long-running MovingAI benchmark"]
725 fn test_bidirectional_a_star_matches_moving_ai_arena2_bucket_80_optimal_length() {
726 let map = MovingAiMap::parse_str(include_str!("../benchdata/moving_ai/dao/arena2.map"))
727 .expect("arena2 MovingAI map should parse");
728 let scenario =
729 MovingAiScenario::parse_str(include_str!("../benchdata/moving_ai/dao/arena2.map.scen"))
730 .expect("arena2 MovingAI scenarios should parse")
731 .into_iter()
732 .find(|row| row.bucket == 80)
733 .expect("arena2 MovingAI bucket 80 scenario should exist");
734
735 let planner = BidirectionalAStarPlanner::from_obstacle_points(
736 &map.to_obstacles(),
737 BidirectionalAStarConfig {
738 resolution: 1.0,
739 robot_radius: 0.5,
740 heuristic_weight: 1.0,
741 },
742 )
743 .expect("Bidirectional A* planner should build from arena2 obstacles");
744
745 let start = map
746 .planning_point(scenario.start_x, scenario.start_y)
747 .expect("arena2 start should be valid");
748 let goal = map
749 .planning_point(scenario.goal_x, scenario.goal_y)
750 .expect("arena2 goal should be valid");
751
752 let path = planner
753 .plan(start, goal)
754 .expect("Bidirectional A* should solve the arena2 bucket 80 scenario");
755
756 assert!(
757 (path.total_length() - scenario.optimal_length).abs() < 1e-6,
758 "Bidirectional A* path length {} should match MovingAI optimal {}",
759 path.total_length(),
760 scenario.optimal_length
761 );
762 }
763}