Skip to main content

deke_linear/
retimer.rs

1//! Stage C — time-parameterise a joint path at constant TCP speed.
2//!
3//! This is a CNC-style constant-feedrate planner, not a TOPP retimer. The
4//! feasible-speed ceiling along the path (the "maximum velocity curve") comes from
5//! the per-joint v/a/j limits projected onto the path tangent `q'(s)`. The
6//! commanded speed `tcp_speed` is held flat wherever that ceiling allows; near a
7//! singularity `|q'(s)| → ∞` so the ceiling collapses and the feedrate dips to zero
8//! smoothly instead of demanding infinite joint speed. The profile is built by a
9//! backward+forward acceleration-bounded pass (zero speed at both ends) followed by
10//! a forward jerk-limited time integration that tracks it.
11//!
12//! Joint velocity is enforced exactly; acceleration and jerk are enforced through
13//! the tangent projection — the `q''(s)·ṡ²` curvature cross-term is a deliberate
14//! first-pass approximation, softened by the jerk-limited integrator.
15
16use std::time::Duration;
17
18use deke_types::glam::DVec3;
19use deke_types::{
20    ContinuousFKChain, DekeError, DekeResult, Retimer, SRobotPath, SRobotQ, SRobotTraj, Validator,
21};
22
23use crate::constraints::LinearConstraints;
24use crate::diagnostic::LinearRetimerDiagnostic;
25use crate::error::LinearError;
26
27const BIG: f64 = 1e9;
28
29/// Constant-feedrate, jerk-limited retimer over a joint path.
30#[derive(Clone, Debug)]
31pub struct ConstantSpeedRetimer<'a, const N: usize, FK> {
32    fk: &'a FK,
33}
34
35impl<'a, const N: usize, FK> ConstantSpeedRetimer<'a, N, FK>
36where
37    FK: ContinuousFKChain<N, f64>,
38{
39    pub fn new(fk: &'a FK) -> Self {
40        Self { fk }
41    }
42
43    pub(crate) fn retime_path(
44        &self,
45        c: &LinearConstraints<N>,
46        path: &SRobotPath<N, f64>,
47        run_idx: usize,
48    ) -> Result<(SRobotTraj<N, f64>, LinearRetimerDiagnostic), LinearError> {
49        let q: Vec<SRobotQ<N, f64>> = path.iter().copied().collect();
50        let m = q.len();
51        let dt = c.output_dt.as_secs_f64().max(1e-6);
52
53        // Cartesian arc length from FK end positions (true metres for `tcp_speed`).
54        let pos: Vec<DVec3> = q
55            .iter()
56            .map(|qi| self.fk.fk_end(qi).map(|t| t.translation))
57            .collect::<Result<_, DekeError>>()?;
58        let mut s = vec![0.0f64; m];
59        for i in 1..m {
60            s[i] = s[i - 1] + pos[i].distance(pos[i - 1]);
61        }
62        let total = s[m - 1];
63        if m < 2 || total < 1e-9 {
64            let traj = SRobotTraj::new(c.output_dt, path.clone());
65            return Ok((
66                traj,
67                LinearRetimerDiagnostic {
68                    output_samples: m,
69                    duration: Duration::from_secs_f64((m.saturating_sub(1)) as f64 * dt),
70                    arc_length: total,
71                    commanded_speed: c.tcp_speed,
72                    peak_speed: 0.0,
73                },
74            ));
75        }
76
77        // q'(s) by central difference over arc length.
78        let mut qp = vec![SRobotQ::<N, f64>::zeros(); m];
79        for (i, qpi) in qp.iter_mut().enumerate() {
80            let (lo, hi) = if i == 0 {
81                (0, 1)
82            } else if i == m - 1 {
83                (m - 2, m - 1)
84            } else {
85                (i - 1, i + 1)
86            };
87            let ds = (s[hi] - s[lo]).max(1e-12);
88            *qpi = (q[hi] - q[lo]) * (1.0 / ds);
89        }
90
91        // Raw geometric speed ceiling per sample (before clamping to the command).
92        // A dip below the command here is forced by joint v-limits + path curvature,
93        // i.e. a corner or near-singular patch — distinct from the temporal rest
94        // ramps the MVC adds at the ends.
95        if c.forbid_interior_dips {
96            let mut worst: Option<(usize, f64)> = None;
97            #[allow(clippy::needless_range_loop)]
98            for i in 1..m - 1 {
99                let g = project_min(&qp[i], &c.joint.v_max);
100                if g < c.tcp_speed * (1.0 - 1e-3) && worst.is_none_or(|(_, gw)| g < gw) {
101                    worst = Some((i, g));
102                }
103            }
104            if let Some((i, g)) = worst {
105                return Err(LinearError::SpeedDipRequired {
106                    run: run_idx,
107                    s: s[i],
108                    feasible_speed: g,
109                    commanded: c.tcp_speed,
110                });
111            }
112        }
113
114        let v_ceiling = |i: usize| project_min(&qp[i], &c.joint.v_max).min(c.tcp_speed);
115        let a_path: Vec<f64> = (0..m)
116            .map(|i| project_min(&qp[i], &c.joint.a_max))
117            .collect();
118        let j_path: Vec<f64> = (0..m)
119            .map(|i| project_min(&qp[i], &c.joint.j_max))
120            .collect();
121
122        // Acceleration-bounded velocity ceiling. Only the end is pinned to rest;
123        // start-from-rest is the integrator's initial condition (v = 0), not a
124        // ceiling — pinning the start too would forbid ever accelerating.
125        let mut vc: Vec<f64> = (0..m).map(v_ceiling).collect();
126        vc[m - 1] = 0.0;
127        for i in (0..m - 1).rev() {
128            let ds = s[i + 1] - s[i];
129            vc[i] = vc[i].min((vc[i + 1] * vc[i + 1] + 2.0 * a_path[i] * ds).sqrt());
130        }
131        for i in 1..m {
132            let ds = s[i] - s[i - 1];
133            vc[i] = vc[i].min((vc[i - 1] * vc[i - 1] + 2.0 * a_path[i - 1] * ds).sqrt());
134        }
135
136        // Per-segment reciprocal lengths and value slopes. Precomputing these
137        // turns every inner-loop lookup into a fused `base + slope·f` — no
138        // division and no subtraction in the hot path — and the joint sample
139        // becomes `q[i] + dq[i]·f`.
140        let seg_n = m - 1;
141        let mut inv_ds = vec![0.0f64; seg_n];
142        let mut vc_d = vec![0.0f64; seg_n];
143        let mut a_d = vec![0.0f64; seg_n];
144        let mut j_d = vec![0.0f64; seg_n];
145        let mut dq = vec![SRobotQ::<N, f64>::zeros(); seg_n];
146        for i in 0..seg_n {
147            let ds = s[i + 1] - s[i];
148            inv_ds[i] = if ds > 0.0 { 1.0 / ds } else { 0.0 };
149            vc_d[i] = vc[i + 1] - vc[i];
150            a_d[i] = a_path[i + 1] - a_path[i];
151            j_d[i] = j_path[i + 1] - j_path[i];
152            dq[i] = q[i + 1] - q[i];
153        }
154
155        // Forward jerk-limited time integration tracking the ceiling. The flat
156        // estimate `total / (tcp_speed·dt)` is a lower bound on the step count
157        // (real speed never exceeds the command); doubling it covers the rest
158        // ramps so the buffer almost never reallocates mid-sweep.
159        let est = (total / (c.tcp_speed.max(1e-6) * dt)) as usize;
160        let mut samples: Vec<SRobotQ<N, f64>> = Vec::with_capacity(est * 2 + 16);
161        samples.push(q[0]);
162        let mut sx = 0.0f64;
163        let mut v = 0.0f64;
164        let mut a = 0.0f64;
165        let mut peak = 0.0f64;
166        let max_iters = (est + m) * 8 + 100_000;
167
168        // `sx` only ever advances, so a single forward cursor (`seg`) brackets
169        // every lookup in amortised O(1). The bracket landed on at the end of a
170        // step is exactly where the next step's ceiling is read, so it is carried
171        // across iterations — one `seg` call per step serves both the sample and
172        // the next ceiling read.
173        let mut cur = 0usize;
174        let mut i = 0usize;
175        let mut f = 0.0f64;
176        let mut iters = 0usize;
177        while sx < total - 1e-9 {
178            iters += 1;
179            if iters > max_iters {
180                return Err(LinearError::Stalled {
181                    run: run_idx,
182                    s: sx,
183                });
184            }
185            let vlim = (vc[i] + vc_d[i] * f).max(0.0);
186            let alim = a_path[i] + a_d[i] * f;
187            let jlim = j_path[i] + j_d[i] * f;
188
189            let a_des = ((vlim - v) / dt).clamp(-alim, alim);
190            a = a_des.clamp(a - jlim * dt, a + jlim * dt).clamp(-alim, alim);
191            v = (v + a * dt).clamp(0.0, vlim);
192            peak = peak.max(v);
193            sx += v * dt;
194            (i, f) = seg(&s, &inv_ds, &mut cur, sx.min(total));
195            samples.push(q[i] + dq[i] * f);
196
197            // Guard against a stall at a vanishing ceiling (true singularity).
198            if v < 1e-9 && vlim < 1e-9 && sx < total - 1e-6 {
199                return Err(LinearError::Stalled {
200                    run: run_idx,
201                    s: sx,
202                });
203            }
204        }
205        if samples.last().map(|l| l.distance(&q[m - 1])).unwrap_or(1.0) > 1e-9 {
206            samples.push(q[m - 1]);
207        }
208
209        let out_samples = samples.len();
210        let path_out = SRobotPath::try_new(samples).map_err(LinearError::from)?;
211        let traj = SRobotTraj::new(c.output_dt, path_out);
212        Ok((
213            traj,
214            LinearRetimerDiagnostic {
215                output_samples: out_samples,
216                duration: Duration::from_secs_f64((out_samples.saturating_sub(1)) as f64 * dt),
217                arc_length: total,
218                commanded_speed: c.tcp_speed,
219                peak_speed: peak,
220            },
221        ))
222    }
223}
224
225impl<'a, const N: usize, FK> Retimer<N, f64> for ConstantSpeedRetimer<'a, N, FK>
226where
227    FK: ContinuousFKChain<N, f64>,
228{
229    type Diagnostic = LinearRetimerDiagnostic;
230    type Constraints = LinearConstraints<N>;
231
232    fn retime<V: Validator<N, (), f64>>(
233        &self,
234        constraints: &Self::Constraints,
235        path: &SRobotPath<N, f64>,
236        validator: &V,
237        ctx: &V::Context<'_>,
238    ) -> (DekeResult<SRobotTraj<N, f64>>, Self::Diagnostic) {
239        match self.retime_path(constraints, path, 0) {
240            Ok((traj, diag)) => {
241                let samples: Vec<SRobotQ<N, f64>> = traj.path().iter().copied().collect();
242                if let Err(e) = validator.validate_motion(&samples, ctx) {
243                    return (Err(e), diag);
244                }
245                (Ok(traj), diag)
246            }
247            Err(e) => (
248                Err(e.into()),
249                LinearRetimerDiagnostic {
250                    output_samples: 0,
251                    duration: Duration::ZERO,
252                    arc_length: 0.0,
253                    commanded_speed: constraints.tcp_speed,
254                    peak_speed: 0.0,
255                },
256            ),
257        }
258    }
259}
260
261/// `min_j limit_j / |qp_j|` over axes that actually move; `BIG` if none do.
262fn project_min<const N: usize>(qp: &SRobotQ<N, f64>, limit: &SRobotQ<N, f64>) -> f64 {
263    let mut m = BIG;
264    let mut any = false;
265    for j in 0..N {
266        let g = qp.0[j].abs();
267        if g > 1e-9 {
268            any = true;
269            m = m.min(limit.0[j] / g);
270        }
271    }
272    if any { m } else { BIG }
273}
274
275/// Bracket `x` against the ascending grid `s`, advancing the forward-only cursor
276/// `cur` (kept in `0..s.len()-1`). Returns the segment index `i` with
277/// `s[i] <= x <= s[i+1]` and the in-segment fraction `f`, both clamped to the
278/// grid range. `inv_ds[i]` is the reciprocal segment length, so the fraction
279/// costs a multiply, not a divide. Amortised O(1) over the monotonic sweep.
280#[inline]
281fn seg(s: &[f64], inv_ds: &[f64], cur: &mut usize, x: f64) -> (usize, f64) {
282    let last = s.len() - 1;
283    let x = x.clamp(s[0], s[last]);
284    while *cur < last - 1 && s[*cur + 1] <= x {
285        *cur += 1;
286    }
287    let f = (x - s[*cur]) * inv_ds[*cur];
288    (*cur, f)
289}