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