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 any
11//! `Fn(SRobotQ<N, f64>, SRobotQ<N, f64>) -> f64` scoring a transition between
12//! two configurations. [`weighted_euclidean`], [`planned_path_length`] and
13//! [`planned_trajectory_time`] build the common ones (cheap joint distance,
14//! planner arc length, retimed trajectory time). The planner passed to
15//! [`plan_multipath`] is only used to *generate* connector paths in the output
16//! — [`plan_multipath_straight`] emits validated straight-line connectors
17//! instead.
18//!
19//! If you already hold a precomputed `option × option` cost matrix and want the
20//! chosen option index per cluster (rather than stitched paths), call
21//! [`solve_matrix`] / [`solve_matrix_multi_start`] directly.
22//!
23//! ```no_run
24//! # use deke_multipath::*;
25//! # use deke_types::{SRobotQ, SRobotPath, Planner, Validator};
26//! # fn go<'ctx, const N: usize, P, V, MW>(
27//! #     paths: Vec<ReqPath<N>>, planner: &P, cfg: &P::Config, make_wp: MW,
28//! #     validator: &V, ctx: &V::Context<'ctx>, start: SRobotQ<N, f64>, weights: SRobotQ<N, f64>,
29//! # ) -> MultipathResult<Vec<SRobotPath<N, f64>>>
30//! # where P: Planner<N, f64> + Sync, P::Config: Sync, V: Validator<N, (), f64>,
31//! #       V::Context<'ctx>: Sync,
32//! #       MW: Fn(SRobotQ<N, f64>, SRobotQ<N, f64>) -> P::Waypoints + Sync {
33//! let settings = MultiPathSettings::new(start);
34//! let cost = weighted_euclidean(weights);
35//! let transition = TransitionPlanner { planner, config: cfg, make_waypoints: make_wp };
36//! plan_multipath(&paths, &cost, &settings, &transition, validator, ctx)
37//! # }
38//! ```
39//!
40//! # Feature flags
41//!
42//! - `rayon` — use rayon to plan connectors concurrently and to fan out the
43//!   heuristic's seed tours. Worth enabling when connector planning is the
44//!   bottleneck (real collision-checked RRT, where each plan costs milliseconds)
45//!   or for large heuristic instances; for trivially cheap connectors the
46//!   thread-pool dispatch can cost more than it saves. The exact Held–Karp
47//!   solver stays sequential — its layers depend on one another, so it does not
48//!   parallelize without a structural rewrite. Enabling it adds `Sync` bounds on
49//!   the planner, its config, the waypoint builder, and the validator context
50//!   (all satisfied by the deke planners/validators).
51
52mod agtsp;
53mod cost;
54mod error;
55mod reqpath;
56
57use std::cmp::Ordering;
58use std::collections::HashMap;
59
60use deke_types::{DekeError, Planner, SRobotPath, SRobotQ, Validator};
61
62use agtsp::Problem;
63use cost::build_matrices;
64use reqpath::{DirectedOption, expand};
65
66pub use cost::{
67    planned_path_length, planned_trajectory_time, weighted_distance, weighted_euclidean,
68};
69pub use error::{MultipathError, MultipathResult};
70pub use reqpath::ReqPath;
71
72/// Default Held–Karp cell budget: above this many DP cells the solver switches
73/// from exact to the nearest-neighbour + 2-opt heuristic. `16 * 1024 * 1024`
74/// cells ≈ 256 MB.
75pub const DEFAULT_CELL_BUDGET: usize = agtsp::DEFAULT_CELL_BUDGET;
76
77/// `Sync` exactly when the `rayon` feature is enabled. The public API requires
78/// thread-safety only when work is actually dispatched across the rayon pool;
79/// the sequential default build imposes no such bound. Blanket-implemented for
80/// every type, so callers never name or implement it — it only appears in the
81/// public bounds, hence `#[doc(hidden)] pub`.
82#[doc(hidden)]
83#[cfg(feature = "rayon")]
84pub trait MaybeSync: Sync {}
85#[cfg(feature = "rayon")]
86impl<T: Sync> MaybeSync for T {}
87
88#[doc(hidden)]
89#[cfg(not(feature = "rayon"))]
90pub trait MaybeSync {}
91#[cfg(not(feature = "rayon"))]
92impl<T> MaybeSync for T {}
93
94/// Knobs for a multipath solve. Build with [`MultiPathSettings::new`].
95pub struct MultiPathSettings<const N: usize> {
96    /// Configuration the motion starts from.
97    pub start: SRobotQ<N, f64>,
98    /// Optional configuration the motion must finish at.
99    pub end: Option<SRobotQ<N, f64>>,
100    /// Connectors whose endpoints coincide within this (unweighted) joint
101    /// distance are skipped — no point planning a move that goes nowhere.
102    pub dedup_tol: f64,
103    /// Above this many DP cells the solver switches from exact Held–Karp to the
104    /// nearest-neighbour + 2-opt heuristic.
105    pub exact_cell_budget: usize,
106}
107
108impl<const N: usize> MultiPathSettings<N> {
109    pub fn new(start: SRobotQ<N, f64>) -> Self {
110        Self {
111            start,
112            end: None,
113            dedup_tol: 1e-6,
114            exact_cell_budget: agtsp::DEFAULT_CELL_BUDGET,
115        }
116    }
117
118    pub fn with_end(mut self, end: SRobotQ<N, f64>) -> Self {
119        self.end = Some(end);
120        self
121    }
122}
123
124/// Everything needed to generate real connector paths with a deke planner. The
125/// `make_waypoints` closure bridges the planner's associated `Waypoints` type —
126/// for the RRT planners that is `|s, e| StartEnd { start: s, end: e }`.
127pub struct TransitionPlanner<'a, const N: usize, P, MW>
128where
129    P: Planner<N, f64>,
130{
131    pub planner: &'a P,
132    pub config: &'a P::Config,
133    pub make_waypoints: MW,
134}
135
136/// Solve the ordering and stitch the full motion using `transition` to plan
137/// obstacle-aware connectors between the chosen paths.
138pub fn plan_multipath<'ctx, const N: usize, P, V, MW, C>(
139    req_paths: &[ReqPath<N>],
140    cost: &C,
141    settings: &MultiPathSettings<N>,
142    transition: &TransitionPlanner<'_, N, P, MW>,
143    validator: &V,
144    ctx: &V::Context<'ctx>,
145) -> MultipathResult<Vec<SRobotPath<N, f64>>>
146where
147    P: Planner<N, f64> + MaybeSync,
148    P::Config: MaybeSync,
149    V: Validator<N, (), f64>,
150    V::Context<'ctx>: MaybeSync,
151    MW: Fn(SRobotQ<N, f64>, SRobotQ<N, f64>) -> P::Waypoints + MaybeSync,
152    C: Fn(SRobotQ<N, f64>, SRobotQ<N, f64>) -> f64,
153{
154    run(req_paths, cost, settings, |from, to| {
155        let waypoints = (transition.make_waypoints)(*from, *to);
156        let (path, _diag) =
157            transition
158                .planner
159                .plan::<DekeError, _>(transition.config, &waypoints, validator, ctx);
160        Ok(path?)
161    })
162}
163
164/// Solve the ordering and stitch the full motion using straight joint-space
165/// connectors. Each connector is checked with `validator.validate_motion`; if a
166/// straight line between two required paths is infeasible the solve fails (there
167/// is no planner to route around the obstacle).
168pub fn plan_multipath_straight<'ctx, const N: usize, V, C>(
169    req_paths: &[ReqPath<N>],
170    cost: &C,
171    settings: &MultiPathSettings<N>,
172    validator: &V,
173    ctx: &V::Context<'ctx>,
174) -> MultipathResult<Vec<SRobotPath<N, f64>>>
175where
176    V: Validator<N, (), f64>,
177    V::Context<'ctx>: MaybeSync,
178    C: Fn(SRobotQ<N, f64>, SRobotQ<N, f64>) -> f64,
179{
180    run(req_paths, cost, settings, |from, to| {
181        validator.validate_motion(&[*from, *to], ctx)?;
182        Ok(SRobotPath::from_two(*from, *to))
183    })
184}
185
186/// Solve the asymmetric generalized TSP directly over a precomputed cost matrix,
187/// returning the chosen option index per cluster in visiting order and the total
188/// cost — without expanding paths or stitching motion. This is the bare ordering
189/// core for callers that already have their own cost structure and only want the
190/// selection and order back.
191///
192/// - `cluster_ids[i]` is the cluster option `i` belongs to. Cluster labels may be
193///   any `usize`; they are densified internally, so the returned indices are
194///   positions into `cluster_ids` regardless of how the clusters are numbered.
195/// - `transition` is the `option × option` matrix where `(i, j)` is the cost to
196///   move from option `i` to option `j` (fold option `j`'s own traversal cost
197///   into the entry if you want the solver to prefer cheaper realizations). Must
198///   be square with side `cluster_ids.len()`.
199/// - `start[i]` is the cost to begin the tour at option `i`; `end[i]` the cost to
200///   finish at it. `None` means a zero vector (no start/end bias).
201/// - Non-finite entries mark infeasible edges and are routed around.
202/// - `cell_budget` switches exact Held–Karp → heuristic above that many DP cells;
203///   pass [`DEFAULT_CELL_BUDGET`] for the standard 16M-cell cap.
204///
205/// Returns `None` if no feasible tour visits every cluster exactly once.
206pub fn solve_matrix(
207    cluster_ids: &[usize],
208    transition: &[Vec<f64>],
209    start: Option<&[f64]>,
210    end: Option<&[f64]>,
211    cell_budget: usize,
212) -> Option<(Vec<usize>, f64)> {
213    let prepared = MatrixProblem::new(cluster_ids, transition, start, end);
214    let solution = agtsp::solve(&prepared.problem(&prepared.start), cell_budget)?;
215    Some((solution.order, solution.cost))
216}
217
218/// Multi-start, top-k variant of [`solve_matrix`]: run one solve per starting
219/// cluster (each constrained to begin its tour at that cluster) and return the
220/// `k` cheapest tours found, ascending by cost. Useful when a single optimum is
221/// not enough — e.g. choosing among near-equal orderings by a downstream metric
222/// the cost matrix does not capture.
223///
224/// Arguments are identical to [`solve_matrix`]. The per-start solves are
225/// independent and fan out across the rayon pool when the `rayon` feature is
226/// enabled. At most one tour is returned per starting cluster, so the result has
227/// at most `min(k, n_clusters)` entries.
228pub fn solve_matrix_multi_start(
229    cluster_ids: &[usize],
230    transition: &[Vec<f64>],
231    start: Option<&[f64]>,
232    end: Option<&[f64]>,
233    cell_budget: usize,
234    k: usize,
235) -> Vec<(Vec<usize>, f64)> {
236    if k == 0 {
237        return Vec::new();
238    }
239    let prepared = MatrixProblem::new(cluster_ids, transition, start, end);
240
241    // Force the tour to begin in cluster `c` by leaving only that cluster's
242    // start edges finite. One independent solve per starting cluster.
243    let solve_from = |c: usize| -> Option<(Vec<usize>, f64)> {
244        let start: Vec<f64> = prepared
245            .cluster_ids
246            .iter()
247            .zip(&prepared.start)
248            .map(|(&ci, &s)| if ci == c { s } else { f64::INFINITY })
249            .collect();
250        let solution = agtsp::solve(&prepared.problem(&start), cell_budget)?;
251        Some((solution.order, solution.cost))
252    };
253
254    let mut tours: Vec<(Vec<usize>, f64)> = {
255        #[cfg(feature = "rayon")]
256        {
257            use rayon::prelude::*;
258            (0..prepared.n_clusters)
259                .into_par_iter()
260                .filter_map(solve_from)
261                .collect()
262        }
263        #[cfg(not(feature = "rayon"))]
264        {
265            (0..prepared.n_clusters).filter_map(solve_from).collect()
266        }
267    };
268
269    tours.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal));
270    tours.truncate(k);
271    tours
272}
273
274/// Owned, validated inputs for the matrix-level AGTSP entry points. Holds the
275/// densified cluster labels and the flattened row-major matrix / start / end
276/// vectors so a borrowing [`Problem`] can be built (once per solve) against them.
277struct MatrixProblem {
278    cluster_ids: Vec<usize>,
279    n_clusters: usize,
280    options: usize,
281    transition: Vec<f64>,
282    start: Vec<f64>,
283    end: Vec<f64>,
284}
285
286impl MatrixProblem {
287    fn new(
288        cluster_ids: &[usize],
289        transition: &[Vec<f64>],
290        start: Option<&[f64]>,
291        end: Option<&[f64]>,
292    ) -> Self {
293        let options = cluster_ids.len();
294        debug_assert_eq!(
295            transition.len(),
296            options,
297            "transition must be square with side cluster_ids.len()"
298        );
299        debug_assert!(
300            transition.iter().all(|row| row.len() == options),
301            "transition rows must each have length cluster_ids.len()"
302        );
303        let (dense, n_clusters) = densify_clusters(cluster_ids);
304        let flat = transition
305            .iter()
306            .flat_map(|row| row.iter().copied())
307            .collect();
308        let resolve = |v: Option<&[f64]>| match v {
309            Some(s) => {
310                debug_assert_eq!(s.len(), options, "start/end length must equal option count");
311                s.to_vec()
312            }
313            None => vec![0.0; options],
314        };
315        MatrixProblem {
316            cluster_ids: dense,
317            n_clusters,
318            options,
319            transition: flat,
320            start: resolve(start),
321            end: resolve(end),
322        }
323    }
324
325    fn problem<'a>(&'a self, start: &'a [f64]) -> Problem<'a> {
326        Problem {
327            cluster_ids: &self.cluster_ids,
328            n_clusters: self.n_clusters,
329            transition: &self.transition,
330            options: self.options,
331            start,
332            end: &self.end,
333        }
334    }
335}
336
337/// Remap arbitrary cluster labels onto a dense `0..k` range (in order of first
338/// appearance), returning the remapped labels and `k`. The Held–Karp bitmask
339/// indexes clusters by bit position, so labels must be dense and zero-based.
340fn densify_clusters(cluster_ids: &[usize]) -> (Vec<usize>, usize) {
341    let mut map: HashMap<usize, usize> = HashMap::new();
342    let dense = cluster_ids
343        .iter()
344        .map(|&c| {
345            let next = map.len();
346            *map.entry(c).or_insert(next)
347        })
348        .collect();
349    (dense, map.len())
350}
351
352fn run<const N: usize, Cost, Conn>(
353    req_paths: &[ReqPath<N>],
354    cost: &Cost,
355    settings: &MultiPathSettings<N>,
356    connect: Conn,
357) -> MultipathResult<Vec<SRobotPath<N, f64>>>
358where
359    Cost: Fn(SRobotQ<N, f64>, SRobotQ<N, f64>) -> f64,
360    Conn: Fn(&SRobotQ<N, f64>, &SRobotQ<N, f64>) -> MultipathResult<SRobotPath<N, f64>> + MaybeSync,
361{
362    let (options, n_clusters) = expand(req_paths)?;
363    let matrices = build_matrices(&options, cost, &settings.start, settings.end.as_ref());
364    let cluster_ids: Vec<usize> = options.iter().map(|o| o.cluster).collect();
365    let problem = Problem {
366        cluster_ids: &cluster_ids,
367        n_clusters,
368        transition: &matrices.transition,
369        options: options.len(),
370        start: &matrices.start,
371        end: &matrices.end,
372    };
373    let solution =
374        agtsp::solve(&problem, settings.exact_cell_budget).ok_or(MultipathError::NoFeasibleTour)?;
375    tracing::debug!(
376        n_required = req_paths.len(),
377        n_options = options.len(),
378        cost = solution.cost,
379        "deke-multipath: ordering solved"
380    );
381    assemble(options, &solution.order, settings, connect)
382}
383
384/// One element of the stitched plan before connectors are realized: either a
385/// chosen required path (already oriented) or a connector that still needs to be
386/// planned between two configurations.
387enum Segment<const N: usize> {
388    Connector(SRobotQ<N, f64>, SRobotQ<N, f64>),
389    Required(SRobotPath<N, f64>),
390}
391
392fn assemble<const N: usize, C>(
393    options: Vec<DirectedOption<N>>,
394    order: &[usize],
395    settings: &MultiPathSettings<N>,
396    connect: C,
397) -> MultipathResult<Vec<SRobotPath<N, f64>>>
398where
399    C: Fn(&SRobotQ<N, f64>, &SRobotQ<N, f64>) -> MultipathResult<SRobotPath<N, f64>> + MaybeSync,
400{
401    // Lay out the plan as connector/required segments. Connectors are
402    // independent point-to-point plans, so they can be realized concurrently
403    // (the costly RRT case); the required paths just pass through. The tour
404    // visits each option at most once, so the chosen paths are moved out rather
405    // than cloned.
406    let mut options: Vec<Option<DirectedOption<N>>> = options.into_iter().map(Some).collect();
407    let mut segments: Vec<Segment<N>> = Vec::new();
408    let mut cursor = settings.start;
409    for &oi in order {
410        let path = options[oi]
411            .take()
412            .expect("each option appears once in the tour")
413            .path;
414        let first = *path.first();
415        if cursor.distance(&first) > settings.dedup_tol {
416            segments.push(Segment::Connector(cursor, first));
417        }
418        cursor = *path.last();
419        segments.push(Segment::Required(path));
420    }
421    if let Some(end) = settings.end
422        && cursor.distance(&end) > settings.dedup_tol
423    {
424        segments.push(Segment::Connector(cursor, end));
425    }
426
427    let realize = |segment: Segment<N>| -> MultipathResult<SRobotPath<N, f64>> {
428        match segment {
429            Segment::Connector(from, to) => connect(&from, &to),
430            Segment::Required(path) => Ok(path),
431        }
432    };
433
434    #[cfg(feature = "rayon")]
435    {
436        use rayon::prelude::*;
437        segments.into_par_iter().map(realize).collect()
438    }
439    #[cfg(not(feature = "rayon"))]
440    {
441        segments.into_iter().map(realize).collect()
442    }
443}