Skip to main content

rust_robotics_planning/
hybrid_a_star.rs

1//! Hybrid A* path planning algorithm
2//!
3//! Plans paths for car-like robots with non-holonomic constraints
4//! by combining A* grid search with continuous-space vehicle motion
5//! primitives and Reeds-Shepp analytic expansion.
6
7use std::cmp::Ordering;
8use std::collections::{BinaryHeap, HashMap};
9use std::f64::consts::PI;
10
11use crate::grid::GridMap;
12use crate::reeds_shepp_path;
13use rust_robotics_core::{RoboticsError, RoboticsResult};
14
15/// Configuration for Hybrid A* planner
16#[derive(Debug, Clone)]
17pub struct HybridAStarConfig {
18    /// Grid resolution in meters
19    pub xy_resolution: f64,
20    /// Yaw angle resolution in radians
21    pub yaw_resolution: f64,
22    /// Robot wheelbase (distance between axles)
23    pub wheelbase: f64,
24    /// Maximum steering angle in radians
25    pub max_steer: f64,
26    /// Number of discrete steering inputs
27    pub n_steer: usize,
28    /// Distance traveled per expansion step
29    pub step_size: f64,
30    /// Robot radius for collision checking
31    pub robot_radius: f64,
32    /// Maximum curvature (1 / minimum turning radius)
33    pub max_curvature: f64,
34    /// Cost multiplier for switching direction
35    pub switch_back_cost: f64,
36    /// Cost multiplier for steering angle
37    pub steer_cost: f64,
38    /// Cost multiplier for changing steering
39    pub steer_change_cost: f64,
40    /// Heuristic weight for holonomic-with-obstacles heuristic
41    pub h_cost: f64,
42    /// Interval for attempting Reeds-Shepp analytic expansion
43    pub analytic_expansion_interval: usize,
44}
45
46impl Default for HybridAStarConfig {
47    fn default() -> Self {
48        let wheelbase = 3.0;
49        let max_steer: f64 = 0.6;
50        let max_curvature = (max_steer).tan() / wheelbase;
51        Self {
52            xy_resolution: 2.0,
53            yaw_resolution: PI / 36.0, // 5 degrees
54            wheelbase,
55            max_steer,
56            n_steer: 20,
57            step_size: 1.5,
58            robot_radius: 1.5,
59            max_curvature,
60            switch_back_cost: 100.0,
61            steer_cost: 1.0,
62            steer_change_cost: 5.0,
63            h_cost: 1.0,
64            analytic_expansion_interval: 5,
65        }
66    }
67}
68
69impl HybridAStarConfig {
70    pub fn validate(&self) -> RoboticsResult<()> {
71        if !self.xy_resolution.is_finite() || self.xy_resolution <= 0.0 {
72            return Err(RoboticsError::InvalidParameter(format!(
73                "xy_resolution must be positive and finite, got {}",
74                self.xy_resolution
75            )));
76        }
77        if !self.yaw_resolution.is_finite() || self.yaw_resolution <= 0.0 {
78            return Err(RoboticsError::InvalidParameter(format!(
79                "yaw_resolution must be positive and finite, got {}",
80                self.yaw_resolution
81            )));
82        }
83        if !self.wheelbase.is_finite() || self.wheelbase <= 0.0 {
84            return Err(RoboticsError::InvalidParameter(format!(
85                "wheelbase must be positive and finite, got {}",
86                self.wheelbase
87            )));
88        }
89        if self.n_steer == 0 {
90            return Err(RoboticsError::InvalidParameter(
91                "n_steer must be at least 1".to_string(),
92            ));
93        }
94        Ok(())
95    }
96}
97
98/// A node in the Hybrid A* search tree
99#[derive(Debug, Clone)]
100struct HybridNode {
101    /// Grid x index
102    x_index: i32,
103    /// Grid y index
104    y_index: i32,
105    /// Yaw index (discretized)
106    yaw_index: i32,
107    /// Direction: true = forward, false = backward
108    direction: bool,
109    /// Continuous x positions along this node's arc
110    x_list: Vec<f64>,
111    /// Continuous y positions along this node's arc
112    y_list: Vec<f64>,
113    /// Continuous yaw angles along this node's arc
114    yaw_list: Vec<f64>,
115    /// Direction list (1 = forward, -1 = backward) for each point
116    directions: Vec<i32>,
117    /// Steering angle used
118    steer: f64,
119    /// Accumulated cost
120    cost: f64,
121    /// Parent node index in storage
122    parent_index: Option<usize>,
123}
124
125/// Priority wrapper for BinaryHeap (min-heap)
126#[derive(Debug)]
127struct PriorityNode {
128    index: usize,
129    cost: f64,
130}
131
132impl Eq for PriorityNode {}
133
134impl PartialEq for PriorityNode {
135    fn eq(&self, other: &Self) -> bool {
136        self.cost == other.cost
137    }
138}
139
140impl Ord for PriorityNode {
141    fn cmp(&self, other: &Self) -> Ordering {
142        other
143            .cost
144            .partial_cmp(&self.cost)
145            .unwrap_or(Ordering::Equal)
146    }
147}
148
149impl PartialOrd for PriorityNode {
150    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
151        Some(self.cmp(other))
152    }
153}
154
155/// Result of Hybrid A* planning
156#[derive(Debug, Clone)]
157pub struct HybridAStarPath {
158    /// X coordinates along the path
159    pub x: Vec<f64>,
160    /// Y coordinates along the path
161    pub y: Vec<f64>,
162    /// Yaw angles along the path
163    pub yaw: Vec<f64>,
164    /// Direction at each point (1 = forward, -1 = backward)
165    pub directions: Vec<i32>,
166}
167
168impl HybridAStarPath {
169    pub fn is_empty(&self) -> bool {
170        self.x.is_empty()
171    }
172
173    pub fn len(&self) -> usize {
174        self.x.len()
175    }
176}
177
178/// Hybrid A* path planner
179pub struct HybridAStarPlanner {
180    config: HybridAStarConfig,
181    grid_map: GridMap,
182    /// Precomputed holonomic heuristic (cost from each grid cell to goal)
183    h_map: Vec<Vec<f64>>,
184    goal_x: i32,
185    goal_y: i32,
186}
187
188impl HybridAStarPlanner {
189    /// Create a new planner from obstacle coordinates
190    pub fn new(ox: &[f64], oy: &[f64], config: HybridAStarConfig) -> RoboticsResult<Self> {
191        config.validate()?;
192        let grid_map = GridMap::try_new(ox, oy, config.xy_resolution, config.robot_radius)?;
193
194        Ok(Self {
195            config,
196            grid_map,
197            h_map: Vec::new(),
198            goal_x: 0,
199            goal_y: 0,
200        })
201    }
202
203    /// Plan a path from start pose to goal pose
204    pub fn plan(
205        &mut self,
206        sx: f64,
207        sy: f64,
208        syaw: f64,
209        gx: f64,
210        gy: f64,
211        gyaw: f64,
212    ) -> RoboticsResult<HybridAStarPath> {
213        // Compute grid indices for goal
214        self.goal_x = self.grid_map.calc_x_index(gx);
215        self.goal_y = self.grid_map.calc_y_index(gy);
216
217        // Compute holonomic heuristic map (Dijkstra from goal)
218        self.h_map = self.compute_holonomic_heuristic();
219
220        let start_yaw_index = self.calc_yaw_index(syaw);
221        let start_node = HybridNode {
222            x_index: self.grid_map.calc_x_index(sx),
223            y_index: self.grid_map.calc_y_index(sy),
224            yaw_index: start_yaw_index,
225            direction: true,
226            x_list: vec![sx],
227            y_list: vec![sy],
228            yaw_list: vec![syaw],
229            directions: vec![1],
230            steer: 0.0,
231            cost: 0.0,
232            parent_index: None,
233        };
234
235        let goal_yaw_index = self.calc_yaw_index(gyaw);
236        let goal_node = HybridNode {
237            x_index: self.goal_x,
238            y_index: self.goal_y,
239            yaw_index: goal_yaw_index,
240            direction: true,
241            x_list: vec![gx],
242            y_list: vec![gy],
243            yaw_list: vec![gyaw],
244            directions: vec![1],
245            steer: 0.0,
246            cost: 0.0,
247            parent_index: None,
248        };
249
250        let mut open_set = BinaryHeap::new();
251        let mut closed_set: HashMap<i64, usize> = HashMap::new();
252        let mut node_storage: Vec<HybridNode> = Vec::new();
253
254        node_storage.push(start_node.clone());
255        let start_idx = 0;
256        let start_key = self.calc_hybrid_index(&node_storage[start_idx]);
257
258        let h = self.calc_heuristic(&node_storage[start_idx]);
259        open_set.push(PriorityNode {
260            index: start_idx,
261            cost: node_storage[start_idx].cost + h,
262        });
263
264        let mut open_map: HashMap<i64, usize> = HashMap::new();
265        open_map.insert(start_key, start_idx);
266
267        let mut iteration = 0;
268
269        while let Some(current_priority) = open_set.pop() {
270            let current_idx = current_priority.index;
271            let current_key = self.calc_hybrid_index(&node_storage[current_idx]);
272
273            if closed_set.contains_key(&current_key) {
274                continue;
275            }
276
277            closed_set.insert(current_key, current_idx);
278            open_map.remove(&current_key);
279
280            // Check goal reached
281            if self.is_goal(&node_storage[current_idx], &goal_node) {
282                return Ok(self.build_path(current_idx, &node_storage, None));
283            }
284
285            // Try analytic expansion (Reeds-Shepp)
286            iteration += 1;
287            if iteration % self.config.analytic_expansion_interval == 0 {
288                if let Some(rs) = self.analytic_expansion(&node_storage[current_idx], &goal_node) {
289                    return Ok(self.build_path(current_idx, &node_storage, Some(&rs)));
290                }
291            }
292
293            // Expand neighbors with motion primitives
294            let neighbors = self.get_neighbors(&node_storage[current_idx], current_idx);
295            for neighbor in neighbors {
296                let neighbor_key = self.calc_hybrid_index(&neighbor);
297
298                if closed_set.contains_key(&neighbor_key) {
299                    continue;
300                }
301
302                if let Some(&existing_idx) = open_map.get(&neighbor_key) {
303                    if neighbor.cost < node_storage[existing_idx].cost {
304                        node_storage.push(neighbor);
305                        let new_idx = node_storage.len() - 1;
306                        open_map.insert(neighbor_key, new_idx);
307                        let h = self.calc_heuristic(&node_storage[new_idx]);
308                        open_set.push(PriorityNode {
309                            index: new_idx,
310                            cost: node_storage[new_idx].cost + h,
311                        });
312                    }
313                } else {
314                    node_storage.push(neighbor);
315                    let new_idx = node_storage.len() - 1;
316                    open_map.insert(neighbor_key, new_idx);
317                    let h = self.calc_heuristic(&node_storage[new_idx]);
318                    open_set.push(PriorityNode {
319                        index: new_idx,
320                        cost: node_storage[new_idx].cost + h,
321                    });
322                }
323            }
324        }
325
326        Err(RoboticsError::PlanningError(
327            "Hybrid A*: no path found".to_string(),
328        ))
329    }
330
331    /// Discretize yaw to grid index
332    fn calc_yaw_index(&self, yaw: f64) -> i32 {
333        let normalized = pi_2_pi(yaw);
334        ((normalized + PI) / self.config.yaw_resolution).round() as i32
335    }
336
337    /// Compute a unique index for the hybrid state (x, y, yaw)
338    fn calc_hybrid_index(&self, node: &HybridNode) -> i64 {
339        let n_yaw = ((2.0 * PI) / self.config.yaw_resolution).round() as i64 + 1;
340        (node.yaw_index as i64)
341            + n_yaw * (node.y_index as i64)
342            + n_yaw * (self.grid_map.y_width as i64) * (node.x_index as i64)
343    }
344
345    /// Check if node is at goal within grid tolerance
346    fn is_goal(&self, node: &HybridNode, goal: &HybridNode) -> bool {
347        node.x_index == goal.x_index
348            && node.y_index == goal.y_index
349            && node.yaw_index == goal.yaw_index
350    }
351
352    /// Get heuristic cost from holonomic A* map
353    fn calc_heuristic(&self, node: &HybridNode) -> f64 {
354        let ix = node.x_index;
355        let iy = node.y_index;
356
357        if ix >= 0 && ix < self.grid_map.x_width && iy >= 0 && iy < self.grid_map.y_width {
358            self.h_map[ix as usize][iy as usize] * self.config.h_cost
359        } else {
360            // Fallback: Euclidean distance
361            let dx = (node.x_index - self.goal_x) as f64;
362            let dy = (node.y_index - self.goal_y) as f64;
363            (dx * dx + dy * dy).sqrt() * self.config.h_cost
364        }
365    }
366
367    /// Generate neighbor nodes using bicycle model motion primitives
368    fn get_neighbors(&self, current: &HybridNode, parent_idx: usize) -> Vec<HybridNode> {
369        let mut neighbors = Vec::new();
370
371        let n_steer = self.config.n_steer as f64;
372        let steer_step = 2.0 * self.config.max_steer / n_steer;
373
374        // For each steering angle
375        for i in 0..=self.config.n_steer {
376            let steer = -self.config.max_steer + i as f64 * steer_step;
377
378            // Forward and backward
379            for &direction in &[1.0_f64, -1.0] {
380                let current_x = *current.x_list.last().unwrap();
381                let current_y = *current.y_list.last().unwrap();
382                let current_yaw = *current.yaw_list.last().unwrap();
383
384                if let Some(node) = self.simulate_motion(
385                    current_x,
386                    current_y,
387                    current_yaw,
388                    steer,
389                    direction,
390                    current,
391                    parent_idx,
392                ) {
393                    neighbors.push(node);
394                }
395            }
396        }
397
398        neighbors
399    }
400
401    /// Simulate bicycle model forward by one step
402    #[allow(clippy::too_many_arguments)]
403    fn simulate_motion(
404        &self,
405        x: f64,
406        y: f64,
407        yaw: f64,
408        steer: f64,
409        direction: f64,
410        parent: &HybridNode,
411        parent_idx: usize,
412    ) -> Option<HybridNode> {
413        let arc_length = self.config.xy_resolution * 1.5;
414        let n_steps = (arc_length / self.config.step_size).ceil() as usize;
415        let d = direction * self.config.step_size;
416
417        let mut cx = x;
418        let mut cy = y;
419        let mut cyaw = yaw;
420
421        let mut x_list = vec![cx];
422        let mut y_list = vec![cy];
423        let mut yaw_list = vec![cyaw];
424
425        for _ in 0..n_steps {
426            cx += d * cyaw.cos();
427            cy += d * cyaw.sin();
428            cyaw += d * steer.tan() / self.config.wheelbase;
429            cyaw = pi_2_pi(cyaw);
430
431            x_list.push(cx);
432            y_list.push(cy);
433            yaw_list.push(cyaw);
434        }
435
436        // Collision check
437        if !self.verify_path(&x_list, &y_list) {
438            return None;
439        }
440
441        let dir_val = if direction > 0.0 { 1 } else { -1 };
442        let directions = vec![dir_val; x_list.len()];
443
444        // Compute cost
445        let mut cost = parent.cost;
446
447        // Distance cost
448        cost += arc_length;
449
450        // Steering cost
451        cost += self.config.steer_cost * steer.abs();
452
453        // Steering change cost
454        cost += self.config.steer_change_cost * (steer - parent.steer).abs();
455
456        // Direction switch cost
457        let parent_dir = if parent.direction { 1.0 } else { -1.0 };
458        if direction != parent_dir {
459            cost += self.config.switch_back_cost;
460        }
461
462        let x_idx = self.grid_map.calc_x_index(cx);
463        let y_idx = self.grid_map.calc_y_index(cy);
464        let yaw_idx = self.calc_yaw_index(cyaw);
465
466        Some(HybridNode {
467            x_index: x_idx,
468            y_index: y_idx,
469            yaw_index: yaw_idx,
470            direction: direction > 0.0,
471            x_list,
472            y_list,
473            yaw_list,
474            directions,
475            steer,
476            cost,
477            parent_index: Some(parent_idx),
478        })
479    }
480
481    /// Verify that all points in a path are collision-free
482    fn verify_path(&self, x_list: &[f64], y_list: &[f64]) -> bool {
483        for (&x, &y) in x_list.iter().zip(y_list.iter()) {
484            let ix = self.grid_map.calc_x_index(x);
485            let iy = self.grid_map.calc_y_index(y);
486            if !self.grid_map.is_valid(ix, iy) {
487                return false;
488            }
489        }
490        true
491    }
492
493    /// Attempt analytic expansion using Reeds-Shepp path
494    fn analytic_expansion(&self, current: &HybridNode, goal: &HybridNode) -> Option<HybridNode> {
495        let sx = *current.x_list.last().unwrap();
496        let sy = *current.y_list.last().unwrap();
497        let syaw = *current.yaw_list.last().unwrap();
498
499        let gx = *goal.x_list.last().unwrap();
500        let gy = *goal.y_list.last().unwrap();
501        let gyaw = *goal.yaw_list.last().unwrap();
502
503        let result = reeds_shepp_path::reeds_shepp_path_planning(
504            sx,
505            sy,
506            syaw,
507            gx,
508            gy,
509            gyaw,
510            self.config.max_curvature,
511            self.config.step_size,
512        );
513
514        let (path_x, path_y, path_yaw, _ctypes, _lengths) = result?;
515
516        if path_x.is_empty() {
517            return None;
518        }
519
520        // Collision check along the Reeds-Shepp path
521        if !self.verify_path(&path_x, &path_y) {
522            return None;
523        }
524
525        // Build directions from consecutive positions
526        let directions: Vec<i32> = path_yaw
527            .windows(2)
528            .map(|w| {
529                let diff = pi_2_pi(w[1] - w[0]);
530                if diff < -1e-6 {
531                    -1
532                } else {
533                    1
534                }
535            })
536            .chain(std::iter::once(1))
537            .collect();
538
539        let last_x = *path_x.last().unwrap();
540        let last_y = *path_y.last().unwrap();
541        let last_yaw = *path_yaw.last().unwrap();
542
543        Some(HybridNode {
544            x_index: self.grid_map.calc_x_index(last_x),
545            y_index: self.grid_map.calc_y_index(last_y),
546            yaw_index: self.calc_yaw_index(last_yaw),
547            direction: true,
548            x_list: path_x,
549            y_list: path_y,
550            yaw_list: path_yaw,
551            directions,
552            steer: 0.0,
553            cost: 0.0,
554            parent_index: None,
555        })
556    }
557
558    /// Build the final path by tracing back from goal to start
559    fn build_path(
560        &self,
561        final_idx: usize,
562        storage: &[HybridNode],
563        rs_segment: Option<&HybridNode>,
564    ) -> HybridAStarPath {
565        #[allow(clippy::type_complexity)]
566        let mut segments: Vec<(Vec<f64>, Vec<f64>, Vec<f64>, Vec<i32>)> = Vec::new();
567
568        let mut current_idx = Some(final_idx);
569        while let Some(idx) = current_idx {
570            let node = &storage[idx];
571            segments.push((
572                node.x_list.clone(),
573                node.y_list.clone(),
574                node.yaw_list.clone(),
575                node.directions.clone(),
576            ));
577            current_idx = node.parent_index;
578        }
579
580        segments.reverse();
581
582        let mut x = Vec::new();
583        let mut y = Vec::new();
584        let mut yaw = Vec::new();
585        let mut directions = Vec::new();
586
587        for (sx, sy, syaw, sd) in &segments {
588            x.extend_from_slice(sx);
589            y.extend_from_slice(sy);
590            yaw.extend_from_slice(syaw);
591            directions.extend_from_slice(sd);
592        }
593
594        // Append Reeds-Shepp segment if present
595        if let Some(rs) = rs_segment {
596            x.extend_from_slice(&rs.x_list);
597            y.extend_from_slice(&rs.y_list);
598            yaw.extend_from_slice(&rs.yaw_list);
599            directions.extend_from_slice(&rs.directions);
600        }
601
602        HybridAStarPath {
603            x,
604            y,
605            yaw,
606            directions,
607        }
608    }
609
610    /// Compute holonomic heuristic using Dijkstra from goal on the grid
611    fn compute_holonomic_heuristic(&self) -> Vec<Vec<f64>> {
612        let xw = self.grid_map.x_width as usize;
613        let yw = self.grid_map.y_width as usize;
614
615        let mut cost_map = vec![vec![f64::INFINITY; yw]; xw];
616
617        let gx = self.goal_x;
618        let gy = self.goal_y;
619
620        if gx < 0 || gx >= self.grid_map.x_width || gy < 0 || gy >= self.grid_map.y_width {
621            return cost_map;
622        }
623
624        cost_map[gx as usize][gy as usize] = 0.0;
625
626        let mut open_set = BinaryHeap::new();
627        open_set.push(DijkstraNode {
628            x: gx,
629            y: gy,
630            cost: 0.0,
631        });
632
633        let motions: [(i32, i32, f64); 8] = [
634            (1, 0, 1.0),
635            (0, 1, 1.0),
636            (-1, 0, 1.0),
637            (0, -1, 1.0),
638            (-1, -1, std::f64::consts::SQRT_2),
639            (-1, 1, std::f64::consts::SQRT_2),
640            (1, -1, std::f64::consts::SQRT_2),
641            (1, 1, std::f64::consts::SQRT_2),
642        ];
643
644        while let Some(current) = open_set.pop() {
645            if current.cost > cost_map[current.x as usize][current.y as usize] {
646                continue;
647            }
648
649            for &(dx, dy, mc) in &motions {
650                let nx = current.x + dx;
651                let ny = current.y + dy;
652
653                if nx < 0 || nx >= self.grid_map.x_width || ny < 0 || ny >= self.grid_map.y_width {
654                    continue;
655                }
656
657                if self.grid_map.obstacle_map[nx as usize][ny as usize] {
658                    continue;
659                }
660
661                let new_cost = current.cost + mc;
662                if new_cost < cost_map[nx as usize][ny as usize] {
663                    cost_map[nx as usize][ny as usize] = new_cost;
664                    open_set.push(DijkstraNode {
665                        x: nx,
666                        y: ny,
667                        cost: new_cost,
668                    });
669                }
670            }
671        }
672
673        cost_map
674    }
675}
676
677/// Dijkstra node for holonomic heuristic computation
678#[derive(Debug)]
679struct DijkstraNode {
680    x: i32,
681    y: i32,
682    cost: f64,
683}
684
685impl Eq for DijkstraNode {}
686
687impl PartialEq for DijkstraNode {
688    fn eq(&self, other: &Self) -> bool {
689        self.cost == other.cost
690    }
691}
692
693impl Ord for DijkstraNode {
694    fn cmp(&self, other: &Self) -> Ordering {
695        other
696            .cost
697            .partial_cmp(&self.cost)
698            .unwrap_or(Ordering::Equal)
699    }
700}
701
702impl PartialOrd for DijkstraNode {
703    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
704        Some(self.cmp(other))
705    }
706}
707
708/// Normalize angle to [-pi, pi]
709fn pi_2_pi(angle: f64) -> f64 {
710    let mut a = angle;
711    while a > PI {
712        a -= 2.0 * PI;
713    }
714    while a < -PI {
715        a += 2.0 * PI;
716    }
717    a
718}
719
720#[cfg(test)]
721mod tests {
722    use super::*;
723
724    /// Create a rectangular boundary with obstacles
725    fn create_boundary_obstacles(
726        x_min: f64,
727        x_max: f64,
728        y_min: f64,
729        y_max: f64,
730        step: f64,
731    ) -> (Vec<f64>, Vec<f64>) {
732        let mut ox = Vec::new();
733        let mut oy = Vec::new();
734
735        let mut x = x_min;
736        while x <= x_max {
737            ox.push(x);
738            oy.push(y_min);
739            ox.push(x);
740            oy.push(y_max);
741            x += step;
742        }
743
744        let mut y = y_min;
745        while y <= y_max {
746            ox.push(x_min);
747            oy.push(y);
748            ox.push(x_max);
749            oy.push(y);
750            y += step;
751        }
752
753        (ox, oy)
754    }
755
756    #[test]
757    fn test_open_space_path() {
758        let (ox, oy) = create_boundary_obstacles(0.0, 50.0, 0.0, 50.0, 1.0);
759
760        let config = HybridAStarConfig {
761            xy_resolution: 2.0,
762            yaw_resolution: PI / 18.0,
763            robot_radius: 1.0,
764            ..Default::default()
765        };
766
767        let mut planner = HybridAStarPlanner::new(&ox, &oy, config).unwrap();
768
769        let result = planner.plan(10.0, 10.0, 0.0, 40.0, 40.0, 0.0);
770        assert!(result.is_ok(), "Planning failed: {:?}", result.err());
771
772        let path = result.unwrap();
773        assert!(!path.is_empty());
774        assert_eq!(path.x.len(), path.y.len());
775        assert_eq!(path.x.len(), path.yaw.len());
776
777        // Check path starts near start
778        let start_dist = ((path.x[0] - 10.0).powi(2) + (path.y[0] - 10.0).powi(2)).sqrt();
779        assert!(
780            start_dist < 5.0,
781            "Path start too far from start: {}",
782            start_dist
783        );
784
785        let last = path.x.len() - 1;
786        let goal_dist = ((path.x[last] - 40.0).powi(2) + (path.y[last] - 40.0).powi(2)).sqrt();
787        assert!(
788            goal_dist < 10.0,
789            "Path end too far from goal: {}",
790            goal_dist
791        );
792    }
793
794    #[test]
795    fn test_path_around_obstacle() {
796        let (mut ox, mut oy) = create_boundary_obstacles(0.0, 50.0, 0.0, 50.0, 1.0);
797
798        // Add a wall in the middle
799        let mut y = 10.0;
800        while y <= 40.0 {
801            ox.push(25.0);
802            oy.push(y);
803            y += 1.0;
804        }
805
806        let config = HybridAStarConfig {
807            xy_resolution: 2.0,
808            yaw_resolution: PI / 18.0,
809            robot_radius: 1.0,
810            ..Default::default()
811        };
812
813        let mut planner = HybridAStarPlanner::new(&ox, &oy, config).unwrap();
814
815        let result = planner.plan(10.0, 25.0, 0.0, 40.0, 25.0, 0.0);
816        assert!(
817            result.is_ok(),
818            "Planning with obstacle failed: {:?}",
819            result.err()
820        );
821
822        let path = result.unwrap();
823        assert!(!path.is_empty());
824        assert!(path.len() > 5, "Path should have multiple waypoints");
825    }
826
827    #[test]
828    fn test_goal_tolerance() {
829        let (ox, oy) = create_boundary_obstacles(0.0, 50.0, 0.0, 50.0, 1.0);
830
831        let config = HybridAStarConfig {
832            xy_resolution: 2.0,
833            yaw_resolution: PI / 18.0,
834            robot_radius: 1.0,
835            ..Default::default()
836        };
837
838        let mut planner = HybridAStarPlanner::new(&ox, &oy, config).unwrap();
839
840        let result = planner.plan(10.0, 10.0, 0.0, 35.0, 35.0, PI / 2.0);
841        assert!(result.is_ok(), "Planning failed: {:?}", result.err());
842
843        let path = result.unwrap();
844        let last = path.x.len() - 1;
845        let goal_dist = ((path.x[last] - 35.0).powi(2) + (path.y[last] - 35.0).powi(2)).sqrt();
846        assert!(
847            goal_dist < 15.0,
848            "Path end too far from goal: dist={}",
849            goal_dist
850        );
851    }
852
853    #[test]
854    fn test_invalid_config() {
855        let (ox, oy) = create_boundary_obstacles(0.0, 50.0, 0.0, 50.0, 1.0);
856
857        let config = HybridAStarConfig {
858            xy_resolution: 0.0,
859            ..Default::default()
860        };
861
862        let result = HybridAStarPlanner::new(&ox, &oy, config);
863        assert!(result.is_err());
864    }
865
866    #[test]
867    fn test_path_result_consistency() {
868        let (ox, oy) = create_boundary_obstacles(0.0, 50.0, 0.0, 50.0, 1.0);
869
870        let config = HybridAStarConfig {
871            xy_resolution: 2.0,
872            yaw_resolution: PI / 18.0,
873            robot_radius: 1.0,
874            ..Default::default()
875        };
876
877        let mut planner = HybridAStarPlanner::new(&ox, &oy, config).unwrap();
878
879        let result = planner.plan(10.0, 10.0, 0.0, 30.0, 30.0, 0.0);
880        assert!(result.is_ok());
881
882        let path = result.unwrap();
883        // All vectors should have same length
884        assert_eq!(path.x.len(), path.y.len());
885        assert_eq!(path.x.len(), path.yaw.len());
886        assert_eq!(path.x.len(), path.directions.len());
887
888        // All yaw values should be in [-pi, pi]
889        for &yaw in &path.yaw {
890            assert!(
891                (-PI - 0.01..=PI + 0.01).contains(&yaw),
892                "Yaw out of range: {}",
893                yaw
894            );
895        }
896
897        // All directions should be 1 or -1
898        for &d in &path.directions {
899            assert!(d == 1 || d == -1, "Invalid direction: {}", d);
900        }
901    }
902}