Skip to main content

deke_linear/
planner.rs

1//! Stage B — resolve a conditioned [`CartesianRun`] into a continuous joint-space
2//! path by analytic-IK branch tracking.
3//!
4//! Each densely-sampled pose is inverted with the chain's analytic IK, which
5//! returns every isolated branch already filtered to joint limits — no Jacobian
6//! inversion, so it cannot blow up near singularities. A dynamic program over the
7//! branch layers chooses a globally continuous, well-conditioned track: a
8//! manipulability term steers away from singular configurations and an edge term
9//! penalises joint motion while rejecting discontinuous wrist flips.
10
11use deke_types::{
12    ContinuousFKChain, DekeError, DekeResult, IkOutcome, IkSolver, Planner, SRobotPath, SRobotQ,
13    Validator,
14};
15
16use crate::constraints::PlannerOptions;
17use crate::diagnostic::LinearPlannerDiagnostic;
18use crate::error::LinearError;
19use crate::path::CartesianRun;
20use crate::util::ladder_dp;
21
22/// Analytic-IK branch-tracking planner over a single conditioned run.
23#[derive(Clone, Debug)]
24pub struct CartesianLinearPlanner<'a, const N: usize, FK> {
25    fk: &'a FK,
26}
27
28impl<'a, const N: usize, FK> CartesianLinearPlanner<'a, N, FK>
29where
30    FK: ContinuousFKChain<N, f64> + IkSolver<N, f64>,
31{
32    pub fn new(fk: &'a FK) -> Self {
33        Self { fk }
34    }
35
36    /// Plan a continuous joint path through `run`, sampled every
37    /// `opts.sample_ds` of arc length. Every candidate configuration is checked
38    /// against `validator`; rejected ones are dropped from the DP, so the planner
39    /// routes through collision-free IK branches.
40    ///
41    /// `seed` anchors the first sample to a configuration the run must continue
42    /// from (the previous run's final pose), so stitched runs stay continuous in
43    /// joint space across a corner.
44    pub(crate) fn plan_run<V: Validator<N, (), f64>>(
45        &self,
46        run: &CartesianRun,
47        opts: &PlannerOptions<N>,
48        validator: &V,
49        ctx: &V::Context<'_>,
50        seed: Option<&SRobotQ<N, f64>>,
51        run_idx: usize,
52    ) -> Result<(SRobotPath<N, f64>, LinearPlannerDiagnostic), LinearError> {
53        let length = run.length();
54        let n = ((length / opts.sample_ds).ceil() as usize).max(1) + 1;
55
56        // Per sample: analytic IK + manipulability node cost for each branch that
57        // passes the validator. `(q, node_cost, manipulability)`.
58        let mut layers: Vec<Vec<(SRobotQ<N, f64>, f64, f64)>> = Vec::with_capacity(n);
59        for i in 0..n {
60            let s = (i as f64 * opts.sample_ds).min(length);
61            let pose = run.eval(s);
62            let sols = match self.fk.ik(pose)? {
63                IkOutcome::Solved(sols) if !sols.is_empty() => sols,
64                _ => return Err(LinearError::Unreachable { run: run_idx, s }),
65            };
66            let mut nodes = Vec::with_capacity(sols.len());
67            for q in sols {
68                if validator.validate(q, ctx).is_err() {
69                    continue;
70                }
71                let w = self.fk.manipulability(&q).map_err(LinearError::Deke)?;
72                nodes.push((q, opts.manip_weight / (w + 1e-9), w));
73            }
74            if nodes.is_empty() {
75                return Err(LinearError::Obstructed { run: run_idx, s });
76            }
77            layers.push(nodes);
78        }
79
80        let min_manip = layers
81            .iter()
82            .flat_map(|l| l.iter().map(|&(_, _, w)| w))
83            .fold(f64::INFINITY, f64::min);
84
85        let ds_at =
86            |k: usize| (k as f64 * opts.sample_ds).min(length) - ((k - 1) as f64 * opts.sample_ds);
87        let layer_sizes: Vec<usize> = layers.iter().map(Vec::len).collect();
88        let (chosen, total) = ladder_dp(
89            &layer_sizes,
90            |k, i| {
91                let (q0, nc, _) = layers[k][i];
92                match seed {
93                    Some(s) if k == 0 => {
94                        if is_reconfiguration(s, &q0, f64::INFINITY, opts) {
95                            f64::INFINITY
96                        } else {
97                            nc + s.distance(&q0)
98                        }
99                    }
100                    _ => nc,
101                }
102            },
103            |k, pi, ci| {
104                let qp = layers[k - 1][pi].0;
105                let qc = layers[k][ci].0;
106                if is_reconfiguration(&qp, &qc, ds_at(k), opts) {
107                    None
108                } else {
109                    Some(qp.distance(&qc))
110                }
111            },
112        )
113        .ok_or(LinearError::NoContinuousTrack { run: run_idx })?;
114
115        let track: Vec<SRobotQ<N, f64>> = chosen
116            .iter()
117            .enumerate()
118            .map(|(k, &i)| layers[k][i].0)
119            .collect();
120        let path = SRobotPath::try_new(track).map_err(LinearError::from)?;
121        Ok((
122            path,
123            LinearPlannerDiagnostic {
124                samples: n,
125                min_manipulability: min_manip,
126                total_cost: total,
127            },
128        ))
129    }
130}
131
132impl<'a, const N: usize, FK> Planner<N, f64> for CartesianLinearPlanner<'a, N, FK>
133where
134    FK: ContinuousFKChain<N, f64> + IkSolver<N, f64>,
135{
136    type Diagnostic = LinearPlannerDiagnostic;
137    type Config = PlannerOptions<N>;
138    type Waypoints = CartesianRun;
139
140    fn plan<E: Into<DekeError>, V: Validator<N, (), f64>>(
141        &self,
142        config: &Self::Config,
143        waypoints: &Self::Waypoints,
144        validator: &V,
145        ctx: &V::Context<'_>,
146    ) -> (DekeResult<SRobotPath<N, f64>>, Self::Diagnostic) {
147        match self.plan_run(waypoints, config, validator, ctx, None, 0) {
148            Ok((path, diag)) => (Ok(path), diag),
149            Err(e) => (
150                Err(e.into()),
151                LinearPlannerDiagnostic {
152                    samples: 0,
153                    min_manipulability: 0.0,
154                    total_cost: f64::INFINITY,
155                },
156            ),
157        }
158    }
159}
160
161/// Is the joint move `a → b` over Cartesian distance `ds` a reconfiguration?
162///
163/// Trips on the absolute continuity guard (`max_branch_jump`) or, when the
164/// velocity test is enabled, when executing the move at `max_velocity` would
165/// drive any joint past `reconfig_vel_fraction` of its velocity limit — at weld
166/// speeds, the signature of a singularity or wrist flip.
167pub(crate) fn is_reconfiguration<const N: usize>(
168    a: &SRobotQ<N, f64>,
169    b: &SRobotQ<N, f64>,
170    ds: f64,
171    opts: &PlannerOptions<N>,
172) -> bool {
173    if (*a - *b).linf_norm() > opts.max_branch_jump {
174        return true;
175    }
176    if opts.max_velocity > 0.0 && ds > 1e-12 {
177        for j in 0..N {
178            let vmax = opts.joint_v_max.0[j];
179            if vmax.is_finite() {
180                let req = (b.0[j] - a.0[j]).abs() * opts.max_velocity / ds;
181                if req > opts.reconfig_vel_fraction * vmax {
182                    return true;
183                }
184            }
185        }
186    }
187    false
188}
189
190#[cfg(test)]
191mod tests {
192    use super::is_reconfiguration;
193    use crate::constraints::PlannerOptions;
194    use deke_types::SRobotQ;
195
196    fn opts(max_velocity: f64, v_max: f64) -> PlannerOptions<2> {
197        PlannerOptions {
198            sample_ds: 2e-3,
199            manip_weight: 1.0,
200            max_branch_jump: 100.0, // disable the absolute guard for these cases
201            max_velocity,
202            joint_v_max: SRobotQ::splat(v_max),
203            reconfig_vel_fraction: 0.9,
204        }
205    }
206
207    #[test]
208    fn velocity_criterion_flags_fast_joint() {
209        let a = SRobotQ::<2, f64>::from_array([0.0, 0.0]);
210        // 0.5 rad over 2 mm at 0.01 m/s → 2.5 rad/s required on joint 0.
211        let b = SRobotQ::<2, f64>::from_array([0.5, 0.0]);
212        // 2.5 > 0.9 * 2.0 = 1.8 → reconfiguration.
213        assert!(is_reconfiguration(&a, &b, 2e-3, &opts(0.01, 2.0)));
214        // A gentle move: 0.01 rad over 2 mm → 0.05 rad/s ≪ 1.8 → fine.
215        let c = SRobotQ::<2, f64>::from_array([0.01, 0.0]);
216        assert!(!is_reconfiguration(&a, &c, 2e-3, &opts(0.01, 2.0)));
217    }
218
219    #[test]
220    fn velocity_criterion_disabled_when_speed_zero() {
221        let a = SRobotQ::<2, f64>::from_array([0.0, 0.0]);
222        let b = SRobotQ::<2, f64>::from_array([0.5, 0.0]);
223        assert!(!is_reconfiguration(&a, &b, 2e-3, &opts(0.0, 2.0)));
224    }
225}