Skip to main content

deke_linear/
retimer.rs

1//! Stage C — time-parameterise a joint path at a commanded constant TCP speed.
2//!
3//! This is a CNC-style constant-feedrate planner. The timing is found by a
4//! **discrete convex program**: the variable is the cumulative arc length
5//! `σ[k]` at each output tick `k·dt`, and the per-joint velocity/acceleration/
6//! jerk limits are written directly as bounds on the *finite differences* of the
7//! emitted joint samples — the exact quantities a downstream controller
8//! reconstructs. So the limits are honoured **by construction** rather than by a
9//! continuous bound plus a margin, and the solver never touches a fragile
10//! joint-space third derivative.
11//!
12//! The joint path is first smoothed (a cubic spline, densely resampled) so the
13//! chord-linear interpolation the solver times is C²-smooth at the tick scale;
14//! `σ` is solved with a small banded LP (Clarabel); the result is reconstructed,
15//! and a final finite-difference check against the *true* limits is the airtight
16//! backstop. A path that physically cannot fit under the limits fails.
17
18use std::time::Duration;
19
20use clarabel::algebra::CscMatrix;
21use clarabel::solver::{DefaultSettings, DefaultSolver, IPSolver, SolverStatus, SupportedConeT};
22use deke_types::glam::DVec3;
23use deke_types::{
24    ContinuousFKChain, DekeError, DekeResult, Retimer, SRobotPath, SRobotQ, SRobotTraj, Validator,
25};
26
27use crate::constraints::{JointLimits, LinearConstraints};
28use crate::diagnostic::LinearRetimerDiagnostic;
29use crate::error::LinearError;
30
31/// The per-tick limit caps are planned at `margin·limit` so the small cross-bin
32/// leak of the chord-linear reconstruction stays under the true limit; the final
33/// finite-difference verify against the *true* limits is the airtight backstop.
34const LIMIT_MARGIN: f64 = 0.97;
35
36/// Finest arc-length spacing (metres) the smoothing spline is resampled at, so
37/// the chord-linear path the solver times is C²-smooth at the output-tick scale
38/// and its FK arc length tracks the commanded Cartesian arc closely.
39const SMOOTH_STEP: f64 = 1e-4;
40
41/// A converged solve: the arc-length profile `σ`, the live-sample count, the
42/// reconstructed joint samples, and the realized TCP accel/jerk overshoot ratios.
43type SolvedProfile<const N: usize> = (Vec<f64>, usize, Vec<SRobotQ<N, f64>>, f64, f64);
44
45/// Constant-feedrate, jerk-limited retimer over a joint path.
46#[derive(Clone, Debug)]
47pub struct ConstantSpeedRetimer<'a, const N: usize, FK> {
48    fk: &'a FK,
49}
50
51impl<'a, const N: usize, FK> ConstantSpeedRetimer<'a, N, FK>
52where
53    FK: ContinuousFKChain<N, f64>,
54{
55    pub fn new(fk: &'a FK) -> Self {
56        Self { fk }
57    }
58
59    pub(crate) fn retime_path(
60        &self,
61        c: &LinearConstraints<N>,
62        path: &SRobotPath<N, f64>,
63        run_idx: usize,
64    ) -> Result<(SRobotTraj<N, f64>, LinearRetimerDiagnostic), LinearError> {
65        let raw: Vec<SRobotQ<N, f64>> = path.iter().copied().collect();
66        let dt = c.output_dt.as_secs_f64().max(1e-6);
67
68        // Smooth, densely-resampled joint path + its Cartesian arc length. The
69        // solver works on the chord-linear interpolation of these knots, so
70        // smoothing keeps the secant changes between knots tiny (small cross-bin
71        // leak) and removes the IK jitter that would otherwise spike the jerk.
72        let (knots, s) = smooth_path(self.fk, &raw, c.corner_smoothing)?;
73        let nb = knots.len();
74        let total = if nb == 0 { 0.0 } else { s[nb - 1] };
75        if nb < 2 || total < 1e-9 {
76            let traj = SRobotTraj::new(c.output_dt, path.clone());
77            return Ok((traj, degenerate_diag(nb, dt, total, c.tcp.speed)));
78        }
79
80        // Per-segment secant slope dq/ds — the chord-linear path derivative. The
81        // joint velocity/accel/jerk of the output are exactly `secant·Δᵐσ/dtᵐ`
82        // within a segment, so capping the σ-differences bounds the per-joint
83        // finite differences the consumer measures, with no path derivatives.
84        let secant: Vec<[f64; N]> = (0..nb - 1)
85            .map(|b| {
86                let ds = (s[b + 1] - s[b]).max(1e-12);
87                std::array::from_fn(|j| (knots[b + 1].0[j] - knots[b].0[j]) / ds)
88            })
89            .collect();
90
91        let v_cmd = c.tcp.speed.max(1e-9);
92        let bin_of = |sx: f64| -> usize {
93            let sx = sx.clamp(0.0, total);
94            let mut b = 0;
95            while b + 1 < nb - 1 && s[b + 1] <= sx {
96                b += 1;
97            }
98            b
99        };
100        // `min_j limit_j / |secant_j|` on a segment — the projected scalar limit.
101        let proj = |b: usize, lim: &SRobotQ<N, f64>| -> f64 {
102            (0..N)
103                .map(|j| lim.0[j] / secant[b][j].abs().max(1e-12))
104                .fold(f64::INFINITY, f64::min)
105        };
106
107        // Constant-speed contract: if the joint velocity limits force the
108        // feasible speed below the command anywhere interior and the caller
109        // forbids dips, fail loudly rather than slow down.
110        if c.forbid_interior_dips {
111            let mut worst: Option<(usize, f64)> = None;
112            #[allow(clippy::needless_range_loop)]
113            for b in 1..nb - 1 {
114                let g = proj(b, &c.joint.v_max);
115                if g < v_cmd * (1.0 - 1e-3) && worst.is_none_or(|(_, gw)| g < gw) {
116                    worst = Some((b, g));
117                }
118            }
119            if let Some((b, g)) = worst {
120                return Err(LinearError::SpeedDipRequired {
121                    run: run_idx,
122                    s: s[b],
123                    feasible_speed: g,
124                    commanded: v_cmd,
125                });
126            }
127        }
128
129        // Generous tick count: cruise time + a few jerk-limited ramp lengths,
130        // using the *effective* accel/jerk floor (joint projection intersected
131        // with the TCP caps, which can be far tighter and lengthen the ramps).
132        let mg = LIMIT_MARGIN;
133        let a_eff = (0..nb - 1)
134            .map(|b| proj(b, &c.joint.a_max))
135            .fold(f64::INFINITY, f64::min)
136            .min(c.tcp.accel.unwrap_or(f64::INFINITY))
137            .max(1e-6);
138        let j_eff = (0..nb - 1)
139            .map(|b| proj(b, &c.joint.j_max))
140            .fold(f64::INFINITY, f64::min)
141            .min(c.tcp.jerk.unwrap_or(f64::INFINITY))
142            .max(1e-6);
143        let ramp_t = v_cmd / a_eff + a_eff / j_eff;
144        let mut kk =
145            ((total / (v_cmd * dt)).ceil() as usize + (4.0 * ramp_t / dt) as usize + 64).max(8);
146
147        // The TCP tangential accel/jerk a controller measures is the finite
148        // difference of the FK Cartesian speed, which the chord-linear
149        // reconstruction over-reads vs the arc-length `Δσ` the program bounds (the
150        // third difference — jerk — most). Rather than guess a fixed derate, plan
151        // the TCP caps at the joint margin, measure the *realized* FK-FD overshoot,
152        // and tighten each TCP quantity by exactly its overshoot before re-solving.
153        // This converges in 1–2 passes, derates only what actually over-reads (so
154        // accel isn't throttled needlessly), and is robust to path/robot curvature.
155        let mut a_der = mg;
156        let mut j_der = mg;
157        let mut solved: Option<SolvedProfile<N>> = None;
158        for _tcp_iter in 0..5 {
159            // Solve, growing the horizon if the program is infeasible (a too-small
160            // tick count, distinct from a path that genuinely can't be timed). The
161            // chord-linear coefficients depend on which segment each σ[k] lands in,
162            // so re-bin and re-solve to a fixed point (stable in 1–3 passes).
163            let mut sigma: Option<Vec<f64>> = None;
164            for _grow in 0..6 {
165                let mut sg: Vec<f64> = (0..kk)
166                    .map(|k| k as f64 / (kk - 1) as f64 * total)
167                    .collect();
168                let mut prev_bins: Vec<usize> = Vec::new();
169                let mut feasible = true;
170                for _pass in 0..4 {
171                    let bins: Vec<usize> = sg.iter().map(|&sx| bin_of(sx)).collect();
172                    if bins == prev_bins {
173                        break;
174                    }
175                    let cap_v: Vec<f64> = bins
176                        .iter()
177                        .map(|&b| (mg * proj(b, &c.joint.v_max)).min(v_cmd) * dt)
178                        .collect();
179                    let cap_a: Vec<f64> = bins
180                        .iter()
181                        .map(|&b| {
182                            (mg * proj(b, &c.joint.a_max))
183                                .min(c.tcp.accel.map_or(f64::INFINITY, |t| a_der * t))
184                                * dt
185                                * dt
186                        })
187                        .collect();
188                    let cap_j: Vec<f64> = bins
189                        .iter()
190                        .map(|&b| {
191                            (mg * proj(b, &c.joint.j_max))
192                                .min(c.tcp.jerk.map_or(f64::INFINITY, |t| j_der * t))
193                                * dt
194                                * dt
195                                * dt
196                        })
197                        .collect();
198                    match solve_sigma(kk, total, &cap_v, &cap_a, &cap_j) {
199                        Some(next) => sg = next,
200                        None => {
201                            feasible = false;
202                            break;
203                        }
204                    }
205                    prev_bins = bins;
206                }
207                if feasible {
208                    sigma = Some(sg);
209                    break;
210                }
211                kk = (kk as f64 * 1.6) as usize + 16;
212            }
213            let sigma = sigma.ok_or(LinearError::Stalled {
214                run: run_idx,
215                s: 0.0,
216            })?;
217
218            // Reconstruct chord-linear joint samples; trim the trailing stationary
219            // tail (ticks parked at `total` after the motion finished).
220            let recon = |sx: f64| -> SRobotQ<N, f64> {
221                let b = bin_of(sx);
222                SRobotQ(std::array::from_fn(|j| {
223                    knots[b].0[j] + secant[b][j] * (sx - s[b])
224                }))
225            };
226            let mut end = kk;
227            while end > 2 && (total - sigma[end - 2]).abs() < 1e-9 {
228                end -= 1;
229            }
230            let samples: Vec<SRobotQ<N, f64>> = sigma[..end].iter().map(|&sx| recon(sx)).collect();
231
232            // Measure the realized FK TCP accel/jerk overshoot (FK finite
233            // difference ÷ true cap; `0` when a cap is unset). `1.0` is exactly at.
234            let (a_over, j_over) = tcp_fd_ratios(self.fk, &samples, c.tcp.accel, c.tcp.jerk, dt)?;
235            let tol = 1.0 + 1e-6;
236            let converged = a_over <= tol && j_over <= tol;
237            solved = Some((sigma, end, samples, a_over, j_over));
238            if converged {
239                break;
240            }
241            // Tighten only the quantity that over-reads, by its overshoot plus a
242            // small safety factor for the residual nonlinearity, then re-solve.
243            if a_over > tol {
244                a_der /= a_over * 1.01;
245            }
246            if j_over > tol {
247                j_der /= j_over * 1.01;
248            }
249        }
250        let (sigma, end, samples, a_over, j_over) = solved.ok_or(LinearError::Stalled {
251            run: run_idx,
252            s: 0.0,
253        })?;
254
255        // Airtight backstop: the *finite differences* of the emitted samples
256        // against the *true* (un-margined) per-joint limits. If any exceeds, the
257        // path can't be timed under the limits at this speed — fail.
258        if let Some((kind, joint, value, limit, idx)) = verify_fd(&samples, &c.joint, dt) {
259            return Err(LinearError::LimitExceeded {
260                run: run_idx,
261                s: sigma.get(idx).copied().unwrap_or(total),
262                joint,
263                kind,
264                value,
265                limit,
266            });
267        }
268        // Same backstop for the TCP caps: if the adaptation above could not bring
269        // the realized FK accel/jerk under the true cap, fail rather than emit a
270        // trajectory that exceeds it.
271        if a_over > 1.0 + 1e-6 {
272            let limit = c
273                .tcp
274                .accel
275                .expect("accel cap set when its ratio is positive");
276            return Err(LinearError::TcpLimitExceeded {
277                run: run_idx,
278                kind: "acceleration",
279                value: a_over * limit,
280                limit,
281            });
282        }
283        if j_over > 1.0 + 1e-6 {
284            let limit = c.tcp.jerk.expect("jerk cap set when its ratio is positive");
285            return Err(LinearError::TcpLimitExceeded {
286                run: run_idx,
287                kind: "jerk",
288                value: j_over * limit,
289                limit,
290            });
291        }
292
293        let out_samples = samples.len();
294        let (_, pk_a, pk_j) = fd_peaks(&samples, dt);
295        let peak_speed = (1..end)
296            .map(|k| (sigma[k] - sigma[k - 1]) / dt)
297            .fold(0.0, f64::max);
298        let path_out = SRobotPath::try_new(samples).map_err(LinearError::from)?;
299        let traj = SRobotTraj::new(c.output_dt, path_out);
300        Ok((
301            traj,
302            LinearRetimerDiagnostic {
303                output_samples: out_samples,
304                duration: Duration::from_secs_f64((out_samples.saturating_sub(1)) as f64 * dt),
305                arc_length: total,
306                commanded_speed: c.tcp.speed,
307                peak_speed,
308                peak_joint_accel: pk_a,
309                peak_joint_jerk: pk_j,
310            },
311        ))
312    }
313}
314
315impl<'a, const N: usize, FK> Retimer<N, f64> for ConstantSpeedRetimer<'a, N, FK>
316where
317    FK: ContinuousFKChain<N, f64>,
318{
319    type Diagnostic = LinearRetimerDiagnostic;
320    type Constraints = LinearConstraints<N>;
321
322    fn retime<V: Validator<N, (), f64>>(
323        &self,
324        constraints: &Self::Constraints,
325        path: &SRobotPath<N, f64>,
326        validator: &V,
327        ctx: &V::Context<'_>,
328    ) -> (DekeResult<SRobotTraj<N, f64>>, Self::Diagnostic) {
329        match self.retime_path(constraints, path, 0) {
330            Ok((traj, diag)) => {
331                let samples: Vec<SRobotQ<N, f64>> = traj.path().iter().copied().collect();
332                if let Err(e) = validator.validate_motion(&samples, ctx) {
333                    return (Err(e), diag);
334                }
335                (Ok(traj), diag)
336            }
337            Err(e) => (
338                Err(e.into()),
339                LinearRetimerDiagnostic {
340                    output_samples: 0,
341                    duration: Duration::ZERO,
342                    arc_length: 0.0,
343                    commanded_speed: constraints.tcp.speed,
344                    peak_speed: 0.0,
345                    peak_joint_accel: 0.0,
346                    peak_joint_jerk: 0.0,
347                },
348            ),
349        }
350    }
351}
352
353fn degenerate_diag(nb: usize, dt: f64, total: f64, speed: f64) -> LinearRetimerDiagnostic {
354    LinearRetimerDiagnostic {
355        output_samples: nb,
356        duration: Duration::from_secs_f64((nb.saturating_sub(1)) as f64 * dt),
357        arc_length: total,
358        commanded_speed: speed,
359        peak_speed: 0.0,
360        peak_joint_accel: 0.0,
361        peak_joint_jerk: 0.0,
362    }
363}
364
365/// Smooth and densely resample the joint path, returning the knots and their
366/// cumulative Cartesian arc length. Coincident-arc knots are dropped first (a
367/// zero-length chord makes the cubic singular). `None` (or too few knots) keeps
368/// the raw deduped polyline.
369fn smooth_path<const N: usize, FK: ContinuousFKChain<N, f64>>(
370    fk: &FK,
371    raw: &[SRobotQ<N, f64>],
372    res: Option<f64>,
373) -> Result<(Vec<SRobotQ<N, f64>>, Vec<f64>), LinearError> {
374    let arc = |pts: &[SRobotQ<N, f64>]| -> Result<Vec<f64>, LinearError> {
375        let pos: Vec<DVec3> = pts
376            .iter()
377            .map(|q| fk.fk_end(q).map(|t| t.translation))
378            .collect::<Result<_, DekeError>>()?;
379        let mut s = vec![0.0f64; pts.len()];
380        for i in 1..pts.len() {
381            s[i] = s[i - 1] + pos[i].distance(pos[i - 1]);
382        }
383        Ok(s)
384    };
385    let s_raw = arc(raw)?;
386    let mut sd: Vec<f64> = Vec::with_capacity(raw.len());
387    let mut qd: Vec<SRobotQ<N, f64>> = Vec::with_capacity(raw.len());
388    for i in 0..raw.len() {
389        if qd.is_empty() || s_raw[i] - sd[sd.len() - 1] > 1e-9 {
390            sd.push(s_raw[i]);
391            qd.push(raw[i]);
392        }
393    }
394    let n = qd.len();
395    let step = match res {
396        Some(r) if r > 0.0 && n >= 3 => r.min(SMOOTH_STEP),
397        _ => {
398            let s = arc(&qd)?;
399            return Ok((qd, s));
400        }
401    };
402    let h: Vec<f64> = (0..n - 1).map(|i| (sd[i + 1] - sd[i]).max(1e-12)).collect();
403    let mm = natural_cubic(&h, &qd);
404    let mut out = vec![qd[0]];
405    for i in 0..n - 1 {
406        let k = ((h[i] / step).ceil() as usize).max(1);
407        for ss in 1..=k {
408            let uu = sd[i] + h[i] * (ss as f64) / (k as f64);
409            let a = (sd[i + 1] - uu) / h[i];
410            let b = (uu - sd[i]) / h[i];
411            out.push(SRobotQ(std::array::from_fn(|j| {
412                qd[i].0[j] * a
413                    + qd[i + 1].0[j] * b
414                    + (mm[i][j] * (a * a * a - a) + mm[i + 1][j] * (b * b * b - b))
415                        * (h[i] * h[i] / 6.0)
416            })));
417        }
418    }
419    let s = arc(&out)?;
420    Ok((out, s))
421}
422
423/// Natural-cubic-spline second derivatives per joint via the Thomas algorithm
424/// (`M_0 = M_{n-1} = 0`); the scalar tridiagonal sweep is shared by every joint.
425#[allow(clippy::needless_range_loop)]
426fn natural_cubic<const N: usize>(h: &[f64], y: &[SRobotQ<N, f64>]) -> Vec<[f64; N]> {
427    let n = y.len();
428    let mut cp = vec![0.0f64; n];
429    let mut dp = vec![[0.0f64; N]; n];
430    for i in 1..n - 1 {
431        let (a, b, cc) = (h[i - 1], 2.0 * (h[i - 1] + h[i]), h[i]);
432        let denom = b - a * cp[i - 1];
433        cp[i] = cc / denom;
434        for j in 0..N {
435            let rhs =
436                ((y[i + 1].0[j] - y[i].0[j]) / h[i] - (y[i].0[j] - y[i - 1].0[j]) / h[i - 1]) * 6.0;
437            dp[i][j] = (rhs - dp[i - 1][j] * a) / denom;
438        }
439    }
440    let mut mm = vec![[0.0f64; N]; n];
441    for i in (1..n - 1).rev() {
442        for j in 0..N {
443            mm[i][j] = dp[i][j] - mm[i + 1][j] * cp[i];
444        }
445    }
446    mm
447}
448
449/// Solve `maximise Σσ` subject to `σ[0]=0`, `σ[n-1]=total`, rest (v=a=0) at both
450/// ends, monotonic advance, and the per-tick first/second/third-difference caps.
451/// Returns the σ profile, or `None` if the program is infeasible.
452#[allow(clippy::needless_range_loop, clippy::field_reassign_with_default)]
453fn solve_sigma(
454    n: usize,
455    total: f64,
456    cap_v: &[f64],
457    cap_a: &[f64],
458    cap_j: &[f64],
459) -> Option<Vec<f64>> {
460    // Non-dimensionalize: solve in σ̃ = σ/total ∈ [0,1] so the variables are O(1)
461    // and the (tiny) per-tick difference caps are well-conditioned against them.
462    let inv = 1.0 / total;
463    let mut t: Vec<(usize, usize, f64)> = Vec::new();
464    let mut b: Vec<f64> = Vec::new();
465    let mut row = 0usize;
466    let eq = |t: &mut Vec<(usize, usize, f64)>,
467              b: &mut Vec<f64>,
468              row: &mut usize,
469              e: &[(usize, f64)],
470              rhs: f64| {
471        for &(c, v) in e {
472            t.push((*row, c, v));
473        }
474        b.push(rhs);
475        *row += 1;
476    };
477    eq(&mut t, &mut b, &mut row, &[(0, 1.0)], 0.0);
478    eq(&mut t, &mut b, &mut row, &[(n - 1, 1.0)], 1.0);
479    eq(&mut t, &mut b, &mut row, &[(1, 1.0), (0, -1.0)], 0.0);
480    eq(&mut t, &mut b, &mut row, &[(2, 1.0), (1, -1.0)], 0.0);
481    eq(
482        &mut t,
483        &mut b,
484        &mut row,
485        &[(n - 1, 1.0), (n - 2, -1.0)],
486        0.0,
487    );
488    eq(
489        &mut t,
490        &mut b,
491        &mut row,
492        &[(n - 2, 1.0), (n - 3, -1.0)],
493        0.0,
494    );
495    let n_eq = row;
496    for k in 1..n {
497        t.push((row, k - 1, 1.0));
498        t.push((row, k, -1.0));
499        b.push(0.0);
500        row += 1;
501        t.push((row, k, 1.0));
502        t.push((row, k - 1, -1.0));
503        b.push(cap_v[k] * inv);
504        row += 1;
505    }
506    for k in 2..n {
507        for sgn in [1.0, -1.0] {
508            t.push((row, k, sgn));
509            t.push((row, k - 1, -2.0 * sgn));
510            t.push((row, k - 2, sgn));
511            b.push(cap_a[k] * inv);
512            row += 1;
513        }
514    }
515    for k in 3..n {
516        for sgn in [1.0, -1.0] {
517            t.push((row, k, sgn));
518            t.push((row, k - 1, -3.0 * sgn));
519            t.push((row, k - 2, 3.0 * sgn));
520            t.push((row, k - 3, -sgn));
521            b.push(cap_j[k] * inv);
522            row += 1;
523        }
524    }
525    let m = row;
526    // Row-scale each inequality so its (tiny) cap RHS becomes ±1: the difference
527    // caps span ~1e-6…1, and normalizing them to O(1) lets the interior-point
528    // solver hit the tight ramp constraints in far fewer iterations.
529    let mut scale = vec![1.0f64; m];
530    for r in n_eq..m {
531        if b[r].abs() > 1e-12 {
532            scale[r] = 1.0 / b[r].abs();
533        }
534    }
535    for entry in t.iter_mut() {
536        entry.2 *= scale[entry.0];
537    }
538    for r in 0..m {
539        b[r] *= scale[r];
540    }
541    let a = triplets_to_csc(m, n, t);
542    let p = CscMatrix::new(n, n, vec![0; n + 1], vec![], vec![]);
543    let q = vec![-1.0f64; n];
544    let cones = [
545        SupportedConeT::ZeroConeT(n_eq),
546        SupportedConeT::NonnegativeConeT(m - n_eq),
547    ];
548    let mut set = DefaultSettings::default();
549    set.verbose = false;
550    set.max_iter = 200;
551    let mut solver = DefaultSolver::new(&p, &q, &a, &b, &cones, set).ok()?;
552    solver.solve();
553    match solver.solution.status {
554        SolverStatus::Solved | SolverStatus::AlmostSolved => {
555            Some(solver.solution.x.iter().map(|x| x * total).collect())
556        }
557        _ => None,
558    }
559}
560
561/// Build a column-compressed sparse matrix from `(row, col, val)` triplets.
562fn triplets_to_csc(m: usize, n: usize, mut t: Vec<(usize, usize, f64)>) -> CscMatrix<f64> {
563    t.sort_by(|a, b| a.1.cmp(&b.1).then(a.0.cmp(&b.0)));
564    let mut colptr = vec![0usize; n + 1];
565    let mut rowval = Vec::with_capacity(t.len());
566    let mut nzval = Vec::with_capacity(t.len());
567    for &(r, c, v) in &t {
568        colptr[c + 1] += 1;
569        rowval.push(r);
570        nzval.push(v);
571    }
572    for c in 0..n {
573        colptr[c + 1] += colptr[c];
574    }
575    CscMatrix::new(m, n, colptr, rowval, nzval)
576}
577
578/// Worst per-joint finite-difference violation of the *true* limits, or `None`
579/// if every difference is within limit. Returns `(kind, joint, value, limit,
580/// sample_index)`.
581fn verify_fd<const N: usize>(
582    q: &[SRobotQ<N, f64>],
583    lim: &JointLimits<N>,
584    dt: f64,
585) -> Option<(&'static str, usize, f64, f64, usize)> {
586    let n = q.len();
587    let mut worst: Option<(f64, &'static str, usize, f64, f64, usize)> = None;
588    let mut consider = |val: f64, limit: f64, kind: &'static str, j: usize, idx: usize| {
589        if val > limit * (1.0 + 1e-6) {
590            let r = val / limit;
591            if worst.is_none_or(|w| r > w.0) {
592                worst = Some((r, kind, j, val, limit, idx));
593            }
594        }
595    };
596    for i in 1..n {
597        for j in 0..N {
598            consider(
599                (q[i].0[j] - q[i - 1].0[j]).abs() / dt,
600                lim.v_max.0[j],
601                "velocity",
602                j,
603                i,
604            );
605        }
606    }
607    for i in 2..n {
608        for j in 0..N {
609            let a = (q[i].0[j] - 2.0 * q[i - 1].0[j] + q[i - 2].0[j]).abs() / (dt * dt);
610            consider(a, lim.a_max.0[j], "acceleration", j, i);
611        }
612    }
613    for i in 3..n {
614        for j in 0..N {
615            let jk = (q[i].0[j] - 3.0 * q[i - 1].0[j] + 3.0 * q[i - 2].0[j] - q[i - 3].0[j]).abs()
616                / (dt * dt * dt);
617            consider(jk, lim.j_max.0[j], "jerk", j, i);
618        }
619    }
620    worst.map(|(_, kind, j, val, limit, idx)| (kind, j, val, limit, idx))
621}
622
623/// Realized TCP tangential acceleration/jerk overshoot — the finite differences
624/// of the FK Cartesian speed stream divided by their caps (`(accel, jerk)`, each
625/// `0.0` when its cap is unset). `1.0` is exactly at the cap; `> 1` over it.
626fn tcp_fd_ratios<const N: usize, FK: ContinuousFKChain<N, f64>>(
627    fk: &FK,
628    q: &[SRobotQ<N, f64>],
629    accel_cap: Option<f64>,
630    jerk_cap: Option<f64>,
631    dt: f64,
632) -> Result<(f64, f64), LinearError> {
633    if (accel_cap.is_none() && jerk_cap.is_none()) || q.len() < 3 {
634        return Ok((0.0, 0.0));
635    }
636    let pos: Vec<DVec3> = q
637        .iter()
638        .map(|qi| fk.fk_end(qi).map(|t| t.translation))
639        .collect::<Result<_, DekeError>>()?;
640    let sp: Vec<f64> = (0..pos.len() - 1)
641        .map(|i| pos[i + 1].distance(pos[i]) / dt)
642        .collect();
643    let a_ratio = accel_cap.map_or(0.0, |a| {
644        (1..sp.len()).fold(0.0f64, |w, i| w.max((sp[i] - sp[i - 1]).abs() / dt)) / a
645    });
646    let j_ratio = jerk_cap.map_or(0.0, |j| {
647        (2..sp.len()).fold(0.0f64, |w, i| {
648            w.max((sp[i] - 2.0 * sp[i - 1] + sp[i - 2]).abs() / (dt * dt))
649        }) / j
650    });
651    Ok((a_ratio, j_ratio))
652}
653
654/// Peak per-joint finite-difference velocity/acceleration/jerk of the output.
655fn fd_peaks<const N: usize>(q: &[SRobotQ<N, f64>], dt: f64) -> (f64, f64, f64) {
656    let n = q.len();
657    let (mut v, mut a, mut jk) = (0.0f64, 0.0f64, 0.0f64);
658    for i in 1..n {
659        for j in 0..N {
660            v = v.max((q[i].0[j] - q[i - 1].0[j]).abs() / dt);
661        }
662    }
663    for i in 2..n {
664        for j in 0..N {
665            a = a.max((q[i].0[j] - 2.0 * q[i - 1].0[j] + q[i - 2].0[j]).abs() / (dt * dt));
666        }
667    }
668    for i in 3..n {
669        for j in 0..N {
670            jk = jk.max(
671                (q[i].0[j] - 3.0 * q[i - 1].0[j] + 3.0 * q[i - 2].0[j] - q[i - 3].0[j]).abs()
672                    / (dt * dt * dt),
673            );
674        }
675    }
676    (v, a, jk)
677}