Skip to main content

deke_linear/
redundant.rs

1//! Redundancy-resolving planner for a tool that is rotationally symmetric about
2//! one of its axes (a welding torch, spray head, …).
3//!
4//! The free rotation about the tool's symmetry axis (`yaw` `ψ`) is a continuous
5//! redundant DOF. It is a smooth scalar, so it is gridded coarsely and resolved by
6//! a single global DP over `(station) × (yaw × branch)` — exact, so it finds the
7//! globally optimal yaw track in one pass. A manipulability node cost steers off
8//! singularities; a yaw-rate edge penalty keeps the spin smooth; the velocity
9//! reconfiguration test rejects discontinuous edges. The coarse `ψ(s)` schedule is
10//! then refined: at fine arc-length spacing the orientation is
11//! `R_ref(s) · Rot(â, ψ(s))` and analytic IK places the arm exactly, picking the
12//! branch nearest the previous step (predictor–corrector).
13
14use deke_types::glam::{DAffine3, DQuat, DVec3};
15use deke_types::{
16    ContinuousFKChain, DekeError, DekeResult, IkOutcome, IkSolver, Planner, SRobotPath, SRobotQ,
17    Validator,
18};
19
20use crate::constraints::PlannerOptions;
21use crate::diagnostic::RedundantDiagnostic;
22use crate::error::LinearError;
23use crate::path::CartesianRun;
24use crate::planner::is_reconfiguration;
25use crate::util::{interp, ladder_dp};
26
27/// The tool-frame axis the tool is symmetric about (its free rotation DOF). The
28/// sign matters only for the orientation convention; the rotation axis itself is
29/// sign-agnostic.
30#[derive(Clone, Copy, Debug, PartialEq)]
31pub enum RedundantAxis {
32    PosX,
33    NegX,
34    PosY,
35    NegY,
36    PosZ,
37    NegZ,
38    /// An arbitrary unit axis in the tool frame.
39    Custom(DVec3),
40}
41
42impl RedundantAxis {
43    /// Unit axis vector in the tool frame.
44    pub fn vector(&self) -> DVec3 {
45        match self {
46            RedundantAxis::PosX => DVec3::X,
47            RedundantAxis::NegX => DVec3::NEG_X,
48            RedundantAxis::PosY => DVec3::Y,
49            RedundantAxis::NegY => DVec3::NEG_Y,
50            RedundantAxis::PosZ => DVec3::Z,
51            RedundantAxis::NegZ => DVec3::NEG_Z,
52            RedundantAxis::Custom(v) => v.normalize_or_zero(),
53        }
54    }
55}
56
57/// Knobs for the redundancy-resolving yaw search.
58#[derive(Clone, Debug)]
59pub struct RedundantOptions {
60    /// Tool symmetry axis (free rotation DOF).
61    pub axis: RedundantAxis,
62    /// Overall allowed yaw range (radians) relative to the reference orientation.
63    pub yaw_window: (f64, f64),
64    /// Yaw samples across the whole window (the DP grid resolution).
65    pub yaw_samples: usize,
66    /// DP station spacing (metres).
67    pub dp_ds: f64,
68    /// Edge penalty weight on yaw rate `|Δψ|/Δs` (smoother spin).
69    pub yaw_rate_weight: f64,
70    /// Maximum yaw change between DP stations (radians).
71    pub max_yaw_step: f64,
72}
73
74impl Default for RedundantOptions {
75    fn default() -> Self {
76        Self {
77            axis: RedundantAxis::PosZ,
78            yaw_window: (-std::f64::consts::PI, std::f64::consts::PI),
79            yaw_samples: 24,
80            dp_ds: 5e-3,
81            yaw_rate_weight: 0.2,
82            max_yaw_step: 0.6,
83        }
84    }
85}
86
87/// Bundles the branch-tracking knobs and the yaw-search knobs so the redundant
88/// planner can satisfy the single-config [`Planner`] trait.
89#[derive(Clone, Debug)]
90pub struct RedundantConfig<const N: usize> {
91    pub planner: PlannerOptions<N>,
92    pub redundant: RedundantOptions,
93}
94
95struct YawNode<const N: usize> {
96    yaw: f64,
97    q: SRobotQ<N, f64>,
98    cost: f64,
99}
100
101/// Multi-anchor yaw planner over a single conditioned run.
102#[derive(Clone, Debug)]
103pub struct RedundantLinearPlanner<'a, const N: usize, FK> {
104    fk: &'a FK,
105}
106
107impl<'a, const N: usize, FK> RedundantLinearPlanner<'a, N, FK>
108where
109    FK: ContinuousFKChain<N, f64> + IkSolver<N, f64>,
110{
111    pub fn new(fk: &'a FK) -> Self {
112        Self { fk }
113    }
114
115    #[allow(clippy::too_many_arguments)]
116    pub(crate) fn plan_run<V: Validator<N, (), f64>>(
117        &self,
118        run: &CartesianRun,
119        planner: &PlannerOptions<N>,
120        red: &RedundantOptions,
121        validator: &V,
122        ctx: &V::Context<'_>,
123        seed: Option<&SRobotQ<N, f64>>,
124        run_idx: usize,
125    ) -> Result<(SRobotPath<N, f64>, RedundantDiagnostic), LinearError> {
126        let axis = red.axis.vector();
127        let length = run.length();
128        let n_dp = ((length / red.dp_ds).ceil() as usize).max(1) + 1;
129
130        let stations = self.build_stations(
131            run, red, planner, axis, length, n_dp, validator, ctx, run_idx,
132        )?;
133
134        let (coarse_s, coarse_psi) = solve_global(&stations, red, planner, length, n_dp)
135            .ok_or(LinearError::NoContinuousTrack { run: run_idx })?;
136
137        self.refine(
138            run,
139            planner,
140            axis,
141            length,
142            &coarse_s,
143            &coarse_psi,
144            validator,
145            ctx,
146            seed,
147            run_idx,
148        )
149    }
150
151    #[allow(clippy::too_many_arguments)]
152    fn build_stations<V: Validator<N, (), f64>>(
153        &self,
154        run: &CartesianRun,
155        red: &RedundantOptions,
156        planner: &PlannerOptions<N>,
157        axis: DVec3,
158        length: f64,
159        n_dp: usize,
160        validator: &V,
161        ctx: &V::Context<'_>,
162        run_idx: usize,
163    ) -> Result<Vec<Vec<YawNode<N>>>, LinearError> {
164        let samples = red.yaw_samples.max(1);
165        let yaw_at = |m: usize| -> f64 {
166            if samples <= 1 {
167                0.5 * (red.yaw_window.0 + red.yaw_window.1)
168            } else {
169                red.yaw_window.0
170                    + (red.yaw_window.1 - red.yaw_window.0) * m as f64 / (samples - 1) as f64
171            }
172        };
173
174        let mut stations = Vec::with_capacity(n_dp);
175        for k in 0..n_dp {
176            let s = (k as f64 * red.dp_ds).min(length);
177            let ref_pose = run.eval(s);
178            let ref_rot = DQuat::from_mat3(&ref_pose.matrix3);
179            let mut nodes = Vec::with_capacity(samples * 4);
180            let mut had_ik = false;
181            for m in 0..samples {
182                let psi = yaw_at(m);
183                let rot = ref_rot * DQuat::from_axis_angle(axis, psi);
184                let target = DAffine3::from_rotation_translation(rot, ref_pose.translation);
185                if let IkOutcome::Solved(sols) = self.fk.ik(target)? {
186                    had_ik |= !sols.is_empty();
187                    for q in sols {
188                        if validator.validate(q, ctx).is_err() {
189                            continue;
190                        }
191                        let w = self.fk.manipulability(&q).map_err(LinearError::Deke)?;
192                        nodes.push(YawNode {
193                            yaw: psi,
194                            q,
195                            cost: planner.manip_weight / (w + 1e-9),
196                        });
197                    }
198                }
199            }
200            if nodes.is_empty() {
201                // No yaw at this station is both reachable and collision-free.
202                return Err(if had_ik {
203                    LinearError::Obstructed { run: run_idx, s }
204                } else {
205                    LinearError::Unreachable { run: run_idx, s }
206                });
207            }
208            stations.push(nodes);
209        }
210        Ok(stations)
211    }
212
213    #[allow(clippy::too_many_arguments)]
214    fn refine<V: Validator<N, (), f64>>(
215        &self,
216        run: &CartesianRun,
217        planner: &PlannerOptions<N>,
218        axis: DVec3,
219        length: f64,
220        coarse_s: &[f64],
221        coarse_psi: &[f64],
222        validator: &V,
223        ctx: &V::Context<'_>,
224        seed: Option<&SRobotQ<N, f64>>,
225        run_idx: usize,
226    ) -> Result<(SRobotPath<N, f64>, RedundantDiagnostic), LinearError> {
227        let n_fine = ((length / planner.sample_ds).ceil() as usize).max(1) + 1;
228        let mut fine: Vec<SRobotQ<N, f64>> = Vec::with_capacity(n_fine);
229        let mut min_manip = f64::INFINITY;
230        let mut yaw_lo = f64::INFINITY;
231        let mut yaw_hi = f64::NEG_INFINITY;
232        let mut prev: Option<(SRobotQ<N, f64>, f64)> = None;
233
234        for i in 0..n_fine {
235            let s = (i as f64 * planner.sample_ds).min(length);
236            let psi = interp(coarse_s, coarse_psi, s);
237            yaw_lo = yaw_lo.min(psi);
238            yaw_hi = yaw_hi.max(psi);
239            let ref_pose = run.eval(s);
240            let rot = DQuat::from_mat3(&ref_pose.matrix3) * DQuat::from_axis_angle(axis, psi);
241            let target = DAffine3::from_rotation_translation(rot, ref_pose.translation);
242            let raw = match self.fk.ik(target)? {
243                IkOutcome::Solved(sols) if !sols.is_empty() => sols,
244                _ => return Err(LinearError::Unreachable { run: run_idx, s }),
245            };
246            let sols: Vec<SRobotQ<N, f64>> = raw
247                .into_iter()
248                .filter(|q| validator.validate(*q, ctx).is_ok())
249                .collect();
250            if sols.is_empty() {
251                return Err(LinearError::Obstructed { run: run_idx, s });
252            }
253
254            let q = match prev {
255                Some((pq, _)) => sols
256                    .into_iter()
257                    .min_by(|a, b| pq.distance(a).total_cmp(&pq.distance(b)))
258                    .unwrap(),
259                None => match seed {
260                    Some(s) => sols
261                        .into_iter()
262                        .filter(|q| !is_reconfiguration(s, q, f64::INFINITY, planner))
263                        .min_by(|a, b| s.distance(a).total_cmp(&s.distance(b)))
264                        .ok_or(LinearError::NoContinuousTrack { run: run_idx })?,
265                    None => {
266                        let mut best = sols[0];
267                        let mut best_w = -1.0;
268                        for q in sols {
269                            let w = self.fk.manipulability(&q).map_err(LinearError::Deke)?;
270                            if w > best_w {
271                                best_w = w;
272                                best = q;
273                            }
274                        }
275                        best
276                    }
277                },
278            };
279
280            if let Some((pq, ps)) = prev {
281                let dsf = (s - ps).max(1e-12);
282                if is_reconfiguration(&pq, &q, dsf, planner) {
283                    return Err(LinearError::NoContinuousTrack { run: run_idx });
284                }
285            }
286
287            min_manip = min_manip.min(self.fk.manipulability(&q).map_err(LinearError::Deke)?);
288            fine.push(q);
289            prev = Some((q, s));
290        }
291
292        let path = SRobotPath::try_new(fine).map_err(LinearError::from)?;
293        Ok((
294            path,
295            RedundantDiagnostic {
296                samples: n_fine,
297                min_manipulability: min_manip,
298                yaw_range: (yaw_lo, yaw_hi),
299            },
300        ))
301    }
302}
303
304impl<'a, const N: usize, FK> Planner<N, f64> for RedundantLinearPlanner<'a, N, FK>
305where
306    FK: ContinuousFKChain<N, f64> + IkSolver<N, f64>,
307{
308    type Diagnostic = RedundantDiagnostic;
309    type Config = RedundantConfig<N>;
310    type Waypoints = CartesianRun;
311
312    fn plan<E: Into<DekeError>, V: Validator<N, (), f64>>(
313        &self,
314        config: &Self::Config,
315        waypoints: &Self::Waypoints,
316        validator: &V,
317        ctx: &V::Context<'_>,
318    ) -> (DekeResult<SRobotPath<N, f64>>, Self::Diagnostic) {
319        match self.plan_run(
320            waypoints,
321            &config.planner,
322            &config.redundant,
323            validator,
324            ctx,
325            None,
326            0,
327        ) {
328            Ok((path, diag)) => (Ok(path), diag),
329            Err(e) => (
330                Err(e.into()),
331                RedundantDiagnostic {
332                    samples: 0,
333                    min_manipulability: 0.0,
334                    yaw_range: (0.0, 0.0),
335                },
336            ),
337        }
338    }
339}
340
341/// The single global DP over the yaw grid. Returns the coarse `(s[], ψ[])` of the
342/// minimum-cost continuous yaw+branch track, or `None` if none exists.
343fn solve_global<const N: usize>(
344    stations: &[Vec<YawNode<N>>],
345    red: &RedundantOptions,
346    planner: &PlannerOptions<N>,
347    length: f64,
348    n_dp: usize,
349) -> Option<(Vec<f64>, Vec<f64>)> {
350    let ds_at = |k: usize| {
351        ((k as f64 * red.dp_ds).min(length) - ((k - 1) as f64 * red.dp_ds).min(length)).max(1e-9)
352    };
353    let layer_sizes: Vec<usize> = stations.iter().map(Vec::len).collect();
354    let (chosen, _) = ladder_dp(
355        &layer_sizes,
356        |k, i| stations[k][i].cost,
357        |k, p, i| {
358            let na = &stations[k - 1][p];
359            let nb = &stations[k][i];
360            let dyaw = (nb.yaw - na.yaw).abs();
361            let ds = ds_at(k);
362            if dyaw > red.max_yaw_step || is_reconfiguration(&na.q, &nb.q, ds, planner) {
363                None
364            } else {
365                Some(na.q.distance(&nb.q) + red.yaw_rate_weight * dyaw / ds)
366            }
367        },
368    )?;
369
370    let s = (0..n_dp)
371        .map(|k| (k as f64 * red.dp_ds).min(length))
372        .collect();
373    let psi = chosen
374        .iter()
375        .enumerate()
376        .map(|(k, &i)| stations[k][i].yaw)
377        .collect();
378    Some((s, psi))
379}