1use std::cmp::Ordering;
8use std::collections::{BinaryHeap, HashMap, HashSet};
9
10use rust_robotics_core::{RoboticsError, RoboticsResult};
11
12use crate::stl_cbs::{
13 first_conflict, stl_pairwise_separation_robustness, StlCbsConflict, StlCbsConflictKind,
14 StlCbsPath, StlTimeInterval, StlTimedCell,
15};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
19pub enum KinodynamicHeading2D {
20 East,
21 North,
22 West,
23 South,
24}
25
26impl KinodynamicHeading2D {
27 pub fn left(self) -> Self {
28 match self {
29 Self::East => Self::North,
30 Self::North => Self::West,
31 Self::West => Self::South,
32 Self::South => Self::East,
33 }
34 }
35
36 pub fn right(self) -> Self {
37 match self {
38 Self::East => Self::South,
39 Self::South => Self::West,
40 Self::West => Self::North,
41 Self::North => Self::East,
42 }
43 }
44
45 pub fn reverse(self) -> Self {
46 self.left().left()
47 }
48
49 pub fn delta(self) -> (i32, i32) {
50 match self {
51 Self::East => (1, 0),
52 Self::North => (0, 1),
53 Self::West => (-1, 0),
54 Self::South => (0, -1),
55 }
56 }
57
58 pub fn label(self) -> &'static str {
59 match self {
60 Self::East => "E",
61 Self::North => "N",
62 Self::West => "W",
63 Self::South => "S",
64 }
65 }
66
67 pub fn angle_degrees(self) -> f64 {
68 match self {
69 Self::East => 0.0,
70 Self::North => -90.0,
71 Self::West => 180.0,
72 Self::South => 90.0,
73 }
74 }
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
79pub struct KinodynamicTimedPose2D {
80 pub x: i32,
81 pub y: i32,
82 pub heading: KinodynamicHeading2D,
83 pub t: u64,
84}
85
86impl KinodynamicTimedPose2D {
87 pub fn new(x: i32, y: i32, heading: KinodynamicHeading2D, t: u64) -> Self {
88 Self { x, y, heading, t }
89 }
90
91 pub fn cell(self) -> StlTimedCell {
92 StlTimedCell::new(self.x, self.y, self.t)
93 }
94}
95
96#[derive(Debug, Clone, Copy, PartialEq)]
98pub struct KinodynamicContinuousPose2D {
99 pub x: f64,
100 pub y: f64,
101 pub t: f64,
102}
103
104impl KinodynamicContinuousPose2D {
105 pub fn new(x: f64, y: f64, t: f64) -> Self {
106 Self { x, y, t }
107 }
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub struct KinodynamicStlCbsAgent2D {
113 pub id: usize,
114 pub start: (i32, i32, KinodynamicHeading2D),
115 pub goal: (i32, i32),
116 pub goal_heading: Option<KinodynamicHeading2D>,
117}
118
119impl KinodynamicStlCbsAgent2D {
120 pub fn new(
121 id: usize,
122 start: (i32, i32),
123 heading: KinodynamicHeading2D,
124 goal: (i32, i32),
125 ) -> Self {
126 Self {
127 id,
128 start: (start.0, start.1, heading),
129 goal,
130 goal_heading: None,
131 }
132 }
133
134 pub fn with_goal_heading(mut self, heading: KinodynamicHeading2D) -> Self {
135 self.goal_heading = Some(heading);
136 self
137 }
138}
139
140#[derive(Debug, Clone, PartialEq)]
142pub struct KinodynamicStlCbsConfig2D {
143 pub width: i32,
144 pub height: i32,
145 pub obstacle_map: Vec<Vec<bool>>,
146 pub max_time: u64,
147 pub max_cbs_nodes: usize,
148 pub move_duration: u64,
149 pub turn_duration: u64,
150 pub wait_duration: u64,
151 pub allow_wait: bool,
152 pub allow_reverse: bool,
153}
154
155impl KinodynamicStlCbsConfig2D {
156 pub fn new(width: i32, height: i32) -> Self {
157 Self {
158 width,
159 height,
160 obstacle_map: vec![vec![false; height.max(0) as usize]; width.max(0) as usize],
161 max_time: 96,
162 max_cbs_nodes: 2_048,
163 move_duration: 2,
164 turn_duration: 1,
165 wait_duration: 1,
166 allow_wait: true,
167 allow_reverse: false,
168 }
169 }
170}
171
172#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct KinodynamicStlCbsPath2D {
175 pub agent_id: usize,
176 pub poses: Vec<KinodynamicTimedPose2D>,
177}
178
179impl KinodynamicStlCbsPath2D {
180 pub fn arrival_time(&self) -> u64 {
181 self.poses.last().map_or(0, |pose| pose.t)
182 }
183
184 pub fn pose_at(&self, t: u64) -> KinodynamicTimedPose2D {
185 self.poses
186 .iter()
187 .find(|pose| pose.t == t)
188 .copied()
189 .or_else(|| self.poses.iter().rev().find(|pose| pose.t < t).copied())
190 .unwrap_or_else(|| {
191 *self
192 .poses
193 .first()
194 .expect("validated kinodynamic STL-CBS path is non-empty")
195 })
196 }
197
198 pub fn continuous_pose_at(&self, t: f64) -> KinodynamicContinuousPose2D {
199 let first = *self
200 .poses
201 .first()
202 .expect("validated kinodynamic STL-CBS path is non-empty");
203 if t <= first.t as f64 {
204 return KinodynamicContinuousPose2D::new(first.x as f64, first.y as f64, t);
205 }
206 let last = *self
207 .poses
208 .last()
209 .expect("validated kinodynamic STL-CBS path is non-empty");
210 if t >= last.t as f64 {
211 return KinodynamicContinuousPose2D::new(last.x as f64, last.y as f64, t);
212 }
213 for window in self.poses.windows(2) {
214 let from = window[0];
215 let to = window[1];
216 if t >= from.t as f64 && t <= to.t as f64 {
217 let dt = (to.t - from.t) as f64;
218 let alpha = if dt <= f64::EPSILON {
219 0.0
220 } else {
221 (t - from.t as f64) / dt
222 };
223 return KinodynamicContinuousPose2D::new(
224 from.x as f64 + (to.x - from.x) as f64 * alpha,
225 from.y as f64 + (to.y - from.y) as f64 * alpha,
226 t,
227 );
228 }
229 }
230 KinodynamicContinuousPose2D::new(last.x as f64, last.y as f64, t)
231 }
232
233 pub fn cell_path(&self) -> StlCbsPath {
234 StlCbsPath {
235 agent_id: self.agent_id,
236 waypoints: self.poses.iter().map(|pose| pose.cell()).collect(),
237 }
238 }
239}
240
241#[derive(Debug, Clone, PartialEq)]
243pub struct KinodynamicStlCbsPlan2D {
244 pub paths: Vec<KinodynamicStlCbsPath2D>,
245 pub cell_paths: Vec<StlCbsPath>,
246 pub total_cost: u64,
247 pub conflicts_resolved: usize,
248 pub continuous_conflicts_resolved: usize,
249 pub high_level_nodes_expanded: usize,
250 pub min_pairwise_separation_robustness: f64,
251 pub min_continuous_pairwise_separation_robustness: f64,
252 pub first_continuous_conflict: Option<KinodynamicContinuousConflict2D>,
253}
254
255#[derive(Debug, Clone, Copy, PartialEq)]
257pub struct KinodynamicContinuousConflict2D {
258 pub agent_a: usize,
259 pub agent_b: usize,
260 pub t: f64,
261 pub distance: f64,
262 pub min_distance: f64,
263 pub ax: f64,
264 pub ay: f64,
265 pub bx: f64,
266 pub by: f64,
267}
268
269#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
270enum KinodynamicConstraintKind {
271 Vertex {
272 x: i32,
273 y: i32,
274 t: u64,
275 },
276 Edge {
277 from_x: i32,
278 from_y: i32,
279 to_x: i32,
280 to_y: i32,
281 t: u64,
282 },
283}
284
285#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
286struct KinodynamicConstraint {
287 agent_id: usize,
288 kind: KinodynamicConstraintKind,
289}
290
291#[derive(Debug, Clone, PartialEq, Eq)]
292struct CbsNode {
293 constraints: Vec<KinodynamicConstraint>,
294 paths: Vec<KinodynamicStlCbsPath2D>,
295 total_cost: u64,
296 conflicts_resolved: usize,
297 continuous_conflicts_resolved: usize,
298}
299
300#[derive(Debug, Clone, Copy, PartialEq, Eq)]
301struct CbsOpenEntry {
302 node_index: usize,
303 total_cost: u64,
304 conflicts_resolved: usize,
305}
306
307impl Ord for CbsOpenEntry {
308 fn cmp(&self, other: &Self) -> Ordering {
309 other
310 .total_cost
311 .cmp(&self.total_cost)
312 .then_with(|| other.conflicts_resolved.cmp(&self.conflicts_resolved))
313 .then_with(|| other.node_index.cmp(&self.node_index))
314 }
315}
316
317impl PartialOrd for CbsOpenEntry {
318 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
319 Some(self.cmp(other))
320 }
321}
322
323#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
324struct SearchState {
325 x: i32,
326 y: i32,
327 heading: KinodynamicHeading2D,
328 t: u64,
329}
330
331#[derive(Debug, Clone, Copy, PartialEq, Eq)]
332struct SearchOpenEntry {
333 state: SearchState,
334 f: u64,
335 g: u64,
336}
337
338impl Ord for SearchOpenEntry {
339 fn cmp(&self, other: &Self) -> Ordering {
340 other
341 .f
342 .cmp(&self.f)
343 .then_with(|| other.g.cmp(&self.g))
344 .then_with(|| other.state.t.cmp(&self.state.t))
345 .then_with(|| other.state.heading.cmp(&self.state.heading))
346 }
347}
348
349impl PartialOrd for SearchOpenEntry {
350 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
351 Some(self.cmp(other))
352 }
353}
354
355#[derive(Debug, Clone, PartialEq, Eq)]
356struct ParentLink {
357 previous: Option<SearchState>,
358 samples: Vec<KinodynamicTimedPose2D>,
359}
360
361#[derive(Debug, Clone, PartialEq)]
362pub struct KinodynamicStlCbsPlanner2D {
363 config: KinodynamicStlCbsConfig2D,
364}
365
366impl KinodynamicStlCbsPlanner2D {
367 pub fn new(config: KinodynamicStlCbsConfig2D) -> RoboticsResult<Self> {
368 validate_config(&config)?;
369 Ok(Self { config })
370 }
371
372 pub fn config(&self) -> &KinodynamicStlCbsConfig2D {
373 &self.config
374 }
375
376 pub fn plan_independent(
377 &self,
378 agents: &[KinodynamicStlCbsAgent2D],
379 ) -> RoboticsResult<Vec<KinodynamicStlCbsPath2D>> {
380 self.validate_agents(agents)?;
381 agents
382 .iter()
383 .map(|agent| self.plan_agent(*agent, &[]))
384 .collect()
385 }
386
387 pub fn plan(
388 &self,
389 agents: &[KinodynamicStlCbsAgent2D],
390 ) -> RoboticsResult<KinodynamicStlCbsPlan2D> {
391 self.validate_agents(agents)?;
392 let root_paths = self.plan_independent(agents)?;
393 let root = CbsNode {
394 total_cost: total_path_cost(&root_paths),
395 constraints: Vec::new(),
396 paths: root_paths,
397 conflicts_resolved: 0,
398 continuous_conflicts_resolved: 0,
399 };
400
401 let mut nodes = vec![root];
402 let mut open = BinaryHeap::new();
403 open.push(CbsOpenEntry {
404 node_index: 0,
405 total_cost: nodes[0].total_cost,
406 conflicts_resolved: 0,
407 });
408 let mut expanded = 0;
409
410 while let Some(entry) = open.pop() {
411 expanded += 1;
412 if expanded > self.config.max_cbs_nodes {
413 return Err(RoboticsError::PlanningError(
414 "kinodynamic STL-CBS exceeded high-level node limit".to_string(),
415 ));
416 }
417 let node = nodes[entry.node_index].clone();
418 let cell_paths = cell_paths_from(&node.paths);
419 if let Some(conflict) = first_conflict(&cell_paths, self.config.max_time) {
420 for constrained_agent in [conflict.agent_a, conflict.agent_b] {
421 let mut constraints = node.constraints.clone();
422 constraints.push(constraint_from_conflict(conflict, constrained_agent));
423 let agent = *agents
424 .iter()
425 .find(|agent| agent.id == constrained_agent)
426 .expect("validated conflict references a known agent");
427 let agent_constraints = constraints_for_agent(&constraints, constrained_agent);
428 let Ok(replanned_path) = self.plan_agent(agent, &agent_constraints) else {
429 continue;
430 };
431 let mut paths = node.paths.clone();
432 let path_index = paths
433 .iter()
434 .position(|path| path.agent_id == constrained_agent)
435 .expect("validated node contains all agent paths");
436 paths[path_index] = replanned_path;
437 let child = CbsNode {
438 total_cost: total_path_cost(&paths),
439 constraints,
440 paths,
441 conflicts_resolved: node.conflicts_resolved + 1,
442 continuous_conflicts_resolved: node.continuous_conflicts_resolved,
443 };
444 let node_index = nodes.len();
445 open.push(CbsOpenEntry {
446 node_index,
447 total_cost: child.total_cost,
448 conflicts_resolved: child.conflicts_resolved,
449 });
450 nodes.push(child);
451 }
452 } else if let Some(continuous_conflict) =
453 first_kinodynamic_continuous_conflict(&node.paths, 1.0, self.config.max_time)?
454 {
455 for constrained_agent in [continuous_conflict.agent_a, continuous_conflict.agent_b]
459 {
460 let Some(new_constraint) = continuous_constraint_from_conflict(
461 &continuous_conflict,
462 &node.paths,
463 constrained_agent,
464 ) else {
465 continue;
466 };
467 let mut constraints = node.constraints.clone();
468 if node.constraints.contains(&new_constraint) {
469 continue;
472 }
473 constraints.push(new_constraint);
474 let agent = *agents
475 .iter()
476 .find(|agent| agent.id == constrained_agent)
477 .expect("validated conflict references a known agent");
478 let agent_constraints = constraints_for_agent(&constraints, constrained_agent);
479 let Ok(replanned_path) = self.plan_agent(agent, &agent_constraints) else {
480 continue;
481 };
482 let mut paths = node.paths.clone();
483 let path_index = paths
484 .iter()
485 .position(|path| path.agent_id == constrained_agent)
486 .expect("validated node contains all agent paths");
487 paths[path_index] = replanned_path;
488 let child = CbsNode {
489 total_cost: total_path_cost(&paths),
490 constraints,
491 paths,
492 conflicts_resolved: node.conflicts_resolved,
493 continuous_conflicts_resolved: node.continuous_conflicts_resolved + 1,
494 };
495 let node_index = nodes.len();
496 open.push(CbsOpenEntry {
497 node_index,
498 total_cost: child.total_cost,
499 conflicts_resolved: child.conflicts_resolved,
500 });
501 nodes.push(child);
502 }
503 } else {
504 let cell_paths = cell_paths_from(&node.paths);
505 let min_continuous_pairwise_separation_robustness =
506 kinodynamic_continuous_pairwise_separation_robustness(
507 &node.paths,
508 1.0,
509 self.config.max_time,
510 )?;
511 let first_continuous_conflict =
512 first_kinodynamic_continuous_conflict(&node.paths, 1.0, self.config.max_time)?;
513 return Ok(KinodynamicStlCbsPlan2D {
514 total_cost: node.total_cost,
515 min_pairwise_separation_robustness: stl_pairwise_separation_robustness(
516 &cell_paths,
517 1.0,
518 StlTimeInterval {
519 start: 0,
520 end: self.config.max_time,
521 },
522 )?,
523 cell_paths,
524 paths: node.paths,
525 conflicts_resolved: node.conflicts_resolved,
526 continuous_conflicts_resolved: node.continuous_conflicts_resolved,
527 high_level_nodes_expanded: expanded,
528 min_continuous_pairwise_separation_robustness,
529 first_continuous_conflict,
530 });
531 }
532 }
533
534 Err(RoboticsError::PlanningError(
535 "kinodynamic STL-CBS could not find a conflict-free plan".to_string(),
536 ))
537 }
538
539 fn plan_agent(
540 &self,
541 agent: KinodynamicStlCbsAgent2D,
542 constraints: &[KinodynamicConstraintKind],
543 ) -> RoboticsResult<KinodynamicStlCbsPath2D> {
544 if self.violates_vertex_constraint(agent.start.0, agent.start.1, 0, constraints) {
545 return Err(RoboticsError::PlanningError(
546 "agent start violates a kinodynamic CBS vertex constraint".to_string(),
547 ));
548 }
549 let start = SearchState {
550 x: agent.start.0,
551 y: agent.start.1,
552 heading: agent.start.2,
553 t: 0,
554 };
555 let mut open = BinaryHeap::new();
556 let mut best_g = HashMap::new();
557 let mut parent = HashMap::new();
558
559 best_g.insert(start, 0);
560 parent.insert(
561 start,
562 ParentLink {
563 previous: None,
564 samples: vec![KinodynamicTimedPose2D::new(
565 start.x,
566 start.y,
567 start.heading,
568 start.t,
569 )],
570 },
571 );
572 open.push(SearchOpenEntry {
573 state: start,
574 g: 0,
575 f: self.heuristic(start, agent),
576 });
577
578 while let Some(entry) = open.pop() {
579 if entry.state.t > self.config.max_time {
580 continue;
581 }
582 if entry.g > *best_g.get(&entry.state).unwrap_or(&u64::MAX) {
583 continue;
584 }
585 if self.goal_reached(entry.state, agent)
586 && !self.violates_future_hold(entry.state, constraints)
587 {
588 return Ok(KinodynamicStlCbsPath2D {
589 agent_id: agent.id,
590 poses: reconstruct_path(&parent, entry.state, agent.id),
591 });
592 }
593
594 for (next, samples) in self.successors(entry.state) {
595 if next.t > self.config.max_time {
596 continue;
597 }
598 if self.violates_transition(entry.state, &samples, constraints) {
599 continue;
600 }
601 let next_g = entry.g + (next.t - entry.state.t);
602 if next_g < *best_g.get(&next).unwrap_or(&u64::MAX) {
603 best_g.insert(next, next_g);
604 parent.insert(
605 next,
606 ParentLink {
607 previous: Some(entry.state),
608 samples,
609 },
610 );
611 open.push(SearchOpenEntry {
612 state: next,
613 g: next_g,
614 f: next_g + self.heuristic(next, agent),
615 });
616 }
617 }
618 }
619
620 Err(RoboticsError::PlanningError(format!(
621 "no kinodynamic path found for agent {}",
622 agent.id
623 )))
624 }
625
626 fn successors(&self, state: SearchState) -> Vec<(SearchState, Vec<KinodynamicTimedPose2D>)> {
627 let mut successors = Vec::new();
628 if self.config.allow_wait {
629 successors.push(self.same_cell_successor(
630 state,
631 state.heading,
632 self.config.wait_duration,
633 ));
634 }
635 successors.push(self.same_cell_successor(
636 state,
637 state.heading.left(),
638 self.config.turn_duration,
639 ));
640 successors.push(self.same_cell_successor(
641 state,
642 state.heading.right(),
643 self.config.turn_duration,
644 ));
645 if let Some(forward) = self.move_successor(state, state.heading, self.config.move_duration)
646 {
647 successors.push(forward);
648 }
649 if self.config.allow_reverse {
650 if let Some(reverse) =
651 self.move_successor(state, state.heading.reverse(), self.config.move_duration)
652 {
653 successors.push(reverse);
654 }
655 }
656 successors
657 }
658
659 fn same_cell_successor(
660 &self,
661 state: SearchState,
662 heading: KinodynamicHeading2D,
663 duration: u64,
664 ) -> (SearchState, Vec<KinodynamicTimedPose2D>) {
665 let next = SearchState {
666 x: state.x,
667 y: state.y,
668 heading,
669 t: state.t + duration,
670 };
671 let samples = (1..=duration)
672 .map(|dt| KinodynamicTimedPose2D::new(state.x, state.y, heading, state.t + dt))
673 .collect();
674 (next, samples)
675 }
676
677 fn move_successor(
678 &self,
679 state: SearchState,
680 movement_heading: KinodynamicHeading2D,
681 duration: u64,
682 ) -> Option<(SearchState, Vec<KinodynamicTimedPose2D>)> {
683 let (dx, dy) = movement_heading.delta();
684 let nx = state.x + dx;
685 let ny = state.y + dy;
686 if !self.is_free(nx, ny) {
687 return None;
688 }
689 let next = SearchState {
690 x: nx,
691 y: ny,
692 heading: state.heading,
693 t: state.t + duration,
694 };
695 let samples = (1..=duration)
696 .map(|dt| {
697 if dt == duration {
698 KinodynamicTimedPose2D::new(nx, ny, state.heading, state.t + dt)
699 } else {
700 KinodynamicTimedPose2D::new(state.x, state.y, state.heading, state.t + dt)
701 }
702 })
703 .collect();
704 Some((next, samples))
705 }
706
707 fn goal_reached(&self, state: SearchState, agent: KinodynamicStlCbsAgent2D) -> bool {
708 (state.x, state.y) == agent.goal
709 && agent
710 .goal_heading
711 .map_or(true, |goal_heading| goal_heading == state.heading)
712 }
713
714 fn heuristic(&self, state: SearchState, agent: KinodynamicStlCbsAgent2D) -> u64 {
715 manhattan((state.x, state.y), agent.goal) * self.config.move_duration
716 }
717
718 fn violates_transition(
719 &self,
720 state: SearchState,
721 samples: &[KinodynamicTimedPose2D],
722 constraints: &[KinodynamicConstraintKind],
723 ) -> bool {
724 let mut previous = KinodynamicTimedPose2D::new(state.x, state.y, state.heading, state.t);
725 for &sample in samples {
726 if !self.is_free(sample.x, sample.y)
727 || self.violates_vertex_constraint(sample.x, sample.y, sample.t, constraints)
728 {
729 return true;
730 }
731 if (previous.x, previous.y) != (sample.x, sample.y)
732 && self.violates_edge_constraint(
733 previous.x,
734 previous.y,
735 sample.x,
736 sample.y,
737 sample.t,
738 constraints,
739 )
740 {
741 return true;
742 }
743 previous = sample;
744 }
745 false
746 }
747
748 fn violates_future_hold(
749 &self,
750 state: SearchState,
751 constraints: &[KinodynamicConstraintKind],
752 ) -> bool {
753 (state.t..=self.config.max_time)
754 .any(|t| self.violates_vertex_constraint(state.x, state.y, t, constraints))
755 }
756
757 fn violates_vertex_constraint(
758 &self,
759 x: i32,
760 y: i32,
761 t: u64,
762 constraints: &[KinodynamicConstraintKind],
763 ) -> bool {
764 constraints.iter().any(|constraint| {
765 matches!(
766 constraint,
767 KinodynamicConstraintKind::Vertex { x: cx, y: cy, t: ct }
768 if *cx == x && *cy == y && *ct == t
769 )
770 })
771 }
772
773 fn violates_edge_constraint(
774 &self,
775 from_x: i32,
776 from_y: i32,
777 to_x: i32,
778 to_y: i32,
779 t: u64,
780 constraints: &[KinodynamicConstraintKind],
781 ) -> bool {
782 constraints.iter().any(|constraint| {
783 matches!(
784 constraint,
785 KinodynamicConstraintKind::Edge {
786 from_x: cx0,
787 from_y: cy0,
788 to_x: cx1,
789 to_y: cy1,
790 t: ct,
791 } if *cx0 == from_x
792 && *cy0 == from_y
793 && *cx1 == to_x
794 && *cy1 == to_y
795 && *ct == t
796 )
797 })
798 }
799
800 fn validate_agents(&self, agents: &[KinodynamicStlCbsAgent2D]) -> RoboticsResult<()> {
801 if agents.is_empty() {
802 return Err(RoboticsError::InvalidParameter(
803 "kinodynamic STL-CBS requires at least one agent".to_string(),
804 ));
805 }
806 let mut ids = HashSet::new();
807 for agent in agents {
808 if !ids.insert(agent.id) {
809 return Err(RoboticsError::InvalidParameter(
810 "kinodynamic STL-CBS agent ids must be unique".to_string(),
811 ));
812 }
813 if !self.is_free(agent.start.0, agent.start.1)
814 || !self.is_free(agent.goal.0, agent.goal.1)
815 {
816 return Err(RoboticsError::InvalidParameter(format!(
817 "agent {} start/goal must be free grid cells",
818 agent.id
819 )));
820 }
821 }
822 Ok(())
823 }
824
825 fn is_free(&self, x: i32, y: i32) -> bool {
826 x >= 0
827 && y >= 0
828 && x < self.config.width
829 && y < self.config.height
830 && !self.config.obstacle_map[x as usize][y as usize]
831 }
832}
833
834fn cell_paths_from(paths: &[KinodynamicStlCbsPath2D]) -> Vec<StlCbsPath> {
835 paths
836 .iter()
837 .map(KinodynamicStlCbsPath2D::cell_path)
838 .collect()
839}
840
841fn constraint_from_conflict(
842 conflict: StlCbsConflict,
843 constrained_agent: usize,
844) -> KinodynamicConstraint {
845 let kind = match conflict.kind {
846 StlCbsConflictKind::Vertex { x, y, t } => KinodynamicConstraintKind::Vertex { x, y, t },
847 StlCbsConflictKind::Edge {
848 ax0,
849 ay0,
850 ax1,
851 ay1,
852 bx0,
853 by0,
854 bx1,
855 by1,
856 t,
857 } => {
858 if constrained_agent == conflict.agent_a {
859 KinodynamicConstraintKind::Edge {
860 from_x: ax0,
861 from_y: ay0,
862 to_x: ax1,
863 to_y: ay1,
864 t,
865 }
866 } else {
867 KinodynamicConstraintKind::Edge {
868 from_x: bx0,
869 from_y: by0,
870 to_x: bx1,
871 to_y: by1,
872 t,
873 }
874 }
875 }
876 };
877 KinodynamicConstraint {
878 agent_id: constrained_agent,
879 kind,
880 }
881}
882
883fn continuous_constraint_from_conflict(
891 conflict: &KinodynamicContinuousConflict2D,
892 paths: &[KinodynamicStlCbsPath2D],
893 constrained_agent: usize,
894) -> Option<KinodynamicConstraint> {
895 let tick = conflict.t.floor() as u64;
896 let path = paths
897 .iter()
898 .find(|path| path.agent_id == constrained_agent)?;
899 let from = path.pose_at(tick);
900 let to = path.pose_at(tick + 1);
901 let kind = if (from.x, from.y) == (to.x, to.y) {
902 KinodynamicConstraintKind::Vertex {
903 x: from.x,
904 y: from.y,
905 t: tick + 1,
906 }
907 } else {
908 KinodynamicConstraintKind::Edge {
909 from_x: from.x,
910 from_y: from.y,
911 to_x: to.x,
912 to_y: to.y,
913 t: tick + 1,
914 }
915 };
916 Some(KinodynamicConstraint {
917 agent_id: constrained_agent,
918 kind,
919 })
920}
921
922fn constraints_for_agent(
923 constraints: &[KinodynamicConstraint],
924 agent_id: usize,
925) -> Vec<KinodynamicConstraintKind> {
926 constraints
927 .iter()
928 .filter(|constraint| constraint.agent_id == agent_id)
929 .map(|constraint| constraint.kind)
930 .collect()
931}
932
933fn total_path_cost(paths: &[KinodynamicStlCbsPath2D]) -> u64 {
934 paths
935 .iter()
936 .map(KinodynamicStlCbsPath2D::arrival_time)
937 .sum()
938}
939
940pub fn kinodynamic_continuous_pairwise_separation_robustness(
941 paths: &[KinodynamicStlCbsPath2D],
942 min_distance: f64,
943 max_time: u64,
944) -> RoboticsResult<f64> {
945 validate_continuous_query(paths, min_distance)?;
946 if paths.len() < 2 {
947 return Ok(f64::INFINITY);
948 }
949 let mut robustness = f64::INFINITY;
950 for t in 0..max_time {
951 let t0 = t as f64;
952 let t1 = (t + 1) as f64;
953 for i in 0..paths.len() {
954 for j in i + 1..paths.len() {
955 let a0 = paths[i].continuous_pose_at(t0);
956 let a1 = paths[i].continuous_pose_at(t1);
957 let b0 = paths[j].continuous_pose_at(t0);
958 let b1 = paths[j].continuous_pose_at(t1);
959 let (_, distance) = closest_relative_segment_distance(a0, a1, b0, b1);
960 robustness = robustness.min(distance - min_distance);
961 }
962 }
963 }
964 Ok(robustness)
965}
966
967pub fn first_kinodynamic_continuous_conflict(
968 paths: &[KinodynamicStlCbsPath2D],
969 min_distance: f64,
970 max_time: u64,
971) -> RoboticsResult<Option<KinodynamicContinuousConflict2D>> {
972 validate_continuous_query(paths, min_distance)?;
973 if paths.len() < 2 {
974 return Ok(None);
975 }
976 for t in 0..max_time {
977 let t0 = t as f64;
978 let t1 = (t + 1) as f64;
979 for i in 0..paths.len() {
980 for j in i + 1..paths.len() {
981 let a0 = paths[i].continuous_pose_at(t0);
982 let a1 = paths[i].continuous_pose_at(t1);
983 let b0 = paths[j].continuous_pose_at(t0);
984 let b1 = paths[j].continuous_pose_at(t1);
985 if let Some((alpha, distance)) =
986 earliest_continuous_violation_alpha(a0, a1, b0, b1, min_distance)
987 {
988 let time = t0 + alpha;
989 let a = interpolate_continuous_pose(a0, a1, alpha);
990 let b = interpolate_continuous_pose(b0, b1, alpha);
991 return Ok(Some(KinodynamicContinuousConflict2D {
992 agent_a: paths[i].agent_id,
993 agent_b: paths[j].agent_id,
994 t: time,
995 distance,
996 min_distance,
997 ax: a.x,
998 ay: a.y,
999 bx: b.x,
1000 by: b.y,
1001 }));
1002 }
1003 }
1004 }
1005 }
1006 Ok(None)
1007}
1008
1009fn validate_continuous_query(
1010 paths: &[KinodynamicStlCbsPath2D],
1011 min_distance: f64,
1012) -> RoboticsResult<()> {
1013 if !min_distance.is_finite() || min_distance < 0.0 {
1014 return Err(RoboticsError::InvalidParameter(
1015 "kinodynamic continuous min_distance must be finite and non-negative".to_string(),
1016 ));
1017 }
1018 for path in paths {
1019 if path.poses.is_empty() {
1020 return Err(RoboticsError::InvalidParameter(
1021 "kinodynamic continuous path must contain poses".to_string(),
1022 ));
1023 }
1024 for window in path.poses.windows(2) {
1025 if window[1].t <= window[0].t {
1026 return Err(RoboticsError::InvalidParameter(
1027 "kinodynamic continuous path times must be strictly increasing".to_string(),
1028 ));
1029 }
1030 }
1031 }
1032 Ok(())
1033}
1034
1035fn closest_relative_segment_distance(
1036 a0: KinodynamicContinuousPose2D,
1037 a1: KinodynamicContinuousPose2D,
1038 b0: KinodynamicContinuousPose2D,
1039 b1: KinodynamicContinuousPose2D,
1040) -> (f64, f64) {
1041 let r0x = a0.x - b0.x;
1042 let r0y = a0.y - b0.y;
1043 let vx = (a1.x - a0.x) - (b1.x - b0.x);
1044 let vy = (a1.y - a0.y) - (b1.y - b0.y);
1045 let vv = vx * vx + vy * vy;
1046 let alpha = if vv <= f64::EPSILON {
1047 0.0
1048 } else {
1049 (-(r0x * vx + r0y * vy) / vv).clamp(0.0, 1.0)
1050 };
1051 let dx = r0x + vx * alpha;
1052 let dy = r0y + vy * alpha;
1053 (alpha, (dx * dx + dy * dy).sqrt())
1054}
1055
1056fn earliest_continuous_violation_alpha(
1057 a0: KinodynamicContinuousPose2D,
1058 a1: KinodynamicContinuousPose2D,
1059 b0: KinodynamicContinuousPose2D,
1060 b1: KinodynamicContinuousPose2D,
1061 min_distance: f64,
1062) -> Option<(f64, f64)> {
1063 let r0x = a0.x - b0.x;
1064 let r0y = a0.y - b0.y;
1065 let vx = (a1.x - a0.x) - (b1.x - b0.x);
1066 let vy = (a1.y - a0.y) - (b1.y - b0.y);
1067 let (_, closest_distance) = closest_relative_segment_distance(a0, a1, b0, b1);
1068 if closest_distance >= min_distance - 1.0e-9 {
1069 return None;
1070 }
1071 let radius2 = min_distance * min_distance;
1072 let start2 = r0x * r0x + r0y * r0y;
1073 if start2 < radius2 {
1074 return Some((0.0, start2.sqrt()));
1075 }
1076
1077 let a = vx * vx + vy * vy;
1078 if a <= f64::EPSILON {
1079 return None;
1080 }
1081 let b = 2.0 * (r0x * vx + r0y * vy);
1082 let c = start2 - radius2;
1083 let discriminant = b * b - 4.0 * a * c;
1084 if discriminant < 0.0 {
1085 return None;
1086 }
1087 let sqrt_discriminant = discriminant.sqrt();
1088 let mut roots = [
1089 (-b - sqrt_discriminant) / (2.0 * a),
1090 (-b + sqrt_discriminant) / (2.0 * a),
1091 ];
1092 roots.sort_by(|left, right| left.partial_cmp(right).unwrap_or(Ordering::Equal));
1093 for alpha in roots {
1094 if (-f64::EPSILON..=1.0 + f64::EPSILON).contains(&alpha) {
1095 let alpha = alpha.clamp(0.0, 1.0);
1096 let dx = r0x + vx * alpha;
1097 let dy = r0y + vy * alpha;
1098 return Some((alpha, (dx * dx + dy * dy).sqrt()));
1099 }
1100 }
1101 None
1102}
1103
1104fn interpolate_continuous_pose(
1105 from: KinodynamicContinuousPose2D,
1106 to: KinodynamicContinuousPose2D,
1107 alpha: f64,
1108) -> KinodynamicContinuousPose2D {
1109 KinodynamicContinuousPose2D::new(
1110 from.x + (to.x - from.x) * alpha,
1111 from.y + (to.y - from.y) * alpha,
1112 from.t + (to.t - from.t) * alpha,
1113 )
1114}
1115
1116fn reconstruct_path(
1117 parent: &HashMap<SearchState, ParentLink>,
1118 mut current: SearchState,
1119 agent_id: usize,
1120) -> Vec<KinodynamicTimedPose2D> {
1121 let mut chunks = Vec::new();
1122 loop {
1123 let link = parent
1124 .get(¤t)
1125 .expect("search parent map contains every reached state");
1126 chunks.push(link.samples.clone());
1127 match link.previous {
1128 Some(previous) => current = previous,
1129 None => break,
1130 }
1131 }
1132 chunks.reverse();
1133 let _ = agent_id;
1134 chunks.into_iter().flatten().collect()
1135}
1136
1137fn manhattan(a: (i32, i32), b: (i32, i32)) -> u64 {
1138 (a.0 - b.0).unsigned_abs() as u64 + (a.1 - b.1).unsigned_abs() as u64
1139}
1140
1141fn validate_config(config: &KinodynamicStlCbsConfig2D) -> RoboticsResult<()> {
1142 if config.width <= 0 || config.height <= 0 {
1143 return Err(RoboticsError::InvalidParameter(
1144 "kinodynamic STL-CBS width and height must be positive".to_string(),
1145 ));
1146 }
1147 if config.max_time == 0
1148 || config.max_cbs_nodes == 0
1149 || config.move_duration == 0
1150 || config.turn_duration == 0
1151 || config.wait_duration == 0
1152 {
1153 return Err(RoboticsError::InvalidParameter(
1154 "kinodynamic STL-CBS time limits and primitive durations must be positive".to_string(),
1155 ));
1156 }
1157 if config.obstacle_map.len() != config.width as usize {
1158 return Err(RoboticsError::InvalidParameter(
1159 "kinodynamic STL-CBS obstacle_map x-dimension must match width".to_string(),
1160 ));
1161 }
1162 for column in &config.obstacle_map {
1163 if column.len() != config.height as usize {
1164 return Err(RoboticsError::InvalidParameter(
1165 "kinodynamic STL-CBS obstacle_map y-dimension must match height".to_string(),
1166 ));
1167 }
1168 }
1169 Ok(())
1170}
1171
1172#[cfg(test)]
1173mod tests {
1174 use super::*;
1175
1176 fn planner() -> KinodynamicStlCbsPlanner2D {
1177 KinodynamicStlCbsPlanner2D::new(KinodynamicStlCbsConfig2D {
1178 max_time: 32,
1179 max_cbs_nodes: 4_096,
1180 ..KinodynamicStlCbsConfig2D::new(7, 5)
1181 })
1182 .unwrap()
1183 }
1184
1185 #[test]
1186 fn heading_primitives_make_paths_time_aware() {
1187 let planner = planner();
1188 let agent = KinodynamicStlCbsAgent2D::new(0, (0, 1), KinodynamicHeading2D::East, (2, 2));
1189 let path = planner.plan_independent(&[agent]).unwrap().remove(0);
1190
1191 assert!(path.arrival_time() > manhattan((0, 1), (2, 2)));
1192 assert!(path
1193 .poses
1194 .iter()
1195 .any(|pose| pose.heading == KinodynamicHeading2D::North));
1196 }
1197
1198 #[test]
1199 fn goal_heading_is_respected() {
1200 let planner = planner();
1201 let agent = KinodynamicStlCbsAgent2D::new(0, (0, 0), KinodynamicHeading2D::East, (2, 0))
1202 .with_goal_heading(KinodynamicHeading2D::West);
1203 let path = planner.plan_independent(&[agent]).unwrap().remove(0);
1204
1205 assert_eq!(
1206 path.poses.last().unwrap().heading,
1207 KinodynamicHeading2D::West
1208 );
1209 }
1210
1211 #[test]
1212 fn continuous_conflict_detects_between_tick_crossing() {
1213 let paths = vec![
1214 KinodynamicStlCbsPath2D {
1215 agent_id: 0,
1216 poses: vec![
1217 KinodynamicTimedPose2D::new(0, 0, KinodynamicHeading2D::East, 0),
1218 KinodynamicTimedPose2D::new(1, 1, KinodynamicHeading2D::East, 1),
1219 ],
1220 },
1221 KinodynamicStlCbsPath2D {
1222 agent_id: 1,
1223 poses: vec![
1224 KinodynamicTimedPose2D::new(0, 1, KinodynamicHeading2D::East, 0),
1225 KinodynamicTimedPose2D::new(1, 0, KinodynamicHeading2D::East, 1),
1226 ],
1227 },
1228 ];
1229
1230 assert!(first_conflict(&cell_paths_from(&paths), 1).is_none());
1231 let robustness =
1232 kinodynamic_continuous_pairwise_separation_robustness(&paths, 0.2, 1).unwrap();
1233 let conflict = first_kinodynamic_continuous_conflict(&paths, 0.2, 1)
1234 .unwrap()
1235 .unwrap();
1236
1237 assert!(robustness < 0.0);
1238 assert!((conflict.t - 0.4).abs() < 0.11);
1239 assert_eq!(conflict.agent_a, 0);
1240 assert_eq!(conflict.agent_b, 1);
1241 }
1242
1243 #[test]
1248 fn cbs_resolves_continuous_only_perpendicular_conflict() {
1249 let planner = KinodynamicStlCbsPlanner2D::new(KinodynamicStlCbsConfig2D {
1250 max_time: 48,
1251 max_cbs_nodes: 8_192,
1252 move_duration: 1,
1253 turn_duration: 1,
1254 wait_duration: 1,
1255 allow_reverse: true,
1256 ..KinodynamicStlCbsConfig2D::new(5, 5)
1257 })
1258 .unwrap();
1259 let agents = [
1260 KinodynamicStlCbsAgent2D::new(0, (0, 2), KinodynamicHeading2D::East, (4, 2)),
1261 KinodynamicStlCbsAgent2D::new(1, (2, 0), KinodynamicHeading2D::North, (2, 4)),
1262 ];
1263
1264 let independent = planner.plan_independent(&agents).unwrap();
1266 assert!(first_conflict(&cell_paths_from(&independent), 48).is_some());
1267
1268 let plan = planner.plan(&agents).unwrap();
1269
1270 assert!(
1272 plan.continuous_conflicts_resolved > 0,
1273 "expected at least one continuous-conflict CBS branch"
1274 );
1275 assert!(first_conflict(&plan.cell_paths, 48).is_none());
1277 assert!(plan.first_continuous_conflict.is_none());
1278 assert!(plan.min_continuous_pairwise_separation_robustness >= 0.0);
1279 for (agent, path) in agents.iter().zip(plan.paths.iter()) {
1281 let last = path.poses.last().unwrap();
1282 assert_eq!((last.x, last.y), agent.goal);
1283 }
1284 }
1285
1286 #[test]
1287 fn cbs_resolves_oriented_corridor_conflict() {
1288 let planner = planner();
1289 let agents = [
1290 KinodynamicStlCbsAgent2D::new(0, (0, 2), KinodynamicHeading2D::East, (6, 2)),
1291 KinodynamicStlCbsAgent2D::new(1, (6, 2), KinodynamicHeading2D::West, (0, 2)),
1292 ];
1293 let independent = planner.plan_independent(&agents).unwrap();
1294 assert!(first_conflict(&cell_paths_from(&independent), 32).is_some());
1295
1296 let plan = planner.plan(&agents).unwrap();
1297 assert!(plan.conflicts_resolved > 0);
1298 assert!(first_conflict(&plan.cell_paths, 32).is_none());
1299 assert!(plan.min_pairwise_separation_robustness >= 0.0);
1300 assert!(plan.min_continuous_pairwise_separation_robustness >= 0.0);
1301 assert!(plan.first_continuous_conflict.is_none());
1302 }
1303}