1use rust_robotics_core::{RoboticsError, RoboticsResult};
9
10use crate::traversal_risk_graph::TerrainRiskCell;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum AdaptiveCostmapCellState {
15 Free,
16 Unknown,
17 StaticObstacle,
18 MovableObstacle,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq)]
23pub struct AdaptiveCostmapCell {
24 pub state: AdaptiveCostmapCellState,
25 pub cost: f64,
26}
27
28impl AdaptiveCostmapCell {
29 pub fn free() -> Self {
30 Self {
31 state: AdaptiveCostmapCellState::Free,
32 cost: 0.0,
33 }
34 }
35}
36
37#[derive(Debug, Clone, PartialEq)]
39pub struct AdaptiveCostmapNamoConfig {
40 pub width: i32,
41 pub height: i32,
42 pub unknown_cost: f64,
43 pub movable_initial_cost: f64,
44 pub movable_cost_increment: f64,
45 pub movable_cost_decrement: f64,
46 pub static_obstacle_cost: f64,
47 pub lethal_cost: f64,
48 pub stuck_command_speed: f64,
49 pub stuck_actual_speed_ratio: f64,
50 pub progress_distance: f64,
51}
52
53impl AdaptiveCostmapNamoConfig {
54 pub fn new(width: i32, height: i32) -> Self {
55 Self {
56 width,
57 height,
58 unknown_cost: 25.0,
59 movable_initial_cost: 20.0,
60 movable_cost_increment: 30.0,
61 movable_cost_decrement: 15.0,
62 static_obstacle_cost: 100.0,
63 lethal_cost: 100.0,
64 stuck_command_speed: 0.05,
65 stuck_actual_speed_ratio: 0.2,
66 progress_distance: 0.02,
67 }
68 }
69}
70
71#[derive(Debug, Clone, Copy, PartialEq)]
73pub struct MotionProgressObservation {
74 pub commanded_speed: f64,
75 pub actual_speed: f64,
76 pub odom_delta: f64,
77}
78
79impl MotionProgressObservation {
80 pub fn stuck(commanded_speed: f64) -> Self {
81 Self {
82 commanded_speed,
83 actual_speed: 0.0,
84 odom_delta: 0.0,
85 }
86 }
87
88 pub fn moving(commanded_speed: f64, actual_speed: f64, odom_delta: f64) -> Self {
89 Self {
90 commanded_speed,
91 actual_speed,
92 odom_delta,
93 }
94 }
95}
96
97#[derive(Debug, Clone, PartialEq)]
99pub struct AdaptiveCostmapNamo {
100 config: AdaptiveCostmapNamoConfig,
101 cells: Vec<Vec<AdaptiveCostmapCell>>,
102}
103
104impl AdaptiveCostmapNamo {
105 pub fn new(config: AdaptiveCostmapNamoConfig) -> RoboticsResult<Self> {
106 validate_config(&config)?;
107 let cells =
108 vec![vec![AdaptiveCostmapCell::free(); config.height as usize]; config.width as usize];
109 Ok(Self { config, cells })
110 }
111
112 pub fn width(&self) -> i32 {
113 self.config.width
114 }
115
116 pub fn height(&self) -> i32 {
117 self.config.height
118 }
119
120 pub fn cell(&self, x: i32, y: i32) -> RoboticsResult<AdaptiveCostmapCell> {
121 self.validate_cell(x, y)?;
122 Ok(self.cells[x as usize][y as usize])
123 }
124
125 pub fn set_cell_state(
126 &mut self,
127 x: i32,
128 y: i32,
129 state: AdaptiveCostmapCellState,
130 ) -> RoboticsResult<()> {
131 self.validate_cell(x, y)?;
132 let cost = match state {
133 AdaptiveCostmapCellState::Free => 0.0,
134 AdaptiveCostmapCellState::Unknown => self.config.unknown_cost,
135 AdaptiveCostmapCellState::StaticObstacle => self.config.static_obstacle_cost,
136 AdaptiveCostmapCellState::MovableObstacle => self.config.movable_initial_cost,
137 };
138 self.cells[x as usize][y as usize] = AdaptiveCostmapCell { state, cost };
139 Ok(())
140 }
141
142 pub fn mark_movable_obstacle(&mut self, x: i32, y: i32) -> RoboticsResult<()> {
143 self.set_cell_state(x, y, AdaptiveCostmapCellState::MovableObstacle)
144 }
145
146 pub fn mark_static_obstacle(&mut self, x: i32, y: i32) -> RoboticsResult<()> {
147 self.set_cell_state(x, y, AdaptiveCostmapCellState::StaticObstacle)
148 }
149
150 pub fn mark_unknown(&mut self, x: i32, y: i32) -> RoboticsResult<()> {
151 self.set_cell_state(x, y, AdaptiveCostmapCellState::Unknown)
152 }
153
154 pub fn update_movable_costs(
159 &mut self,
160 movable_cells: &[(i32, i32)],
161 observation: MotionProgressObservation,
162 ) -> RoboticsResult<usize> {
163 validate_observation(&observation)?;
164 let stuck = self.is_stuck(observation);
165 let progressing = self.is_progressing(observation);
166 let mut changed = 0;
167
168 for &(x, y) in movable_cells {
169 self.validate_cell(x, y)?;
170 let cell = &mut self.cells[x as usize][y as usize];
171 if cell.state != AdaptiveCostmapCellState::MovableObstacle {
172 continue;
173 }
174
175 let old_cost = cell.cost;
176 if stuck {
177 cell.cost =
178 (cell.cost + self.config.movable_cost_increment).min(self.config.lethal_cost);
179 } else if progressing {
180 cell.cost = (cell.cost - self.config.movable_cost_decrement)
181 .max(self.config.movable_initial_cost);
182 }
183 if (cell.cost - old_cost).abs() > 1e-9 {
184 changed += 1;
185 }
186 }
187
188 Ok(changed)
189 }
190
191 pub fn to_traversal_risk_cells(&self, block_lethal_movable: bool) -> Vec<Vec<TerrainRiskCell>> {
193 let mut terrain = vec![
194 vec![TerrainRiskCell::free(); self.config.height as usize];
195 self.config.width as usize
196 ];
197 #[allow(clippy::needless_range_loop)]
200 for x in 0..self.config.width as usize {
201 for y in 0..self.config.height as usize {
202 let cell = self.cells[x][y];
203 terrain[x][y] = match cell.state {
204 AdaptiveCostmapCellState::Free => TerrainRiskCell::free(),
205 AdaptiveCostmapCellState::Unknown => TerrainRiskCell {
206 blocked: false,
207 traversability_risk: 0.0,
208 stability_risk: 0.0,
209 exposure_risk: normalize_cost(cell.cost, self.config.lethal_cost),
210 },
211 AdaptiveCostmapCellState::StaticObstacle => TerrainRiskCell::blocked(),
212 AdaptiveCostmapCellState::MovableObstacle => TerrainRiskCell {
213 blocked: block_lethal_movable && cell.cost >= self.config.lethal_cost,
214 traversability_risk: 0.0,
215 stability_risk: 0.0,
216 exposure_risk: normalize_cost(cell.cost, self.config.lethal_cost),
217 },
218 };
219 }
220 }
221 terrain
222 }
223
224 fn validate_cell(&self, x: i32, y: i32) -> RoboticsResult<()> {
225 if x >= 0 && y >= 0 && x < self.config.width && y < self.config.height {
226 Ok(())
227 } else {
228 Err(RoboticsError::InvalidParameter(format!(
229 "cell ({}, {}) is out of bounds ({}x{})",
230 x, y, self.config.width, self.config.height
231 )))
232 }
233 }
234
235 fn is_stuck(&self, observation: MotionProgressObservation) -> bool {
236 observation.commanded_speed >= self.config.stuck_command_speed
237 && observation.actual_speed
238 <= observation.commanded_speed * self.config.stuck_actual_speed_ratio
239 && observation.odom_delta < self.config.progress_distance
240 }
241
242 fn is_progressing(&self, observation: MotionProgressObservation) -> bool {
243 observation.commanded_speed >= self.config.stuck_command_speed
244 && (observation.actual_speed
245 > observation.commanded_speed * self.config.stuck_actual_speed_ratio
246 || observation.odom_delta >= self.config.progress_distance)
247 }
248}
249
250fn validate_config(config: &AdaptiveCostmapNamoConfig) -> RoboticsResult<()> {
251 if config.width <= 0 || config.height <= 0 {
252 return Err(RoboticsError::InvalidParameter(
253 "width and height must be positive".to_string(),
254 ));
255 }
256 for (label, value, allow_zero) in [
257 ("unknown_cost", config.unknown_cost, true),
258 ("movable_initial_cost", config.movable_initial_cost, true),
259 (
260 "movable_cost_increment",
261 config.movable_cost_increment,
262 false,
263 ),
264 (
265 "movable_cost_decrement",
266 config.movable_cost_decrement,
267 false,
268 ),
269 ("static_obstacle_cost", config.static_obstacle_cost, false),
270 ("lethal_cost", config.lethal_cost, false),
271 ("stuck_command_speed", config.stuck_command_speed, false),
272 (
273 "stuck_actual_speed_ratio",
274 config.stuck_actual_speed_ratio,
275 true,
276 ),
277 ("progress_distance", config.progress_distance, true),
278 ] {
279 if !value.is_finite() || value < 0.0 || (!allow_zero && value == 0.0) {
280 return Err(RoboticsError::InvalidParameter(format!(
281 "{} must be finite and {}",
282 label,
283 if allow_zero {
284 "non-negative"
285 } else {
286 "positive"
287 }
288 )));
289 }
290 }
291 if config.movable_initial_cost > config.lethal_cost
292 || config.unknown_cost > config.lethal_cost
293 || config.static_obstacle_cost > config.lethal_cost
294 {
295 return Err(RoboticsError::InvalidParameter(
296 "initial/static/unknown costs must not exceed lethal_cost".to_string(),
297 ));
298 }
299 Ok(())
300}
301
302fn validate_observation(observation: &MotionProgressObservation) -> RoboticsResult<()> {
303 for (label, value) in [
304 ("commanded_speed", observation.commanded_speed),
305 ("actual_speed", observation.actual_speed),
306 ("odom_delta", observation.odom_delta),
307 ] {
308 if value < 0.0 || !value.is_finite() {
309 return Err(RoboticsError::InvalidParameter(format!(
310 "{} must be finite and non-negative",
311 label
312 )));
313 }
314 }
315 Ok(())
316}
317
318fn normalize_cost(cost: f64, lethal_cost: f64) -> f64 {
319 if lethal_cost <= 0.0 {
320 0.0
321 } else {
322 (cost / lethal_cost).clamp(0.0, 1.0)
323 }
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329
330 fn costmap() -> AdaptiveCostmapNamo {
331 AdaptiveCostmapNamo::new(AdaptiveCostmapNamoConfig::new(6, 4)).unwrap()
332 }
333
334 #[test]
335 fn stuck_observation_raises_movable_cost() {
336 let mut map = costmap();
337 map.mark_movable_obstacle(2, 1).unwrap();
338
339 let changed = map
340 .update_movable_costs(&[(2, 1)], MotionProgressObservation::stuck(0.5))
341 .unwrap();
342
343 assert_eq!(changed, 1);
344 assert_eq!(map.cell(2, 1).unwrap().cost, 50.0);
345 }
346
347 #[test]
348 fn cost_caps_at_lethal() {
349 let mut map = costmap();
350 map.mark_movable_obstacle(2, 1).unwrap();
351 for _ in 0..5 {
352 map.update_movable_costs(&[(2, 1)], MotionProgressObservation::stuck(0.5))
353 .unwrap();
354 }
355
356 assert_eq!(map.cell(2, 1).unwrap().cost, 100.0);
357 }
358
359 #[test]
360 fn progress_observation_decays_to_initial_cost() {
361 let mut map = costmap();
362 map.mark_movable_obstacle(2, 1).unwrap();
363 map.update_movable_costs(&[(2, 1)], MotionProgressObservation::stuck(0.5))
364 .unwrap();
365 map.update_movable_costs(&[(2, 1)], MotionProgressObservation::moving(0.5, 0.4, 0.2))
366 .unwrap();
367 map.update_movable_costs(&[(2, 1)], MotionProgressObservation::moving(0.5, 0.4, 0.2))
368 .unwrap();
369 map.update_movable_costs(&[(2, 1)], MotionProgressObservation::moving(0.5, 0.4, 0.2))
370 .unwrap();
371
372 assert_eq!(map.cell(2, 1).unwrap().cost, 20.0);
373 }
374
375 #[test]
376 fn non_movable_cells_are_not_adapted() {
377 let mut map = costmap();
378 map.mark_unknown(2, 1).unwrap();
379 let changed = map
380 .update_movable_costs(&[(2, 1)], MotionProgressObservation::stuck(0.5))
381 .unwrap();
382
383 assert_eq!(changed, 0);
384 assert_eq!(
385 map.cell(2, 1).unwrap().state,
386 AdaptiveCostmapCellState::Unknown
387 );
388 }
389
390 #[test]
391 fn traversal_risk_conversion_blocks_static_and_lethal_movable() {
392 let mut map = costmap();
393 map.mark_static_obstacle(1, 1).unwrap();
394 map.mark_movable_obstacle(2, 1).unwrap();
395 for _ in 0..3 {
396 map.update_movable_costs(&[(2, 1)], MotionProgressObservation::stuck(0.5))
397 .unwrap();
398 }
399 let terrain = map.to_traversal_risk_cells(true);
400
401 assert!(terrain[1][1].blocked);
402 assert!(terrain[2][1].blocked);
403 assert_eq!(terrain[2][1].exposure_risk, 1.0);
404 }
405
406 #[test]
407 fn rejects_invalid_config_and_coordinates() {
408 let config = AdaptiveCostmapNamoConfig::new(0, 4);
409 assert!(matches!(
410 AdaptiveCostmapNamo::new(config),
411 Err(RoboticsError::InvalidParameter(_))
412 ));
413 let mut map = costmap();
414 assert!(matches!(
415 map.mark_movable_obstacle(10, 1),
416 Err(RoboticsError::InvalidParameter(_))
417 ));
418 }
419}