Skip to main content

rust_robotics_planning/
bidirectional_a_star.rs

1//! Bidirectional A* path planning algorithm
2//!
3//! Grid-based path planning using bidirectional A* search.
4//! Searches simultaneously from start and goal, terminating when
5//! the two search frontiers meet.
6
7use 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/// Node with priority for bidirectional A* open set (min-heap)
14#[derive(Debug)]
15struct PriorityNode {
16    grid_index: i32,
17    x: i32,
18    y: i32,
19    cost: f64,
20    priority: f64, // f = g + h
21    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        // Reverse ordering for min-heap behavior
35        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/// Configuration for Bidirectional A* planner
49#[derive(Debug, Clone)]
50pub struct BidirectionalAStarConfig {
51    /// Grid resolution in meters
52    pub resolution: f64,
53    /// Robot radius for obstacle inflation
54    pub robot_radius: f64,
55    /// Heuristic weight (1.0 = optimal, >1.0 = faster but suboptimal)
56    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
94/// Bidirectional A* path planner
95pub struct BidirectionalAStarPlanner {
96    grid_map: GridMap,
97    config: BidirectionalAStarConfig,
98    motion: Vec<(i32, i32, f64)>,
99}
100
101impl BidirectionalAStarPlanner {
102    /// Create a new Bidirectional A* planner with obstacle positions
103    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    /// Create a validated Bidirectional A* planner with obstacle positions
110    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    /// Create from obstacle x/y vectors with default config
127    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    /// Create a validated Bidirectional A* planner from typed obstacle points
137    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    /// Plan a path returning (rx, ry) vectors (legacy interface)
153    #[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    /// Plan a path without requiring the PathPlanner trait in scope
162    pub fn plan(&self, start: Point2D, goal: Point2D) -> RoboticsResult<Path2D> {
163        self.plan_impl(start, goal)
164    }
165
166    /// Plan a path from raw coordinates without requiring the PathPlanner trait in scope
167    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    /// Get reference to the grid map
172    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        // dx, dy, cost (8-connected grid)
182        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    /// Find the best known storage index for a grid cell across both open g-values
195    /// and closed set. Returns the index with the lowest g-cost.
196    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    /// Build a path segment by tracing parent pointers from `start_index` back to the root.
233    /// Returns points in order from `start_index` back to root.
234    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        // Forward search (from start)
288        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(); // grid_index -> (g_cost, storage_index)
291        let mut closed_set_a: HashMap<i32, usize> = HashMap::new();
292
293        // Backward search (from goal)
294        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        // Initialize forward start node
300        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        // Initialize backward start node (goal)
314        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            // Peek both heaps to get min f-costs
331            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 either heap is empty, return best meeting or error
335            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            // Termination: both heaps' min f-cost >= best meeting cost
352            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                // Expand forward search
365                let current = open_set_a.pop().unwrap();
366                let c_id_a = current.grid_index;
367
368                // Skip if already closed with equal or better cost
369                if closed_set_a.contains_key(&c_id_a) {
370                    continue;
371                }
372
373                // Skip stale entries (g-value in heap is worse than current best)
374                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                // Check meeting with backward search
386                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                // Expand backward search
461                let current = open_set_b.pop().unwrap();
462                let c_id_b = current.grid_index;
463
464                // Skip if already closed
465                if closed_set_b.contains_key(&c_id_b) {
466                    continue;
467                }
468
469                // Skip stale entries
470                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                // Check meeting with forward search
482                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        // Boundary
579        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        // Internal obstacle
591        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        // Every point on the path should map to a valid (non-obstacle) grid cell
665        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}