1use std::cmp::Ordering;
8use std::collections::{BinaryHeap, HashMap};
9
10use rust_robotics_core::{RoboticsError, RoboticsResult};
11
12#[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#[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#[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#[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
143pub 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 #[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
184pub 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
252pub 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 #[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
292pub 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
325pub 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 #[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
371pub 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#[derive(Debug, Clone)]
392pub struct TraversalRiskGraphConfig {
393 pub width: i32,
394 pub height: i32,
395 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
421pub 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#[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#[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#[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
712pub 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 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(¤t_g) = g_cost.get(¤t.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 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 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(¤t).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}