Skip to main content

deke_multipath/
lib.rs

1//! Optimal ordering and orientation of *required* robot paths.
2//!
3//! Given a set of paths that must each be traversed exactly once — some of
4//! which may be reversible or have several interchangeable realizations — this
5//! crate chooses one option per required path and orders them to minimise total
6//! motion cost, starting at a configuration and optionally ending at one. It
7//! returns the full stitched motion: connector segments interleaved with the
8//! chosen required paths.
9//!
10//! The ordering is an asymmetric generalized TSP (see [`agtsp`]); cost is a
11//! pluggable joint-space metric ([`TransitionCost`]). The planner is only used
12//! to *generate* connector paths and is optional — [`plan_multipath`] uses a
13//! planner for obstacle-aware connectors, [`plan_multipath_straight`] emits
14//! validated straight-line connectors instead.
15//!
16//! ```no_run
17//! # use deke_multipath::*;
18//! # use deke_types::{SRobotQ, SRobotPath, Planner, Validator};
19//! # fn go<'ctx, const N: usize, P, V, MW>(
20//! #     paths: Vec<ReqPath<N>>, planner: &P, cfg: &P::Config, make_wp: MW,
21//! #     validator: &V, ctx: &V::Context<'ctx>, start: SRobotQ<N, f64>, weights: SRobotQ<N, f64>,
22//! # ) -> MultipathResult<Vec<SRobotPath<N, f64>>>
23//! # where P: Planner<N, f64> + Sync, P::Config: Sync, V: Validator<N, (), f64>,
24//! #       V::Context<'ctx>: Sync,
25//! #       MW: Fn(SRobotQ<N, f64>, SRobotQ<N, f64>) -> P::Waypoints + Sync {
26//! let settings = MultiPathSettings::new(start);
27//! let cost = TransitionCost::JointWeighted(weights);
28//! let transition = TransitionPlanner { planner, config: cfg, make_waypoints: make_wp };
29//! plan_multipath(&paths, &cost, &settings, &transition, validator, ctx)
30//! # }
31//! ```
32//!
33//! # Feature flags
34//!
35//! - `rayon` — use rayon to plan connectors concurrently and to fan out the
36//!   heuristic's seed tours. Worth enabling when connector planning is the
37//!   bottleneck (real collision-checked RRT, where each plan costs milliseconds)
38//!   or for large heuristic instances; for trivially cheap connectors the
39//!   thread-pool dispatch can cost more than it saves. The exact Held–Karp
40//!   solver stays sequential — its layers depend on one another, so it does not
41//!   parallelize without a structural rewrite. Enabling it adds `Sync` bounds on
42//!   the planner, its config, the waypoint builder, and the validator context
43//!   (all satisfied by the deke planners/validators).
44
45mod agtsp;
46mod cost;
47mod error;
48mod reqpath;
49
50use deke_types::{DekeError, Planner, SRobotPath, SRobotQ, Validator};
51
52use agtsp::Problem;
53use cost::build_matrices;
54use reqpath::{DirectedOption, expand};
55
56pub use cost::{TransitionCost, weighted_distance};
57pub use error::{MultipathError, MultipathResult};
58pub use reqpath::ReqPath;
59
60/// `Sync` exactly when the `rayon` feature is enabled. The public API requires
61/// thread-safety only when work is actually dispatched across the rayon pool;
62/// the sequential default build imposes no such bound. Blanket-implemented for
63/// every type, so callers never name or implement it — it only appears in the
64/// public bounds, hence `#[doc(hidden)] pub`.
65#[doc(hidden)]
66#[cfg(feature = "rayon")]
67pub trait MaybeSync: Sync {}
68#[cfg(feature = "rayon")]
69impl<T: Sync> MaybeSync for T {}
70
71#[doc(hidden)]
72#[cfg(not(feature = "rayon"))]
73pub trait MaybeSync {}
74#[cfg(not(feature = "rayon"))]
75impl<T> MaybeSync for T {}
76
77/// Knobs for a multipath solve. Build with [`MultiPathSettings::new`].
78pub struct MultiPathSettings<const N: usize> {
79    /// Configuration the motion starts from.
80    pub start: SRobotQ<N, f64>,
81    /// Optional configuration the motion must finish at.
82    pub end: Option<SRobotQ<N, f64>>,
83    /// Connectors whose endpoints coincide within this (unweighted) joint
84    /// distance are skipped — no point planning a move that goes nowhere.
85    pub dedup_tol: f64,
86    /// Above this many DP cells the solver switches from exact Held–Karp to the
87    /// nearest-neighbour + 2-opt heuristic.
88    pub exact_cell_budget: usize,
89}
90
91impl<const N: usize> MultiPathSettings<N> {
92    pub fn new(start: SRobotQ<N, f64>) -> Self {
93        Self {
94            start,
95            end: None,
96            dedup_tol: 1e-6,
97            exact_cell_budget: agtsp::DEFAULT_CELL_BUDGET,
98        }
99    }
100
101    pub fn with_end(mut self, end: SRobotQ<N, f64>) -> Self {
102        self.end = Some(end);
103        self
104    }
105}
106
107/// Everything needed to generate real connector paths with a deke planner. The
108/// `make_waypoints` closure bridges the planner's associated `Waypoints` type —
109/// for the RRT planners that is `|s, e| StartEnd { start: s, end: e }`.
110pub struct TransitionPlanner<'a, const N: usize, P, MW>
111where
112    P: Planner<N, f64>,
113{
114    pub planner: &'a P,
115    pub config: &'a P::Config,
116    pub make_waypoints: MW,
117}
118
119/// Solve the ordering and stitch the full motion using `transition` to plan
120/// obstacle-aware connectors between the chosen paths.
121pub fn plan_multipath<'ctx, const N: usize, P, V, MW>(
122    req_paths: &[ReqPath<N>],
123    cost: &TransitionCost<N>,
124    settings: &MultiPathSettings<N>,
125    transition: &TransitionPlanner<'_, N, P, MW>,
126    validator: &V,
127    ctx: &V::Context<'ctx>,
128) -> MultipathResult<Vec<SRobotPath<N, f64>>>
129where
130    P: Planner<N, f64> + MaybeSync,
131    P::Config: MaybeSync,
132    V: Validator<N, (), f64>,
133    V::Context<'ctx>: MaybeSync,
134    MW: Fn(SRobotQ<N, f64>, SRobotQ<N, f64>) -> P::Waypoints + MaybeSync,
135{
136    run(req_paths, cost, settings, |from, to| {
137        let waypoints = (transition.make_waypoints)(*from, *to);
138        let (path, _diag) =
139            transition
140                .planner
141                .plan::<DekeError, _>(transition.config, &waypoints, validator, ctx);
142        Ok(path?)
143    })
144}
145
146/// Solve the ordering and stitch the full motion using straight joint-space
147/// connectors. Each connector is checked with `validator.validate_motion`; if a
148/// straight line between two required paths is infeasible the solve fails (there
149/// is no planner to route around the obstacle).
150pub fn plan_multipath_straight<'ctx, const N: usize, V>(
151    req_paths: &[ReqPath<N>],
152    cost: &TransitionCost<N>,
153    settings: &MultiPathSettings<N>,
154    validator: &V,
155    ctx: &V::Context<'ctx>,
156) -> MultipathResult<Vec<SRobotPath<N, f64>>>
157where
158    V: Validator<N, (), f64>,
159    V::Context<'ctx>: MaybeSync,
160{
161    run(req_paths, cost, settings, |from, to| {
162        validator.validate_motion(&[*from, *to], ctx)?;
163        Ok(SRobotPath::from_two(*from, *to))
164    })
165}
166
167fn run<const N: usize, C>(
168    req_paths: &[ReqPath<N>],
169    cost: &TransitionCost<N>,
170    settings: &MultiPathSettings<N>,
171    connect: C,
172) -> MultipathResult<Vec<SRobotPath<N, f64>>>
173where
174    C: Fn(&SRobotQ<N, f64>, &SRobotQ<N, f64>) -> MultipathResult<SRobotPath<N, f64>> + MaybeSync,
175{
176    let (options, n_clusters) = expand(req_paths)?;
177    let matrices = build_matrices(&options, cost, &settings.start, settings.end.as_ref());
178    let cluster_ids: Vec<usize> = options.iter().map(|o| o.cluster).collect();
179    let problem = Problem {
180        cluster_ids: &cluster_ids,
181        n_clusters,
182        transition: &matrices.transition,
183        options: options.len(),
184        start: &matrices.start,
185        end: &matrices.end,
186    };
187    let solution =
188        agtsp::solve(&problem, settings.exact_cell_budget).ok_or(MultipathError::NoFeasibleTour)?;
189    tracing::debug!(
190        n_required = req_paths.len(),
191        n_options = options.len(),
192        cost = solution.cost,
193        "deke-multipath: ordering solved"
194    );
195    assemble(options, &solution.order, settings, connect)
196}
197
198/// One element of the stitched plan before connectors are realized: either a
199/// chosen required path (already oriented) or a connector that still needs to be
200/// planned between two configurations.
201enum Segment<const N: usize> {
202    Connector(SRobotQ<N, f64>, SRobotQ<N, f64>),
203    Required(SRobotPath<N, f64>),
204}
205
206fn assemble<const N: usize, C>(
207    options: Vec<DirectedOption<N>>,
208    order: &[usize],
209    settings: &MultiPathSettings<N>,
210    connect: C,
211) -> MultipathResult<Vec<SRobotPath<N, f64>>>
212where
213    C: Fn(&SRobotQ<N, f64>, &SRobotQ<N, f64>) -> MultipathResult<SRobotPath<N, f64>> + MaybeSync,
214{
215    // Lay out the plan as connector/required segments. Connectors are
216    // independent point-to-point plans, so they can be realized concurrently
217    // (the costly RRT case); the required paths just pass through. The tour
218    // visits each option at most once, so the chosen paths are moved out rather
219    // than cloned.
220    let mut options: Vec<Option<DirectedOption<N>>> = options.into_iter().map(Some).collect();
221    let mut segments: Vec<Segment<N>> = Vec::new();
222    let mut cursor = settings.start;
223    for &oi in order {
224        let path = options[oi]
225            .take()
226            .expect("each option appears once in the tour")
227            .path;
228        let first = *path.first();
229        if cursor.distance(&first) > settings.dedup_tol {
230            segments.push(Segment::Connector(cursor, first));
231        }
232        cursor = *path.last();
233        segments.push(Segment::Required(path));
234    }
235    if let Some(end) = settings.end
236        && cursor.distance(&end) > settings.dedup_tol
237    {
238        segments.push(Segment::Connector(cursor, end));
239    }
240
241    let realize = |segment: Segment<N>| -> MultipathResult<SRobotPath<N, f64>> {
242        match segment {
243            Segment::Connector(from, to) => connect(&from, &to),
244            Segment::Required(path) => Ok(path),
245        }
246    };
247
248    #[cfg(feature = "rayon")]
249    {
250        use rayon::prelude::*;
251        segments.into_par_iter().map(realize).collect()
252    }
253    #[cfg(not(feature = "rayon"))]
254    {
255        segments.into_iter().map(realize).collect()
256    }
257}