Skip to main content

rust_robotics_planning/
kinodynamic_stl_cbs.rs

1//! Kinodynamic-oriented STL-CBS planning.
2//!
3//! This module extends the grid STL-CBS idea with heading states and
4//! time-consuming motion primitives. It keeps the high-level CBS structure but
5//! replans each constrained agent with an A* search over `(x, y, heading, t)`.
6
7use 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/// Four-connected heading for the kinodynamic grid state.
18#[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/// Timed oriented pose.
78#[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/// Continuous pose sampled between timed grid poses.
97#[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/// Agent query with start heading and optional terminal heading.
111#[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/// Planner configuration.
141#[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/// One kinodynamic agent path.
173#[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/// Kinodynamic CBS result.
242#[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/// Continuous-time pairwise conflict found between integer ticks.
256#[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                // Integer-time CBS is clean, but the continuous-time occupancy
456                // check between ticks still found a violation. Convert it into
457                // a high-level CBS branch on each involved agent.
458                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                        // The same continuous constraint is already active; adding
470                        // it again would not change the replan, so skip this branch.
471                        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
883/// Turn a continuous-time conflict into a discrete CBS constraint for one agent.
884///
885/// The conflict happens at continuous time `t`. Within the integer interval
886/// `[floor(t), floor(t) + 1]` the constrained agent either holds a cell or
887/// traverses an edge. We forbid exactly that behavior at the end tick so the
888/// replan must change its sub-tick timing (typically by waiting), which removes
889/// the between-tick near-miss instead of merely shifting it.
890fn 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(&current)
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    /// Two agents cross perpendicularly through a shared center cell. Integer-time
1244    /// CBS can separate them at every integer tick, yet a between-tick near-miss
1245    /// remains. This exercises the continuous-conflict CBS branch: the plan must
1246    /// resolve at least one continuous conflict and end continuously safe.
1247    #[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        // Independent plans collide on the grid, so plain integer CBS is engaged.
1265        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        // The between-tick crossing must have been handled by the continuous branch.
1271        assert!(
1272            plan.continuous_conflicts_resolved > 0,
1273            "expected at least one continuous-conflict CBS branch"
1274        );
1275        // The final plan is clean at every integer tick and in continuous time.
1276        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        // Both agents still reach their goals.
1280        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}