Skip to main content

rust_robotics_planning/
traversal_risk_graph.rs

1//! Traversal-risk graph planning over a 2-D grid.
2//!
3//! This is a compact pure-Rust planner inspired by traversal-risk graph
4//! planners for unstructured terrain.  Each cell has independent risk channels
5//! and the planner searches for the minimum distance-plus-risk path.
6
7use std::cmp::Ordering;
8use std::collections::{BinaryHeap, HashMap};
9
10use rust_robotics_core::{RoboticsError, RoboticsResult};
11
12/// Risk attributes for one terrain cell.
13#[derive(Debug, Clone, Copy, PartialEq)]
14pub struct TerrainRiskCell {
15    pub blocked: bool,
16    pub traversability_risk: f64,
17    pub stability_risk: f64,
18    pub exposure_risk: f64,
19}
20
21impl TerrainRiskCell {
22    pub fn free() -> Self {
23        Self {
24            blocked: false,
25            traversability_risk: 0.0,
26            stability_risk: 0.0,
27            exposure_risk: 0.0,
28        }
29    }
30
31    pub fn blocked() -> Self {
32        Self {
33            blocked: true,
34            ..Self::free()
35        }
36    }
37
38    pub fn with_risk(traversability_risk: f64, stability_risk: f64, exposure_risk: f64) -> Self {
39        Self {
40            blocked: false,
41            traversability_risk,
42            stability_risk,
43            exposure_risk,
44        }
45    }
46}
47
48/// Converts an elevation grid into traversal-risk cells.
49///
50/// Elevation storage is indexed as `elevation[x][y]`, matching
51/// [`TraversalRiskGraphConfig::cells`].
52#[derive(Debug, Clone, Copy, PartialEq)]
53pub struct ElevationRiskConfig {
54    pub cell_size: f64,
55    pub slope_risk_scale: f64,
56    pub roughness_risk_scale: f64,
57    pub max_risk: f64,
58    pub blocking_step_height: Option<f64>,
59}
60
61impl ElevationRiskConfig {
62    pub fn new(cell_size: f64) -> Self {
63        Self {
64            cell_size,
65            ..Self::default()
66        }
67    }
68}
69
70impl Default for ElevationRiskConfig {
71    fn default() -> Self {
72        Self {
73            cell_size: 1.0,
74            slope_risk_scale: 8.0,
75            roughness_risk_scale: 10.0,
76            max_risk: 10.0,
77            blocking_step_height: None,
78        }
79    }
80}
81
82/// Gaussian-style local smoothing for terrain-risk channels.
83#[derive(Debug, Clone, Copy, PartialEq)]
84pub struct RiskMapSmoothingConfig {
85    pub radius_cells: i32,
86    pub iterations: usize,
87    pub sigma_cells: f64,
88    pub smooth_blocked_cells: bool,
89}
90
91impl RiskMapSmoothingConfig {
92    pub fn new(radius_cells: i32) -> Self {
93        Self {
94            radius_cells,
95            ..Self::default()
96        }
97    }
98}
99
100impl Default for RiskMapSmoothingConfig {
101    fn default() -> Self {
102        Self {
103            radius_cells: 1,
104            iterations: 1,
105            sigma_cells: 1.0,
106            smooth_blocked_cells: false,
107        }
108    }
109}
110
111/// Converts clearance from blocked cells into exposure risk.
112#[derive(Debug, Clone, Copy, PartialEq)]
113pub struct ClearanceRiskConfig {
114    pub cell_size: f64,
115    pub minimum_clearance: f64,
116    pub risk_scale: f64,
117    pub max_risk: f64,
118    pub additive: bool,
119}
120
121impl ClearanceRiskConfig {
122    pub fn new(cell_size: f64, minimum_clearance: f64) -> Self {
123        Self {
124            cell_size,
125            minimum_clearance,
126            ..Self::default()
127        }
128    }
129}
130
131impl Default for ClearanceRiskConfig {
132    fn default() -> Self {
133        Self {
134            cell_size: 1.0,
135            minimum_clearance: 1.0,
136            risk_scale: 8.0,
137            max_risk: 8.0,
138            additive: true,
139        }
140    }
141}
142
143/// Build traversal-risk cells from an elevation grid.
144///
145/// The traversability channel receives local slope risk. The stability channel
146/// receives local roughness risk from the largest neighboring height step.
147/// Cells whose local step exceeds `blocking_step_height`, when configured, are
148/// marked blocked.
149pub fn terrain_risk_from_elevation_map(
150    elevation: &[Vec<f64>],
151    config: &ElevationRiskConfig,
152) -> RoboticsResult<Vec<Vec<TerrainRiskCell>>> {
153    let (width, height) = validate_elevation_map(elevation)?;
154    validate_elevation_config(config)?;
155
156    let mut cells = vec![vec![TerrainRiskCell::free(); height]; width];
157    // x and y feed the gradient/roughness helpers and index `cells`; an
158    // enumerate rewrite would not read more clearly than the grid sweep.
159    #[allow(clippy::needless_range_loop)]
160    for x in 0..width {
161        for y in 0..height {
162            let dzdx = elevation_gradient(elevation, x, y, 0, config.cell_size);
163            let dzdy = elevation_gradient(elevation, x, y, 1, config.cell_size);
164            let slope = (dzdx * dzdx + dzdy * dzdy).sqrt();
165            let roughness = local_roughness(elevation, x, y);
166            let traversability_risk = (slope * config.slope_risk_scale).min(config.max_risk);
167            let stability_risk = (roughness * config.roughness_risk_scale).min(config.max_risk);
168            let blocked = config
169                .blocking_step_height
170                .is_some_and(|threshold| roughness >= threshold);
171
172            cells[x][y] = TerrainRiskCell {
173                blocked,
174                traversability_risk,
175                stability_risk,
176                exposure_risk: 0.0,
177            };
178        }
179    }
180
181    Ok(cells)
182}
183
184/// Smooth terrain-risk channels while preserving blocked-cell topology.
185///
186/// Blocked cells remain blocked. When `smooth_blocked_cells` is false, their
187/// risk values are left untouched, but they still contribute to neighboring
188/// risk smoothing.
189pub fn smooth_terrain_risk(
190    cells: &[Vec<TerrainRiskCell>],
191    config: &RiskMapSmoothingConfig,
192) -> RoboticsResult<Vec<Vec<TerrainRiskCell>>> {
193    let (width, height) = validate_cell_grid(cells)?;
194    validate_smoothing_config(config)?;
195    if config.iterations == 0 || config.radius_cells == 0 {
196        return Ok(cells.to_vec());
197    }
198
199    let mut current = cells.to_vec();
200    let radius = config.radius_cells;
201    let radius_sq = radius * radius;
202    let sigma_sq = config.sigma_cells * config.sigma_cells;
203
204    for _ in 0..config.iterations {
205        let mut next = current.clone();
206        for x in 0..width {
207            for y in 0..height {
208                if current[x][y].blocked && !config.smooth_blocked_cells {
209                    continue;
210                }
211
212                let mut weight_sum = 0.0;
213                let mut traversability_sum = 0.0;
214                let mut stability_sum = 0.0;
215                let mut exposure_sum = 0.0;
216
217                for dx in -radius..=radius {
218                    for dy in -radius..=radius {
219                        let distance_sq = dx * dx + dy * dy;
220                        if distance_sq > radius_sq {
221                            continue;
222                        }
223                        let nx = x as i32 + dx;
224                        let ny = y as i32 + dy;
225                        if nx < 0 || ny < 0 || nx >= width as i32 || ny >= height as i32 {
226                            continue;
227                        }
228
229                        let neighbor = current[nx as usize][ny as usize];
230                        let weight = (-(distance_sq as f64) / (2.0 * sigma_sq)).exp();
231                        weight_sum += weight;
232                        traversability_sum += weight * neighbor.traversability_risk;
233                        stability_sum += weight * neighbor.stability_risk;
234                        exposure_sum += weight * neighbor.exposure_risk;
235                    }
236                }
237
238                next[x][y] = TerrainRiskCell {
239                    blocked: current[x][y].blocked,
240                    traversability_risk: traversability_sum / weight_sum,
241                    stability_risk: stability_sum / weight_sum,
242                    exposure_risk: exposure_sum / weight_sum,
243                };
244            }
245        }
246        current = next;
247    }
248
249    Ok(current)
250}
251
252/// Compute Euclidean clearance from every cell to the nearest blocked cell.
253///
254/// Distances are returned in metric units using `cell_size`. If the grid has no
255/// blocked cells, every clearance value is `f64::INFINITY`.
256pub fn clearance_map(
257    cells: &[Vec<TerrainRiskCell>],
258    cell_size: f64,
259) -> RoboticsResult<Vec<Vec<f64>>> {
260    let (width, height) = validate_cell_grid(cells)?;
261    if cell_size <= 0.0 || !cell_size.is_finite() {
262        return Err(RoboticsError::InvalidParameter(
263            "cell_size must be finite and positive".to_string(),
264        ));
265    }
266
267    let blocked = blocked_cell_positions(cells);
268    let mut clearances = vec![vec![f64::INFINITY; height]; width];
269    if blocked.is_empty() {
270        return Ok(clearances);
271    }
272
273    // x and y are distance-field coordinates indexing `clearances`.
274    #[allow(clippy::needless_range_loop)]
275    for x in 0..width {
276        for y in 0..height {
277            let min_distance = blocked
278                .iter()
279                .map(|&(bx, by)| {
280                    let dx = x as f64 - bx as f64;
281                    let dy = y as f64 - by as f64;
282                    (dx * dx + dy * dy).sqrt() * cell_size
283                })
284                .fold(f64::INFINITY, f64::min);
285            clearances[x][y] = min_distance;
286        }
287    }
288
289    Ok(clearances)
290}
291
292/// Add low-clearance exposure risk while preserving existing terrain channels.
293///
294/// A cell at or above `minimum_clearance` receives no additional exposure risk.
295/// Closer cells receive linearly increasing risk up to `max_risk`.
296pub fn add_clearance_exposure_risk(
297    cells: &[Vec<TerrainRiskCell>],
298    config: &ClearanceRiskConfig,
299) -> RoboticsResult<Vec<Vec<TerrainRiskCell>>> {
300    let (width, height) = validate_cell_grid(cells)?;
301    validate_clearance_config(config)?;
302    let clearances = clearance_map(cells, config.cell_size)?;
303    let mut result = cells.to_vec();
304
305    for x in 0..width {
306        for y in 0..height {
307            if result[x][y].blocked {
308                continue;
309            }
310            let clearance = clearances[x][y];
311            let clearance_risk =
312                clearance_exposure_risk(clearance, config.minimum_clearance, config.risk_scale)
313                    .min(config.max_risk);
314            result[x][y].exposure_risk = if config.additive {
315                (result[x][y].exposure_risk + clearance_risk).min(config.max_risk)
316            } else {
317                clearance_risk
318            };
319        }
320    }
321
322    Ok(result)
323}
324
325/// Inflate blocked cells by a Euclidean radius expressed in grid cells.
326///
327/// This is useful for applying a circular robot footprint before graph search.
328pub fn inflate_blocked_cells(
329    cells: &[Vec<TerrainRiskCell>],
330    radius_cells: i32,
331) -> RoboticsResult<Vec<Vec<TerrainRiskCell>>> {
332    if radius_cells < 0 {
333        return Err(RoboticsError::InvalidParameter(
334            "radius_cells must be non-negative".to_string(),
335        ));
336    }
337    let (width, height) = validate_cell_grid(cells)?;
338    if radius_cells == 0 {
339        return Ok(cells.to_vec());
340    }
341
342    let mut inflated = cells.to_vec();
343    let radius_sq = radius_cells * radius_cells;
344    // x and y read `cells` and seed the inflation offsets into `inflated`.
345    #[allow(clippy::needless_range_loop)]
346    for x in 0..width {
347        for y in 0..height {
348            if !cells[x][y].blocked {
349                continue;
350            }
351            let x = x as i32;
352            let y = y as i32;
353            for dx in -radius_cells..=radius_cells {
354                for dy in -radius_cells..=radius_cells {
355                    if dx * dx + dy * dy > radius_sq {
356                        continue;
357                    }
358                    let nx = x + dx;
359                    let ny = y + dy;
360                    if nx >= 0 && ny >= 0 && nx < width as i32 && ny < height as i32 {
361                        inflated[nx as usize][ny as usize].blocked = true;
362                    }
363                }
364            }
365        }
366    }
367
368    Ok(inflated)
369}
370
371/// Inflate blocked cells by a metric footprint radius.
372pub fn inflate_blocked_cells_by_radius(
373    cells: &[Vec<TerrainRiskCell>],
374    radius: f64,
375    cell_size: f64,
376) -> RoboticsResult<Vec<Vec<TerrainRiskCell>>> {
377    if radius < 0.0 || !radius.is_finite() {
378        return Err(RoboticsError::InvalidParameter(
379            "radius must be finite and non-negative".to_string(),
380        ));
381    }
382    if cell_size <= 0.0 || !cell_size.is_finite() {
383        return Err(RoboticsError::InvalidParameter(
384            "cell_size must be finite and positive".to_string(),
385        ));
386    }
387    inflate_blocked_cells(cells, (radius / cell_size).ceil() as i32)
388}
389
390/// Planner configuration.
391#[derive(Debug, Clone)]
392pub struct TraversalRiskGraphConfig {
393    pub width: i32,
394    pub height: i32,
395    /// Cell storage indexed as `cells[x][y]`.
396    pub cells: Vec<Vec<TerrainRiskCell>>,
397    pub allow_diagonal: bool,
398    pub distance_weight: f64,
399    pub risk_weight: f64,
400    pub traversability_weight: f64,
401    pub stability_weight: f64,
402    pub exposure_weight: f64,
403}
404
405impl TraversalRiskGraphConfig {
406    pub fn new(width: i32, height: i32, cells: Vec<Vec<TerrainRiskCell>>) -> Self {
407        Self {
408            width,
409            height,
410            cells,
411            allow_diagonal: false,
412            distance_weight: 1.0,
413            risk_weight: 1.0,
414            traversability_weight: 1.0,
415            stability_weight: 1.0,
416            exposure_weight: 1.0,
417        }
418    }
419}
420
421/// Plan the same query for several `risk_weight` values.
422///
423/// Each sample includes the weighted path returned by the planner plus
424/// `terrain_risk_cost`, which is the unweighted integrated terrain risk along
425/// the path. That makes distance-vs-risk tradeoffs directly comparable across
426/// different weights.
427pub fn sweep_traversal_risk_weights(
428    config: TraversalRiskGraphConfig,
429    risk_weights: &[f64],
430    sx: i32,
431    sy: i32,
432    gx: i32,
433    gy: i32,
434) -> RoboticsResult<Vec<TraversalRiskWeightSample>> {
435    let mut samples = Vec::with_capacity(risk_weights.len());
436    for &risk_weight in risk_weights {
437        let mut config = config.clone();
438        config.risk_weight = risk_weight;
439        let planner = TraversalRiskGraphPlanner::new(config)?;
440        let path = planner.plan(sx, sy, gx, gy)?;
441        let terrain_risk_cost = planner.path_terrain_risk(&path.waypoints)?;
442        samples.push(TraversalRiskWeightSample {
443            risk_weight,
444            terrain_risk_cost,
445            path,
446        });
447    }
448    Ok(samples)
449}
450
451fn validate_cell_grid(cells: &[Vec<TerrainRiskCell>]) -> RoboticsResult<(usize, usize)> {
452    if cells.is_empty() || cells[0].is_empty() {
453        return Err(RoboticsError::InvalidParameter(
454            "cell grid must be non-empty".to_string(),
455        ));
456    }
457    let height = cells[0].len();
458    for column in cells {
459        if column.len() != height {
460            return Err(RoboticsError::InvalidParameter(
461                "cell grid must be rectangular".to_string(),
462            ));
463        }
464        for cell in column {
465            for risk in [
466                cell.traversability_risk,
467                cell.stability_risk,
468                cell.exposure_risk,
469            ] {
470                if risk < 0.0 || !risk.is_finite() {
471                    return Err(RoboticsError::InvalidParameter(
472                        "terrain risks must be finite and non-negative".to_string(),
473                    ));
474                }
475            }
476        }
477    }
478    Ok((cells.len(), height))
479}
480
481fn validate_elevation_map(elevation: &[Vec<f64>]) -> RoboticsResult<(usize, usize)> {
482    if elevation.is_empty() || elevation[0].is_empty() {
483        return Err(RoboticsError::InvalidParameter(
484            "elevation map must be non-empty".to_string(),
485        ));
486    }
487    let height = elevation[0].len();
488    for column in elevation {
489        if column.len() != height {
490            return Err(RoboticsError::InvalidParameter(
491                "elevation map must be rectangular".to_string(),
492            ));
493        }
494        if column.iter().any(|height| !height.is_finite()) {
495            return Err(RoboticsError::InvalidParameter(
496                "elevation values must be finite".to_string(),
497            ));
498        }
499    }
500    Ok((elevation.len(), height))
501}
502
503fn validate_elevation_config(config: &ElevationRiskConfig) -> RoboticsResult<()> {
504    for (label, value, allow_zero) in [
505        ("cell_size", config.cell_size, false),
506        ("slope_risk_scale", config.slope_risk_scale, true),
507        ("roughness_risk_scale", config.roughness_risk_scale, true),
508        ("max_risk", config.max_risk, false),
509    ] {
510        if !value.is_finite() || value < 0.0 || (!allow_zero && value == 0.0) {
511            return Err(RoboticsError::InvalidParameter(format!(
512                "{} must be finite and {}",
513                label,
514                if allow_zero {
515                    "non-negative"
516                } else {
517                    "positive"
518                }
519            )));
520        }
521    }
522    if let Some(threshold) = config.blocking_step_height {
523        if threshold <= 0.0 || !threshold.is_finite() {
524            return Err(RoboticsError::InvalidParameter(
525                "blocking_step_height must be finite and positive".to_string(),
526            ));
527        }
528    }
529    Ok(())
530}
531
532fn validate_smoothing_config(config: &RiskMapSmoothingConfig) -> RoboticsResult<()> {
533    if config.radius_cells < 0 {
534        return Err(RoboticsError::InvalidParameter(
535            "radius_cells must be non-negative".to_string(),
536        ));
537    }
538    if config.sigma_cells <= 0.0 || !config.sigma_cells.is_finite() {
539        return Err(RoboticsError::InvalidParameter(
540            "sigma_cells must be finite and positive".to_string(),
541        ));
542    }
543    Ok(())
544}
545
546fn validate_clearance_config(config: &ClearanceRiskConfig) -> RoboticsResult<()> {
547    for (label, value, allow_zero) in [
548        ("cell_size", config.cell_size, false),
549        ("minimum_clearance", config.minimum_clearance, false),
550        ("risk_scale", config.risk_scale, true),
551        ("max_risk", config.max_risk, true),
552    ] {
553        if !value.is_finite() || value < 0.0 || (!allow_zero && value == 0.0) {
554            return Err(RoboticsError::InvalidParameter(format!(
555                "{} must be finite and {}",
556                label,
557                if allow_zero {
558                    "non-negative"
559                } else {
560                    "positive"
561                }
562            )));
563        }
564    }
565    Ok(())
566}
567
568fn blocked_cell_positions(cells: &[Vec<TerrainRiskCell>]) -> Vec<(usize, usize)> {
569    let mut blocked = Vec::new();
570    for (x, column) in cells.iter().enumerate() {
571        for (y, cell) in column.iter().enumerate() {
572            if cell.blocked {
573                blocked.push((x, y));
574            }
575        }
576    }
577    blocked
578}
579
580fn clearance_exposure_risk(clearance: f64, minimum_clearance: f64, risk_scale: f64) -> f64 {
581    if !clearance.is_finite() || clearance >= minimum_clearance {
582        0.0
583    } else {
584        (1.0 - clearance / minimum_clearance) * risk_scale
585    }
586}
587
588fn elevation_gradient(
589    elevation: &[Vec<f64>],
590    x: usize,
591    y: usize,
592    axis: usize,
593    cell_size: f64,
594) -> f64 {
595    let (len, index) = if axis == 0 {
596        (elevation.len(), x)
597    } else {
598        (elevation[0].len(), y)
599    };
600    if len == 1 {
601        return 0.0;
602    }
603
604    let prev = index.saturating_sub(1);
605    let next = (index + 1).min(len - 1);
606    let prev_height = if axis == 0 {
607        elevation[prev][y]
608    } else {
609        elevation[x][prev]
610    };
611    let next_height = if axis == 0 {
612        elevation[next][y]
613    } else {
614        elevation[x][next]
615    };
616    let distance = (next - prev) as f64 * cell_size;
617    if distance == 0.0 {
618        0.0
619    } else {
620        (next_height - prev_height) / distance
621    }
622}
623
624fn local_roughness(elevation: &[Vec<f64>], x: usize, y: usize) -> f64 {
625    let center = elevation[x][y];
626    let width = elevation.len() as i32;
627    let height = elevation[0].len() as i32;
628    let mut roughness: f64 = 0.0;
629
630    for dx in -1..=1 {
631        for dy in -1..=1 {
632            if dx == 0 && dy == 0 {
633                continue;
634            }
635            let nx = x as i32 + dx;
636            let ny = y as i32 + dy;
637            if nx >= 0 && ny >= 0 && nx < width && ny < height {
638                roughness = roughness.max((center - elevation[nx as usize][ny as usize]).abs());
639            }
640        }
641    }
642
643    roughness
644}
645
646/// One cell in a planned risk path.
647#[derive(Debug, Clone, Copy, PartialEq, Eq)]
648pub struct RiskWaypoint {
649    pub x: i32,
650    pub y: i32,
651}
652
653impl RiskWaypoint {
654    pub fn new(x: i32, y: i32) -> Self {
655        Self { x, y }
656    }
657}
658
659/// Planned path plus decomposed costs.
660#[derive(Debug, Clone, PartialEq)]
661pub struct TraversalRiskPath {
662    pub waypoints: Vec<RiskWaypoint>,
663    pub distance_cost: f64,
664    pub risk_cost: f64,
665    pub total_cost: f64,
666}
667
668/// One result from a risk-weight sweep.
669#[derive(Debug, Clone, PartialEq)]
670pub struct TraversalRiskWeightSample {
671    pub risk_weight: f64,
672    pub terrain_risk_cost: f64,
673    pub path: TraversalRiskPath,
674}
675
676#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
677struct CellKey {
678    x: i32,
679    y: i32,
680}
681
682#[derive(Debug, Clone, Copy)]
683struct OpenEntry {
684    key: CellKey,
685    f_cost: f64,
686}
687
688impl Eq for OpenEntry {}
689
690impl PartialEq for OpenEntry {
691    fn eq(&self, other: &Self) -> bool {
692        self.f_cost == other.f_cost && self.key == other.key
693    }
694}
695
696impl Ord for OpenEntry {
697    fn cmp(&self, other: &Self) -> Ordering {
698        other
699            .f_cost
700            .total_cmp(&self.f_cost)
701            .then_with(|| self.key.x.cmp(&other.key.x))
702            .then_with(|| self.key.y.cmp(&other.key.y))
703    }
704}
705
706impl PartialOrd for OpenEntry {
707    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
708        Some(self.cmp(other))
709    }
710}
711
712/// Traversal-risk graph planner.
713pub struct TraversalRiskGraphPlanner {
714    config: TraversalRiskGraphConfig,
715    motions: Vec<(i32, i32, f64)>,
716}
717
718impl TraversalRiskGraphPlanner {
719    pub fn new(config: TraversalRiskGraphConfig) -> RoboticsResult<Self> {
720        Self::validate_config(&config)?;
721        let motions = if config.allow_diagonal {
722            vec![
723                (1, 0, 1.0),
724                (0, 1, 1.0),
725                (-1, 0, 1.0),
726                (0, -1, 1.0),
727                (1, 1, std::f64::consts::SQRT_2),
728                (1, -1, std::f64::consts::SQRT_2),
729                (-1, 1, std::f64::consts::SQRT_2),
730                (-1, -1, std::f64::consts::SQRT_2),
731            ]
732        } else {
733            vec![(1, 0, 1.0), (0, 1, 1.0), (-1, 0, 1.0), (0, -1, 1.0)]
734        };
735        Ok(Self { config, motions })
736    }
737
738    /// Plan a minimum distance-plus-risk path on the configured grid.
739    pub fn plan(&self, sx: i32, sy: i32, gx: i32, gy: i32) -> RoboticsResult<TraversalRiskPath> {
740        self.validate_free_cell(sx, sy, "start")?;
741        self.validate_free_cell(gx, gy, "goal")?;
742
743        let start = CellKey { x: sx, y: sy };
744        let goal = CellKey { x: gx, y: gy };
745        let mut open = BinaryHeap::new();
746        let mut g_cost: HashMap<CellKey, f64> = HashMap::new();
747        let mut parent: HashMap<CellKey, CellKey> = HashMap::new();
748
749        g_cost.insert(start, 0.0);
750        open.push(OpenEntry {
751            key: start,
752            f_cost: self.heuristic(start, goal),
753        });
754
755        while let Some(current) = open.pop() {
756            if current.key == goal {
757                return self.reconstruct_path(start, goal, &parent);
758            }
759
760            let Some(&current_g) = g_cost.get(&current.key) else {
761                continue;
762            };
763            if current.f_cost > current_g + self.heuristic(current.key, goal) + 1e-9 {
764                continue;
765            }
766
767            for &(dx, dy, distance) in &self.motions {
768                let next = CellKey {
769                    x: current.key.x + dx,
770                    y: current.key.y + dy,
771                };
772                if !self.is_free(next.x, next.y) {
773                    continue;
774                }
775
776                let step_cost = self.edge_cost(current.key, next, distance);
777                let next_g = current_g + step_cost;
778                if g_cost.get(&next).is_some_and(|old| next_g >= *old) {
779                    continue;
780                }
781
782                g_cost.insert(next, next_g);
783                parent.insert(next, current.key);
784                open.push(OpenEntry {
785                    key: next,
786                    f_cost: next_g + self.heuristic(next, goal),
787                });
788            }
789        }
790
791        Err(RoboticsError::PlanningError(
792            "no traversal-risk path found".to_string(),
793        ))
794    }
795
796    /// Weighted terrain risk for a single cell.
797    pub fn cell_risk(&self, x: i32, y: i32) -> RoboticsResult<f64> {
798        self.validate_cell(x, y, "cell")?;
799        Ok(self.cell_risk_unchecked(CellKey { x, y }))
800    }
801
802    /// Integrated terrain risk along a waypoint path, before `risk_weight`.
803    pub fn path_terrain_risk(&self, waypoints: &[RiskWaypoint]) -> RoboticsResult<f64> {
804        if waypoints.is_empty() {
805            return Err(RoboticsError::InvalidParameter(
806                "waypoints must be non-empty".to_string(),
807            ));
808        }
809        for waypoint in waypoints {
810            self.validate_free_cell(waypoint.x, waypoint.y, "path waypoint")?;
811        }
812
813        Ok(waypoints
814            .windows(2)
815            .map(|window| {
816                let from = CellKey {
817                    x: window[0].x,
818                    y: window[0].y,
819                };
820                let to = CellKey {
821                    x: window[1].x,
822                    y: window[1].y,
823                };
824                let dx = (to.x - from.x) as f64;
825                let dy = (to.y - from.y) as f64;
826                let distance = (dx * dx + dy * dy).sqrt();
827                0.5 * (self.cell_risk_unchecked(from) + self.cell_risk_unchecked(to)) * distance
828            })
829            .sum())
830    }
831
832    fn validate_config(config: &TraversalRiskGraphConfig) -> RoboticsResult<()> {
833        if config.width <= 0 || config.height <= 0 {
834            return Err(RoboticsError::InvalidParameter(
835                "width and height must be positive".to_string(),
836            ));
837        }
838        if config.cells.len() != config.width as usize {
839            return Err(RoboticsError::InvalidParameter(
840                "cells x-dimension must match width".to_string(),
841            ));
842        }
843        for column in &config.cells {
844            if column.len() != config.height as usize {
845                return Err(RoboticsError::InvalidParameter(
846                    "cells y-dimension must match height".to_string(),
847                ));
848            }
849            for cell in column {
850                for risk in [
851                    cell.traversability_risk,
852                    cell.stability_risk,
853                    cell.exposure_risk,
854                ] {
855                    if risk < 0.0 || !risk.is_finite() {
856                        return Err(RoboticsError::InvalidParameter(
857                            "terrain risks must be finite and non-negative".to_string(),
858                        ));
859                    }
860                }
861            }
862        }
863        for (label, value) in [
864            ("distance_weight", config.distance_weight),
865            ("risk_weight", config.risk_weight),
866            ("traversability_weight", config.traversability_weight),
867            ("stability_weight", config.stability_weight),
868            ("exposure_weight", config.exposure_weight),
869        ] {
870            if value < 0.0 || !value.is_finite() {
871                return Err(RoboticsError::InvalidParameter(format!(
872                    "{} must be finite and non-negative",
873                    label
874                )));
875            }
876        }
877        Ok(())
878    }
879
880    fn validate_free_cell(&self, x: i32, y: i32, label: &str) -> RoboticsResult<()> {
881        self.validate_cell(x, y, label)?;
882        if self.config.cells[x as usize][y as usize].blocked {
883            return Err(RoboticsError::PlanningError(format!(
884                "{} ({}, {}) is blocked",
885                label, x, y
886            )));
887        }
888        Ok(())
889    }
890
891    fn validate_cell(&self, x: i32, y: i32, label: &str) -> RoboticsResult<()> {
892        if self.in_bounds(x, y) {
893            Ok(())
894        } else {
895            Err(RoboticsError::InvalidParameter(format!(
896                "{} ({}, {}) is out of bounds ({}x{})",
897                label, x, y, self.config.width, self.config.height
898            )))
899        }
900    }
901
902    fn in_bounds(&self, x: i32, y: i32) -> bool {
903        x >= 0 && y >= 0 && x < self.config.width && y < self.config.height
904    }
905
906    fn is_free(&self, x: i32, y: i32) -> bool {
907        self.in_bounds(x, y) && !self.config.cells[x as usize][y as usize].blocked
908    }
909
910    fn cell_risk_unchecked(&self, key: CellKey) -> f64 {
911        let cell = self.config.cells[key.x as usize][key.y as usize];
912        self.config.traversability_weight * cell.traversability_risk
913            + self.config.stability_weight * cell.stability_risk
914            + self.config.exposure_weight * cell.exposure_risk
915    }
916
917    fn edge_cost(&self, from: CellKey, to: CellKey, distance: f64) -> f64 {
918        let from_risk = self.cell_risk_unchecked(from);
919        let to_risk = self.cell_risk_unchecked(to);
920        let risk = 0.5 * (from_risk + to_risk) * distance;
921        self.config.distance_weight * distance + self.config.risk_weight * risk
922    }
923
924    fn heuristic(&self, from: CellKey, goal: CellKey) -> f64 {
925        let dx = (from.x - goal.x) as f64;
926        let dy = (from.y - goal.y) as f64;
927        self.config.distance_weight * (dx * dx + dy * dy).sqrt()
928    }
929
930    fn reconstruct_path(
931        &self,
932        start: CellKey,
933        goal: CellKey,
934        parent: &HashMap<CellKey, CellKey>,
935    ) -> RoboticsResult<TraversalRiskPath> {
936        let mut keys = vec![goal];
937        let mut current = goal;
938        while current != start {
939            current = *parent.get(&current).ok_or_else(|| {
940                RoboticsError::PlanningError("failed to reconstruct risk path".to_string())
941            })?;
942            keys.push(current);
943        }
944        keys.reverse();
945
946        let waypoints: Vec<RiskWaypoint> = keys
947            .iter()
948            .map(|key| RiskWaypoint::new(key.x, key.y))
949            .collect();
950        let mut distance_cost = 0.0;
951        let mut risk_cost = 0.0;
952        for pair in keys.windows(2) {
953            let dx = (pair[1].x - pair[0].x) as f64;
954            let dy = (pair[1].y - pair[0].y) as f64;
955            let distance = (dx * dx + dy * dy).sqrt();
956            distance_cost += self.config.distance_weight * distance;
957            let step_risk = 0.5
958                * (self.cell_risk_unchecked(pair[0]) + self.cell_risk_unchecked(pair[1]))
959                * distance;
960            risk_cost += self.config.risk_weight * step_risk;
961        }
962
963        Ok(TraversalRiskPath {
964            waypoints,
965            distance_cost,
966            risk_cost,
967            total_cost: distance_cost + risk_cost,
968        })
969    }
970}
971
972#[cfg(test)]
973mod tests {
974    use super::*;
975
976    fn free_cells(width: i32, height: i32) -> Vec<Vec<TerrainRiskCell>> {
977        vec![vec![TerrainRiskCell::free(); height as usize]; width as usize]
978    }
979
980    fn planner_with_risk(
981        width: i32,
982        height: i32,
983        high_risk: &[(i32, i32)],
984        risk_weight: f64,
985    ) -> TraversalRiskGraphPlanner {
986        let mut cells = free_cells(width, height);
987        for &(x, y) in high_risk {
988            cells[x as usize][y as usize] = TerrainRiskCell::with_risk(5.0, 0.0, 0.0);
989        }
990        TraversalRiskGraphPlanner::new(TraversalRiskGraphConfig {
991            risk_weight,
992            ..TraversalRiskGraphConfig::new(width, height, cells)
993        })
994        .unwrap()
995    }
996
997    #[test]
998    fn straight_line_when_risk_is_zero() {
999        let planner = planner_with_risk(5, 3, &[], 1.0);
1000        let path = planner.plan(0, 1, 4, 1).unwrap();
1001
1002        assert_eq!(path.waypoints.len(), 5);
1003        assert!(path.waypoints.iter().all(|waypoint| waypoint.y == 1));
1004        assert!((path.distance_cost - 4.0).abs() < 1e-9);
1005        assert_eq!(path.risk_cost, 0.0);
1006    }
1007
1008    #[test]
1009    fn risk_weight_changes_route_choice() {
1010        let risky_corridor = vec![(2, 1), (3, 1), (4, 1)];
1011        let distance_only = planner_with_risk(7, 3, &risky_corridor, 0.0);
1012        let risk_aware = planner_with_risk(7, 3, &risky_corridor, 2.0);
1013
1014        let short_path = distance_only.plan(0, 1, 6, 1).unwrap();
1015        let safe_path = risk_aware.plan(0, 1, 6, 1).unwrap();
1016
1017        assert!(short_path.waypoints.iter().any(|waypoint| waypoint.y == 1));
1018        assert!(
1019            safe_path
1020                .waypoints
1021                .iter()
1022                .filter(|waypoint| risky_corridor.contains(&(waypoint.x, waypoint.y)))
1023                .count()
1024                < short_path
1025                    .waypoints
1026                    .iter()
1027                    .filter(|waypoint| risky_corridor.contains(&(waypoint.x, waypoint.y)))
1028                    .count()
1029        );
1030        assert!(safe_path.distance_cost > short_path.distance_cost);
1031        assert!(safe_path.total_cost < risk_aware.edge_costs_along(&short_path.waypoints));
1032    }
1033
1034    #[test]
1035    fn blocked_cells_force_detour() {
1036        let mut cells = free_cells(5, 3);
1037        cells[2][1] = TerrainRiskCell::blocked();
1038        let planner =
1039            TraversalRiskGraphPlanner::new(TraversalRiskGraphConfig::new(5, 3, cells)).unwrap();
1040        let path = planner.plan(0, 1, 4, 1).unwrap();
1041
1042        assert!(!path
1043            .waypoints
1044            .iter()
1045            .any(|waypoint| waypoint.x == 2 && waypoint.y == 1));
1046        assert!(path.distance_cost > 4.0);
1047    }
1048
1049    #[test]
1050    fn rejects_invalid_risk_values() {
1051        let mut cells = free_cells(2, 2);
1052        cells[1][1].traversability_risk = f64::NAN;
1053        let result = TraversalRiskGraphPlanner::new(TraversalRiskGraphConfig::new(2, 2, cells));
1054
1055        assert!(matches!(result, Err(RoboticsError::InvalidParameter(_))));
1056    }
1057
1058    #[test]
1059    fn elevation_map_generates_slope_and_roughness_risks() {
1060        let elevation = vec![
1061            vec![0.0, 0.0, 0.0],
1062            vec![0.0, 1.0, 0.0],
1063            vec![0.0, 0.0, 0.0],
1064        ];
1065        let cells = terrain_risk_from_elevation_map(
1066            &elevation,
1067            &ElevationRiskConfig {
1068                slope_risk_scale: 4.0,
1069                roughness_risk_scale: 3.0,
1070                max_risk: 20.0,
1071                ..ElevationRiskConfig::new(1.0)
1072            },
1073        )
1074        .unwrap();
1075
1076        assert!(cells[0][1].traversability_risk > 0.0);
1077        assert_eq!(cells[1][1].traversability_risk, 0.0);
1078        assert!((cells[1][1].stability_risk - 3.0).abs() < 1e-9);
1079        assert!(!cells[1][1].blocked);
1080    }
1081
1082    #[test]
1083    fn elevation_step_can_mark_cells_blocked() {
1084        let elevation = vec![
1085            vec![0.0, 0.0, 0.0],
1086            vec![0.0, 1.2, 0.0],
1087            vec![0.0, 0.0, 0.0],
1088        ];
1089        let cells = terrain_risk_from_elevation_map(
1090            &elevation,
1091            &ElevationRiskConfig {
1092                blocking_step_height: Some(1.0),
1093                ..ElevationRiskConfig::new(1.0)
1094            },
1095        )
1096        .unwrap();
1097
1098        assert!(cells[1][1].blocked);
1099        assert!(cells[0][1].blocked);
1100    }
1101
1102    #[test]
1103    fn footprint_inflation_expands_blocked_cells() {
1104        let mut cells = free_cells(5, 5);
1105        cells[2][2] = TerrainRiskCell::blocked();
1106        let inflated = inflate_blocked_cells(&cells, 1).unwrap();
1107
1108        assert!(inflated[2][2].blocked);
1109        assert!(inflated[1][2].blocked);
1110        assert!(inflated[2][1].blocked);
1111        assert!(inflated[3][2].blocked);
1112        assert!(inflated[2][3].blocked);
1113        assert!(!inflated[1][1].blocked);
1114    }
1115
1116    #[test]
1117    fn metric_footprint_radius_is_converted_to_grid_radius() {
1118        let mut cells = free_cells(5, 5);
1119        cells[2][2] = TerrainRiskCell::blocked();
1120        let inflated = inflate_blocked_cells_by_radius(&cells, 0.75, 0.5).unwrap();
1121
1122        assert!(inflated[0][2].blocked);
1123        assert!(inflated[4][2].blocked);
1124        assert!(!inflated[0][0].blocked);
1125    }
1126
1127    #[test]
1128    fn risk_smoothing_spreads_impulse_risk() {
1129        let mut cells = free_cells(5, 5);
1130        cells[2][2] = TerrainRiskCell::with_risk(9.0, 0.0, 0.0);
1131        let smoothed = smooth_terrain_risk(
1132            &cells,
1133            &RiskMapSmoothingConfig {
1134                radius_cells: 1,
1135                iterations: 1,
1136                sigma_cells: 1.0,
1137                smooth_blocked_cells: false,
1138            },
1139        )
1140        .unwrap();
1141
1142        assert!(smoothed[2][2].traversability_risk < 9.0);
1143        assert!(smoothed[2][1].traversability_risk > 0.0);
1144        assert_eq!(smoothed[0][0].traversability_risk, 0.0);
1145    }
1146
1147    #[test]
1148    fn risk_smoothing_preserves_blocked_cells_by_default() {
1149        let mut cells = free_cells(3, 3);
1150        cells[1][1] = TerrainRiskCell {
1151            blocked: true,
1152            traversability_risk: 6.0,
1153            stability_risk: 2.0,
1154            exposure_risk: 1.0,
1155        };
1156        let smoothed = smooth_terrain_risk(&cells, &RiskMapSmoothingConfig::new(1)).unwrap();
1157
1158        assert_eq!(smoothed[1][1], cells[1][1]);
1159        assert!(smoothed[1][0].traversability_risk > 0.0);
1160    }
1161
1162    #[test]
1163    fn zero_iteration_smoothing_returns_original_grid() {
1164        let mut cells = free_cells(3, 3);
1165        cells[1][1] = TerrainRiskCell::with_risk(4.0, 3.0, 2.0);
1166        let smoothed = smooth_terrain_risk(
1167            &cells,
1168            &RiskMapSmoothingConfig {
1169                iterations: 0,
1170                ..RiskMapSmoothingConfig::new(1)
1171            },
1172        )
1173        .unwrap();
1174
1175        assert_eq!(smoothed, cells);
1176    }
1177
1178    #[test]
1179    fn rejects_invalid_smoothing_config() {
1180        let cells = free_cells(3, 3);
1181        let result = smooth_terrain_risk(
1182            &cells,
1183            &RiskMapSmoothingConfig {
1184                sigma_cells: 0.0,
1185                ..RiskMapSmoothingConfig::new(1)
1186            },
1187        );
1188
1189        assert!(matches!(result, Err(RoboticsError::InvalidParameter(_))));
1190    }
1191
1192    #[test]
1193    fn clearance_map_reports_metric_distance_to_blocked_cells() {
1194        let mut cells = free_cells(3, 3);
1195        cells[1][1] = TerrainRiskCell::blocked();
1196        let clearances = clearance_map(&cells, 0.5).unwrap();
1197
1198        assert_eq!(clearances[1][1], 0.0);
1199        assert!((clearances[1][0] - 0.5).abs() < 1e-9);
1200        assert!((clearances[0][0] - std::f64::consts::SQRT_2 * 0.5).abs() < 1e-9);
1201    }
1202
1203    #[test]
1204    fn clearance_map_without_blocked_cells_is_infinite() {
1205        let cells = free_cells(3, 3);
1206        let clearances = clearance_map(&cells, 1.0).unwrap();
1207
1208        assert!(clearances
1209            .iter()
1210            .flatten()
1211            .all(|clearance| clearance.is_infinite()));
1212    }
1213
1214    #[test]
1215    fn clearance_exposure_risk_is_added_to_existing_exposure() {
1216        let mut cells = free_cells(3, 3);
1217        cells[1][1] = TerrainRiskCell::blocked();
1218        cells[1][0].exposure_risk = 1.0;
1219        let risked = add_clearance_exposure_risk(
1220            &cells,
1221            &ClearanceRiskConfig {
1222                cell_size: 1.0,
1223                minimum_clearance: 2.0,
1224                risk_scale: 4.0,
1225                max_risk: 8.0,
1226                additive: true,
1227            },
1228        )
1229        .unwrap();
1230
1231        assert!(risked[1][1].blocked);
1232        assert!((risked[1][0].exposure_risk - 3.0).abs() < 1e-9);
1233        assert!(risked[0][0].exposure_risk > 0.0);
1234        assert!(risked[0][0].exposure_risk < risked[1][0].exposure_risk);
1235    }
1236
1237    #[test]
1238    fn clearance_risk_moves_path_away_from_narrow_corridor() {
1239        let mut cells = free_cells(9, 7);
1240        #[allow(clippy::needless_range_loop)]
1241        for x in 2..=6 {
1242            cells[x][2] = TerrainRiskCell::blocked();
1243            cells[x][4] = TerrainRiskCell::blocked();
1244        }
1245        let risked = add_clearance_exposure_risk(
1246            &cells,
1247            &ClearanceRiskConfig {
1248                minimum_clearance: 2.0,
1249                risk_scale: 8.0,
1250                max_risk: 8.0,
1251                ..ClearanceRiskConfig::new(1.0, 2.0)
1252            },
1253        )
1254        .unwrap();
1255        let distance_only = TraversalRiskGraphPlanner::new(TraversalRiskGraphConfig {
1256            risk_weight: 0.0,
1257            exposure_weight: 1.0,
1258            ..TraversalRiskGraphConfig::new(9, 7, risked.clone())
1259        })
1260        .unwrap();
1261        let clearance_aware = TraversalRiskGraphPlanner::new(TraversalRiskGraphConfig {
1262            risk_weight: 1.0,
1263            exposure_weight: 1.0,
1264            ..TraversalRiskGraphConfig::new(9, 7, risked)
1265        })
1266        .unwrap();
1267
1268        let short_path = distance_only.plan(0, 3, 8, 3).unwrap();
1269        let wide_path = clearance_aware.plan(0, 3, 8, 3).unwrap();
1270
1271        assert!(short_path.waypoints.iter().any(|waypoint| waypoint.y == 3));
1272        assert!(wide_path.distance_cost > short_path.distance_cost);
1273        assert!(wide_path
1274            .waypoints
1275            .iter()
1276            .any(|waypoint| waypoint.y == 0 || waypoint.y == 6));
1277        assert!(
1278            clearance_aware
1279                .path_terrain_risk(&wide_path.waypoints)
1280                .unwrap()
1281                < clearance_aware
1282                    .path_terrain_risk(&short_path.waypoints)
1283                    .unwrap()
1284        );
1285    }
1286
1287    #[test]
1288    fn rejects_invalid_clearance_config() {
1289        let cells = free_cells(3, 3);
1290        let result = add_clearance_exposure_risk(
1291            &cells,
1292            &ClearanceRiskConfig {
1293                minimum_clearance: 0.0,
1294                ..ClearanceRiskConfig::default()
1295            },
1296        );
1297
1298        assert!(matches!(result, Err(RoboticsError::InvalidParameter(_))));
1299    }
1300
1301    #[test]
1302    fn path_terrain_risk_is_unweighted() {
1303        let planner = planner_with_risk(5, 3, &[(2, 1), (3, 1)], 3.0);
1304        let path = vec![
1305            RiskWaypoint::new(0, 1),
1306            RiskWaypoint::new(1, 1),
1307            RiskWaypoint::new(2, 1),
1308            RiskWaypoint::new(3, 1),
1309            RiskWaypoint::new(4, 1),
1310        ];
1311        let terrain_risk = planner.path_terrain_risk(&path).unwrap();
1312
1313        assert!((terrain_risk - 10.0).abs() < 1e-9);
1314    }
1315
1316    #[test]
1317    fn sweep_risk_weights_captures_distance_risk_tradeoff() {
1318        let mut cells = free_cells(7, 3);
1319        #[allow(clippy::needless_range_loop)]
1320        for x in 2..=4 {
1321            cells[x][1] = TerrainRiskCell::with_risk(5.0, 0.0, 0.0);
1322        }
1323        let config = TraversalRiskGraphConfig::new(7, 3, cells);
1324        let samples = sweep_traversal_risk_weights(config, &[0.0, 2.0], 0, 1, 6, 1).unwrap();
1325
1326        assert_eq!(samples.len(), 2);
1327        assert_eq!(samples[0].risk_weight, 0.0);
1328        assert_eq!(samples[1].risk_weight, 2.0);
1329        assert!(samples[1].path.distance_cost > samples[0].path.distance_cost);
1330        assert!(samples[1].terrain_risk_cost < samples[0].terrain_risk_cost);
1331    }
1332
1333    #[test]
1334    fn path_terrain_risk_rejects_empty_paths() {
1335        let planner = planner_with_risk(3, 3, &[], 1.0);
1336        let result = planner.path_terrain_risk(&[]);
1337
1338        assert!(matches!(result, Err(RoboticsError::InvalidParameter(_))));
1339    }
1340
1341    #[test]
1342    fn rejects_invalid_elevation_maps() {
1343        let ragged = vec![vec![0.0, 1.0], vec![0.0]];
1344        let result = terrain_risk_from_elevation_map(&ragged, &ElevationRiskConfig::default());
1345
1346        assert!(matches!(result, Err(RoboticsError::InvalidParameter(_))));
1347    }
1348
1349    impl TraversalRiskGraphPlanner {
1350        fn edge_costs_along(&self, waypoints: &[RiskWaypoint]) -> f64 {
1351            waypoints
1352                .windows(2)
1353                .map(|window| {
1354                    let from = CellKey {
1355                        x: window[0].x,
1356                        y: window[0].y,
1357                    };
1358                    let to = CellKey {
1359                        x: window[1].x,
1360                        y: window[1].y,
1361                    };
1362                    let dx = (to.x - from.x) as f64;
1363                    let dy = (to.y - from.y) as f64;
1364                    self.edge_cost(from, to, (dx * dx + dy * dy).sqrt())
1365                })
1366                .sum()
1367        }
1368    }
1369}