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