Skip to main content

copp\path/
path_core.rs

1use crate::diag::PathError;
2use crate::path::OutOfRangeMode;
3use crate::path::autodiff::Jet3;
4use crate::path::spline::{SplineConfig, SplinePath};
5use nalgebra::DMatrix;
6use rayon::prelude::*;
7use std::sync::Arc;
8
9const EPS_RANGE: f64 = 1e-12;
10
11pub type ParametricFn = Arc<dyn Fn(Jet3) -> Vec<Jet3> + Send + Sync>;
12
13/// Output of path evaluation.
14///
15/// `dq`, `ddq`, `dddq` are `None` when the evaluation did not request them
16/// (e.g. `evaluate_q` only fills `q`; `evaluate_up_to_2nd` fills `q/dq/ddq`).
17#[derive(Debug)]
18pub struct PathDerivatives {
19    pub q: DMatrix<f64>,
20    pub dq: Option<DMatrix<f64>>,
21    pub ddq: Option<DMatrix<f64>>,
22    pub dddq: Option<DMatrix<f64>>,
23}
24
25/// How many derivative orders to compute.
26#[derive(Clone, Copy, PartialEq, Eq)]
27enum Order {
28    Zero,  // q only
29    Two,   // q, dq, ddq
30    Three, // q, dq, ddq, dddq
31}
32
33/// Unified path abstraction over parametric and spline representations.
34///
35/// Construct via [`Path::from_parametric`] or [`Path::from_waypoints`], then
36/// query a batch of parameter values with the `evaluate_*` family of methods.
37///
38/// The valid parameter domain is `[s_min, s_max]` (set at construction time).
39/// Out-of-range behaviour is controlled by [`OutOfRangeMode`]: the default is to
40/// return an error; it can be changed to silent clamping.
41pub struct Path {
42    dim: usize,
43    s_min: f64,
44    s_max: f64,
45    out_of_range_mode: OutOfRangeMode,
46    repr: PathRepr,
47}
48
49enum PathRepr {
50    /// Closure-based path evaluated via third-order forward AD.
51    Parametric(ParametricFn),
52    /// Piecewise-polynomial path built from waypoints.
53    Spline(SplinePath),
54}
55
56impl Path {
57    /// Build a parametric path from an analytic closure.
58    ///
59    /// Derivatives up to third order are computed automatically via [`Jet3`]
60    /// forward-mode AD.  The closure only needs to express `q(s)` symbolically;
61    /// no manual differentiation is required.
62    ///
63    /// # Arguments
64    /// - `q_fn`  : closure mapping scalar `s` to a `dim`-dimensional position vector
65    /// - `s_min` : lower bound of the path parameter
66    /// - `s_max` : upper bound of the path parameter (`s_max > s_min` required)
67    ///
68    /// # Errors
69    /// - [`PathError::InvalidRange`]     : `s_min >= s_max` or either value is non-finite
70    /// - [`PathError::InvalidDimension`] : closure returned an empty vector
71    pub fn from_parametric<F>(q_fn: F, s_min: f64, s_max: f64) -> Result<Self, PathError>
72    where
73        F: Fn(Jet3) -> Vec<Jet3> + Send + Sync + 'static,
74    {
75        validate_range(s_min, s_max)?;
76
77        let sample = q_fn(Jet3::constant((s_min + s_max) * 0.5));
78        if sample.is_empty() {
79            return Err(PathError::InvalidDimension { dim: 0 });
80        }
81        let dim = sample.len();
82
83        Ok(Self {
84            dim,
85            s_min,
86            s_max,
87            out_of_range_mode: OutOfRangeMode::Error,
88            repr: PathRepr::Parametric(Arc::new(q_fn)),
89        })
90    }
91
92    /// Build a spline path by interpolating a waypoint matrix.
93    ///
94    /// Internally solves the Hermite spline system with an O(N) block-Thomas
95    /// algorithm; all dimensions are solved in parallel.
96    /// The default configuration ([`SplineConfig::default`]) uses a quintic
97    /// (order-5) spline with `s ∈ [0, 1]`.
98    ///
99    /// # Arguments
100    /// - `waypoints` : matrix of shape `(dim, n_points)`; each column is one waypoint
101    /// - `cfg`       : spline configuration (order, parameter range, boundary derivatives, out-of-range mode)
102    ///
103    /// # Errors
104    /// - [`PathError::InvalidDimension`]   : `waypoints` has zero rows
105    /// - [`PathError::NotEnoughWaypoints`] : fewer than 2 columns
106    /// - [`PathError::InvalidOrder`]       : `order < 3`
107    /// - [`PathError::InvalidRange`]       : invalid parameter range
108    /// - [`PathError::SingularSystem`]     : spline system is singular (extremely rare)
109    pub fn from_waypoints(waypoints: &DMatrix<f64>, cfg: SplineConfig) -> Result<Self, PathError> {
110        if waypoints.nrows() == 0 {
111            return Err(PathError::InvalidDimension {
112                dim: waypoints.nrows(),
113            });
114        }
115        if waypoints.ncols() < 2 {
116            return Err(PathError::NotEnoughWaypoints {
117                n: waypoints.ncols(),
118            });
119        }
120        if cfg.order < 3 {
121            return Err(PathError::InvalidOrder { order: cfg.order });
122        }
123        validate_range(cfg.s_min, cfg.s_max)?;
124
125        let spline = SplinePath::from_waypoints(waypoints, &cfg)?;
126
127        Ok(Self {
128            dim: waypoints.nrows(),
129            s_min: spline.s_min,
130            s_max: spline.s_max,
131            out_of_range_mode: spline.out_of_range_mode,
132            repr: PathRepr::Spline(spline),
133        })
134    }
135
136    /// Returns the spatial dimension (number of joints) of the path.
137    #[inline(always)]
138    pub fn dim(&self) -> usize {
139        self.dim
140    }
141
142    /// Returns the valid parameter range `(s_min, s_max)`.
143    #[inline(always)]
144    pub fn s_range(&self) -> (f64, f64) {
145        (self.s_min, self.s_max)
146    }
147
148    /// Evaluate position `q` only at the query points (cheapest; no derivatives).
149    ///
150    /// # Arguments
151    /// - `s` : one-dimensional parameter samples (length `N`)
152    ///
153    /// # Returns
154    /// [`PathDerivatives`] with `dq / ddq / dddq` all `None`;
155    /// `q` has shape `(dim, N)`.
156    ///
157    /// # Errors
158    /// - [`PathError::OutOfRangeS`] : a query value is out of range (only in `Error` mode)
159    pub fn evaluate_q(&self, s: &[f64]) -> Result<PathDerivatives, PathError> {
160        self.evaluate_impl(s, Order::Zero)
161    }
162
163    /// Evaluate position, velocity, and acceleration (`q`, `dq`, `ddq`); jerk is not computed.
164    ///
165    /// # Arguments
166    /// - `s` : one-dimensional parameter samples (length `N`)
167    ///
168    /// # Returns
169    /// [`PathDerivatives`] with `dddq = None`;
170    /// `q / dq / ddq` each have shape `(dim, N)`.
171    pub fn evaluate_up_to_2nd(&self, s: &[f64]) -> Result<PathDerivatives, PathError> {
172        self.evaluate_impl(s, Order::Two)
173    }
174
175    /// Evaluate position and all three derivative orders (`q`, `dq`, `ddq`, `dddq`).
176    ///
177    /// This is the most expensive evaluation method.  If jerk is not needed,
178    /// prefer [`evaluate_up_to_2nd`].
179    ///
180    /// # Arguments
181    /// - `s` : one-dimensional parameter samples (length `N`)
182    ///
183    /// # Returns
184    /// [`PathDerivatives`] with all four fields populated; each matrix has shape `(dim, N)`.
185    ///
186    /// # Errors
187    /// - [`PathError::OutOfRangeS`] : a query value is out of range
188    ///
189    /// # Example
190    /// ```rust
191    /// # use copp::path::{Path, sin, cos};
192    /// # use copp::path::autodiff::Jet3;
193    /// let path = Path::from_parametric(
194    ///     |s: Jet3| vec![sin(s), cos(s)],
195    ///     0.0, 1.0,
196    /// ).unwrap();
197    ///
198    /// let s = [0.0, 0.25, 0.5, 0.75, 1.0];
199    /// let out = path.evaluate_up_to_3rd(&s).unwrap();
200    ///
201    /// let dq   = out.dq.as_ref().unwrap();
202    /// let dddq = out.dddq.as_ref().unwrap();
203    /// // dim 0 is sin(s); its first derivative is cos(s), evaluated at s[0]=0.0
204    /// assert!((dq[(0, 0)] - 0.0_f64.cos()).abs() < 1e-10);
205    /// // dim 1 is cos(s); its third derivative is sin(s), evaluated at s[0]=0.0
206    /// assert!((dddq[(1, 0)] - 0.0_f64.sin()).abs() < 1e-10);
207    /// ```
208    ///
209    /// [`evaluate_up_to_2nd`]: Path::evaluate_up_to_2nd
210    pub fn evaluate_up_to_3rd(&self, s: &[f64]) -> Result<PathDerivatives, PathError> {
211        self.evaluate_impl(s, Order::Three)
212    }
213
214    // ── internal ─────────────────────────────────────────────────────────────
215
216    fn evaluate_impl(&self, s: &[f64], order: Order) -> Result<PathDerivatives, PathError> {
217        let n = s.len();
218        let dim = self.dim;
219
220        // Allocate output buffers; skip higher-order buffers when not needed.
221        let mut q = vec![0.0f64; dim * n];
222        let mut dq = (order != Order::Zero).then(|| vec![0.0f64; dim * n]);
223        let mut ddq = (order != Order::Zero).then(|| vec![0.0f64; dim * n]);
224        let mut dddq = (order == Order::Three).then(|| vec![0.0f64; dim * n]);
225
226        match &self.repr {
227            PathRepr::Parametric(eval_fn) => {
228                eval_parametric(
229                    eval_fn,
230                    self,
231                    dim,
232                    (s, &mut q, &mut dq, &mut ddq, &mut dddq),
233                )?;
234            }
235            PathRepr::Spline(spline) => {
236                eval_spline(spline, self, dim, (s, &mut q, &mut dq, &mut ddq, &mut dddq))?;
237            }
238        }
239
240        Ok(PathDerivatives {
241            q: DMatrix::from_vec(dim, n, q),
242            dq: dq.map(|v| DMatrix::from_vec(dim, n, v)),
243            ddq: ddq.map(|v| DMatrix::from_vec(dim, n, v)),
244            dddq: dddq.map(|v| DMatrix::from_vec(dim, n, v)),
245        })
246    }
247
248    #[inline(always)]
249    fn validate_s(&self, s: f64, index: usize) -> Result<f64, PathError> {
250        match self.out_of_range_mode {
251            OutOfRangeMode::Error => {
252                if s < self.s_min - EPS_RANGE || s > self.s_max + EPS_RANGE {
253                    return Err(PathError::OutOfRangeS {
254                        s_min: self.s_min,
255                        s_max: self.s_max,
256                        index,
257                        value: s,
258                    });
259                }
260                Ok(s.clamp(self.s_min, self.s_max))
261            }
262            OutOfRangeMode::Clamp => Ok(s.clamp(self.s_min, self.s_max)),
263        }
264    }
265}
266
267// ── free evaluation functions ─────────────────────────────────────────────────
268
269/// The input of evaluation functions.
270type EvalInput<'a> = (
271    &'a [f64],                // s
272    &'a mut [f64],            // q
273    &'a mut Option<Vec<f64>>, // dq
274    &'a mut Option<Vec<f64>>, // ddq
275    &'a mut Option<Vec<f64>>, // dddq
276);
277
278/// Evaluate a parametric path into pre-allocated column-major buffers.
279///
280/// Per-column parallelism via Rayon: validate + AD-evaluate + write in one pass.
281/// No intermediate `Vec<Vec<Jet3>>` allocation; results go directly into output buffers.
282fn eval_parametric(
283    eval_fn: &ParametricFn,
284    path: &Path,
285    dim: usize,
286    input_eval: EvalInput,
287) -> Result<(), PathError> {
288    let (s_values, q, dq, ddq, dddq) = input_eval;
289    let n = s_values.len();
290
291    // Chunk each output buffer by `dim` so column j maps to slice [j*dim .. (j+1)*dim].
292    // When a derivative level is not requested (`None`), we still need an
293    // `IndexedParallelIterator` of the same length for `zip`; a Vec<None> is the
294    // simplest way to satisfy Rayon's type constraints here.
295    let dq_chunks: Vec<Option<&mut [f64]>> = dq.as_deref_mut().map_or_else(
296        || (0..n).map(|_| None).collect(),
297        |v| v.chunks_mut(dim).map(Some).collect(),
298    );
299    let ddq_chunks: Vec<Option<&mut [f64]>> = ddq.as_deref_mut().map_or_else(
300        || (0..n).map(|_| None).collect(),
301        |v| v.chunks_mut(dim).map(Some).collect(),
302    );
303    let dddq_chunks: Vec<Option<&mut [f64]>> = dddq.as_deref_mut().map_or_else(
304        || (0..n).map(|_| None).collect(),
305        |v| v.chunks_mut(dim).map(Some).collect(),
306    );
307
308    s_values
309        .par_iter()
310        .enumerate()
311        .zip(q.par_chunks_mut(dim))
312        .zip(dq_chunks.into_par_iter())
313        .zip(ddq_chunks.into_par_iter())
314        .zip(dddq_chunks.into_par_iter())
315        .map(
316            |(((((j, &s_raw), q_col), mut dq_col), mut ddq_col), mut dddq_col)| {
317                let s_curr = path.validate_s(s_raw, j)?;
318                let vals = eval_fn(Jet3::seed(s_curr));
319                if vals.len() != dim {
320                    return Err(PathError::DimensionMismatch);
321                }
322                for (i, jet) in vals.iter().enumerate() {
323                    q_col[i] = jet.v;
324                    if let Some(ref mut b) = dq_col {
325                        b[i] = jet.d1;
326                    }
327                    if let Some(ref mut b) = ddq_col {
328                        b[i] = jet.d2;
329                    }
330                    if let Some(ref mut b) = dddq_col {
331                        b[i] = jet.d3;
332                    }
333                }
334                Ok(())
335            },
336        )
337        .collect()
338}
339
340/// Evaluate spline representation into pre-allocated column-major buffers.
341///
342/// Dispatches to `SplinePath::eval_at::<ORDER>` with the minimum derivative
343/// order that satisfies the request — zero run-time branching per sample:
344///   - `Order::Zero`  → `eval_at::<0>` (q only)
345///   - `Order::Two`   → `eval_at::<2>` (q, dq, ddq)
346///   - `Order::Three` → `eval_at::<3>` (q, dq, ddq, dddq)
347fn eval_spline(
348    spline: &SplinePath,
349    path: &Path,
350    dim: usize,
351    input_eval: EvalInput,
352) -> Result<(), PathError> {
353    let (s_values, q, dq, ddq, dddq) = input_eval;
354    match (dq.as_deref_mut(), ddq.as_deref_mut(), dddq.as_deref_mut()) {
355        // ── Order::Zero: q only ───────────────────────────────────────────
356        (None, None, None) => s_values
357            .par_iter()
358            .enumerate()
359            .zip(q.par_chunks_mut(dim))
360            .map(|((j, &s_raw), q_col)| -> Result<(), PathError> {
361                let s_curr = path.validate_s(s_raw, j)?;
362                // eval_at::<0> only writes q_col; the derivative slices are never
363                // accessed, so zero-length arrays satisfy the borrow checker.
364                let (mut no_dq, mut no_ddq, mut no_dddq): ([f64; 0], [f64; 0], [f64; 0]) =
365                    ([], [], []);
366                spline.eval_at::<0>(s_curr, dim, q_col, &mut no_dq, &mut no_ddq, &mut no_dddq);
367                Ok(())
368            })
369            .collect(),
370        // ── Order::Two: q, dq, ddq ────────────────────────────────────────
371        (Some(dq_buf), Some(ddq_buf), None) => {
372            let dq_chunks: Vec<&mut [f64]> = dq_buf.chunks_mut(dim).collect();
373            let ddq_chunks: Vec<&mut [f64]> = ddq_buf.chunks_mut(dim).collect();
374            s_values
375                .par_iter()
376                .enumerate()
377                .zip(q.par_chunks_mut(dim))
378                .zip(dq_chunks.into_par_iter())
379                .zip(ddq_chunks.into_par_iter())
380                .map(
381                    |((((j, &s_raw), q_col), dq_col), ddq_col)| -> Result<(), PathError> {
382                        let s_curr = path.validate_s(s_raw, j)?;
383                        let mut s4 = [];
384                        spline.eval_at::<2>(s_curr, dim, q_col, dq_col, ddq_col, &mut s4);
385                        Ok(())
386                    },
387                )
388                .collect()
389        }
390        // ── Order::Three: q, dq, ddq, dddq ───────────────────────────────
391        (Some(dq_buf), Some(ddq_buf), Some(dddq_buf)) => {
392            let dq_chunks: Vec<&mut [f64]> = dq_buf.chunks_mut(dim).collect();
393            let ddq_chunks: Vec<&mut [f64]> = ddq_buf.chunks_mut(dim).collect();
394            let dddq_chunks: Vec<&mut [f64]> = dddq_buf.chunks_mut(dim).collect();
395            s_values
396                .par_iter()
397                .enumerate()
398                .zip(q.par_chunks_mut(dim))
399                .zip(dq_chunks.into_par_iter())
400                .zip(ddq_chunks.into_par_iter())
401                .zip(dddq_chunks.into_par_iter())
402                .map(
403                    |(((((j, &s_raw), q_col), dq_col), ddq_col), dddq_col)| -> Result<(), PathError> {
404                        let s_curr = path.validate_s(s_raw, j)?;
405                        spline.eval_at::<3>(s_curr, dim, q_col, dq_col, ddq_col, dddq_col);
406                        Ok(())
407                    },
408                )
409                .collect()
410        }
411        // Unreachable: evaluate_impl only produces the three patterns above.
412        _ => unreachable!("unexpected dq/ddq/dddq combination"),
413    }
414}
415
416// ── helpers ───────────────────────────────────────────────────────────────────
417
418fn validate_range(s_min: f64, s_max: f64) -> Result<(), PathError> {
419    if !s_min.is_finite() || !s_max.is_finite() || s_max <= s_min {
420        return Err(PathError::InvalidRange { s_min, s_max });
421    }
422    Ok(())
423}
424
425#[cfg(test)]
426mod tests {
427    use super::PathDerivatives;
428    use crate::path::{Jet3, Path as PathModel, PathError, SplineConfig, cos, exp, sin};
429    use nalgebra::DMatrix;
430    use plotters::prelude::*;
431    use rand::RngExt;
432    use std::error::Error;
433    use std::fs::create_dir_all;
434    use std::hint::black_box;
435    use std::path::Path as StdPath;
436    use std::time::Instant;
437
438    const DIM: usize = 6;
439
440    fn make_s(n: usize) -> DMatrix<f64> {
441        DMatrix::<f64>::from_fn(1, n, |_, j| j as f64 / (n - 1) as f64)
442    }
443
444    fn make_parametric_path() -> Result<PathModel, PathError> {
445        PathModel::from_parametric(
446            |s: Jet3| {
447                vec![
448                    sin(s),
449                    cos(s),
450                    exp(0.3 * s) - 1.0,
451                    s + 0.1 * s * s - 0.01 * s * s * s * s,
452                    sin(2.0 * s) + 0.15 * cos(3.0 * s),
453                    sin(s) * cos(s),
454                ]
455            },
456            0.0,
457            1.0,
458        )
459    }
460
461    fn make_waypoints(n_pts: usize) -> DMatrix<f64> {
462        let mut rng = rand::rng();
463        // Random-walk waypoints: each row is one DOF, each column is a waypoint.
464        let mut waypoints = DMatrix::<f64>::zeros(DIM, n_pts);
465        for mut row in waypoints.row_iter_mut() {
466            row[0] = rng.random_range(-1.0..1.0);
467            for j in 1..n_pts {
468                let step = rng.random_range(-0.35..0.35);
469                row[j] = row[j - 1] + step;
470            }
471        }
472        waypoints
473    }
474
475    #[test]
476    fn test_parametric_autodiff_dim6() -> Result<(), PathError> {
477        let path = make_parametric_path()?;
478
479        let n = 300;
480        let s = make_s(n);
481        let out = path.evaluate_up_to_3rd(s.as_slice())?;
482        let dq = out.dq.as_ref().unwrap();
483        let ddq = out.ddq.as_ref().unwrap();
484        let dddq = out.dddq.as_ref().unwrap();
485
486        // Build expected values for all query points and check all 4 derivative orders.
487        s.as_slice().iter().enumerate().for_each(|(j, &x)| {
488            let e03x = (0.3 * x).exp();
489            let expected_q = [
490                x.sin(),
491                x.cos(),
492                e03x - 1.0,
493                x + 0.1 * x * x - 0.01 * x * x * x * x,
494                (2.0 * x).sin() + 0.15 * (3.0 * x).cos(),
495                x.sin() * x.cos(),
496            ];
497            let expected_dq = [
498                x.cos(),
499                -x.sin(),
500                0.3 * e03x,
501                1.0 + 0.2 * x - 0.04 * x * x * x,
502                2.0 * (2.0 * x).cos() - 0.45 * (3.0 * x).sin(),
503                (2.0 * x).cos(),
504            ];
505            let expected_ddq = [
506                -x.sin(),
507                -x.cos(),
508                0.09 * e03x,
509                0.2 - 0.12 * x * x,
510                -4.0 * (2.0 * x).sin() - 1.35 * (3.0 * x).cos(),
511                -2.0 * (2.0 * x).sin(),
512            ];
513            let expected_dddq = [
514                -x.cos(),
515                x.sin(),
516                0.027 * e03x,
517                -0.24 * x,
518                -8.0 * (2.0 * x).cos() + 4.05 * (3.0 * x).sin(),
519                -4.0 * (2.0 * x).cos(),
520            ];
521
522            for i in 0..DIM {
523                assert!(
524                    (out.q[(i, j)] - expected_q[i]).abs() < 1e-10,
525                    "q    dim={i} idx={j}"
526                );
527                assert!(
528                    (dq[(i, j)] - expected_dq[i]).abs() < 1e-10,
529                    "dq   dim={i} idx={j}"
530                );
531                assert!(
532                    (ddq[(i, j)] - expected_ddq[i]).abs() < 1e-10,
533                    "ddq  dim={i} idx={j}"
534                );
535                assert!(
536                    (dddq[(i, j)] - expected_dddq[i]).abs() < 1e-10,
537                    "dddq dim={i} idx={j}"
538                );
539            }
540        });
541
542        Ok(())
543    }
544
545    #[test]
546    fn test_evaluate_q_only() -> Result<(), PathError> {
547        let path = make_parametric_path()?;
548        let n = 100;
549        let s = make_s(n);
550        let out = path.evaluate_q(s.as_slice())?;
551
552        assert!(out.dq.is_none());
553        assert!(out.ddq.is_none());
554        assert!(out.dddq.is_none());
555
556        for j in 0..n {
557            let x = s[(0, j)];
558            assert!((out.q[(0, j)] - x.sin()).abs() < 1e-10);
559            assert!((out.q[(1, j)] - x.cos()).abs() < 1e-10);
560        }
561
562        Ok(())
563    }
564
565    #[test]
566    fn test_evaluate_up_to_2nd() -> Result<(), PathError> {
567        let path = make_parametric_path()?;
568        let n = 100;
569        let s = make_s(n);
570        let out = path.evaluate_up_to_2nd(s.as_slice())?;
571        let dq = out.dq.as_ref().unwrap();
572        let ddq = out.ddq.as_ref().unwrap();
573
574        assert!(out.dddq.is_none());
575
576        for j in 0..n {
577            let x = s[(0, j)];
578            assert!((out.q[(0, j)] - x.sin()).abs() < 1e-10);
579            assert!((dq[(0, j)] - x.cos()).abs() < 1e-10);
580            assert!((ddq[(0, j)] - (-x.sin())).abs() < 1e-10);
581        }
582
583        Ok(())
584    }
585
586    #[test]
587    fn test_quintic_spline_interpolates_waypoints_dim6() -> Result<(), PathError> {
588        let n_pts = 25;
589        let waypoints = make_waypoints(n_pts);
590
591        let cfg = SplineConfig::default();
592        let path = PathModel::from_waypoints(&waypoints, cfg)?;
593
594        let s = DMatrix::<f64>::from_fn(1, n_pts, |_, j| j as f64 / (n_pts - 1) as f64);
595        let out = path.evaluate_up_to_3rd(s.as_slice())?;
596        let dq = out.dq.as_ref().unwrap();
597        let ddq = out.ddq.as_ref().unwrap();
598        let dddq = out.dddq.as_ref().unwrap();
599
600        // The spline must interpolate every waypoint exactly (up to floating-point rounding)
601        // and all derivatives must be finite (no blowup).
602        for (i, j) in (0..DIM).flat_map(|i| (0..n_pts).map(move |j| (i, j))) {
603            assert!((out.q[(i, j)] - waypoints[(i, j)]).abs() < 1e-8);
604            assert!(dq[(i, j)].is_finite());
605            assert!(ddq[(i, j)].is_finite());
606            assert!(dddq[(i, j)].is_finite());
607        }
608
609        Ok(())
610    }
611
612    #[test]
613    fn test_s_out_of_range_error_dim6() -> Result<(), PathError> {
614        let waypoints = make_waypoints(12);
615        let path = PathModel::from_waypoints(&waypoints, SplineConfig::default())?;
616        let s = DMatrix::<f64>::from_row_slice(1, 3, &[-0.1, 0.5, 1.1]);
617        let err = path.evaluate_up_to_3rd(s.as_slice()).unwrap_err();
618        match err {
619            PathError::OutOfRangeS { .. } => {}
620            _ => panic!("expected OutOfRangeS"),
621        }
622        Ok(())
623    }
624
625    #[test]
626    fn test_benchmark_parametric_and_spline_dim6() -> Result<(), PathError> {
627        let n_eval = 3000;
628        let n_repeat = 8;
629        let s = make_s(n_eval);
630
631        let start = Instant::now();
632        let param_path = make_parametric_path()?;
633        let tc_build_param = start.elapsed().as_secs_f64() * 1e3;
634
635        let start = Instant::now();
636        for _ in 0..n_repeat {
637            let out = param_path.evaluate_up_to_3rd(s.as_slice())?;
638            black_box(out.q[(0, 0)]);
639        }
640        let tc_eval_param = start.elapsed().as_secs_f64() * 1e3 / n_repeat as f64;
641        println!(
642            "[bench][parametric][dim=6] build={tc_build_param:.3} ms eval={tc_eval_param:.3} ms (N={n_eval})"
643        );
644
645        let n_waypoints_list = [16usize, 32, 64, 128, 192, 256, 512, 1024];
646        for &n_pts in &n_waypoints_list {
647            let waypoints = make_waypoints(n_pts);
648            let start = Instant::now();
649            let spline_path = PathModel::from_waypoints(&waypoints, SplineConfig::default())?;
650            let tc_build = start.elapsed().as_secs_f64() * 1e3;
651
652            let start = Instant::now();
653            for _ in 0..n_repeat {
654                let out = spline_path.evaluate_up_to_3rd(s.as_slice())?;
655                black_box(out.q[(0, 0)]);
656            }
657            let tc_eval = start.elapsed().as_secs_f64() * 1e3 / n_repeat as f64;
658
659            println!(
660                "[bench][spline][dim=6][n_pts={n_pts}] build={tc_build:.3} ms eval={tc_eval:.3} ms"
661            );
662        }
663
664        Ok(())
665    }
666
667    #[test]
668    #[ignore = "plotting"]
669    fn test_plot_parametric_and_spline_derivatives() -> Result<(), Box<dyn Error>> {
670        let dir = "data/path_plots";
671        create_dir_all(dir)?;
672
673        let n = 600;
674        let s = make_s(n);
675        let s_vec: Vec<f64> = (0..n).map(|j| s[(0, j)]).collect();
676
677        let param_path = make_parametric_path()?;
678        let param_out = param_path.evaluate_up_to_3rd(s.as_slice())?;
679        plot_grid_4x6(
680            &format!("{dir}/parametric_dim6_grid.png"),
681            "parametric dim=6",
682            &s_vec,
683            &param_out,
684            None,
685        )?;
686
687        let n_pts = 10;
688        let waypoints = make_waypoints(n_pts);
689        let spline_path = PathModel::from_waypoints(&waypoints, SplineConfig::default())?;
690        let spline_out = spline_path.evaluate_up_to_3rd(s.as_slice())?;
691        let wp_s: Vec<f64> = (0..n_pts).map(|j| j as f64 / (n_pts - 1) as f64).collect();
692        plot_grid_4x6(
693            &format!("{dir}/spline_order5_dim6_grid.png"),
694            "spline order=5 dim=6",
695            &s_vec,
696            &spline_out,
697            Some((&wp_s, &waypoints)),
698        )?;
699
700        Ok(())
701    }
702
703    fn plot_grid_4x6(
704        file: &str,
705        title: &str,
706        s: &[f64],
707        data: &PathDerivatives,
708        waypoints: Option<(&[f64], &DMatrix<f64>)>,
709    ) -> Result<(), Box<dyn Error>> {
710        if let Some(parent) = StdPath::new(file).parent() {
711            create_dir_all(parent)?;
712        }
713
714        let root = BitMapBackend::new(file, (2400, 1400)).into_drawing_area();
715        root.fill(&WHITE)?;
716
717        let empty = DMatrix::<f64>::zeros(0, 0);
718        let dq = data.dq.as_ref().unwrap_or(&empty);
719        let ddq = data.ddq.as_ref().unwrap_or(&empty);
720        let dddq = data.dddq.as_ref().unwrap_or(&empty);
721
722        let areas = root.split_evenly((4, DIM));
723        let mats = [&data.q, dq, ddq, dddq];
724        let row_names = ["q", "dq", "ddq", "dddq"];
725
726        for row in 0..4 {
727            for col in 0..DIM {
728                let area = &areas[row * DIM + col];
729                let series = mat_row(mats[row], col);
730                let (mut y_min, mut y_max) = min_max_slice(&series);
731                if (y_max - y_min).abs() < 1e-12 {
732                    y_min -= 1.0;
733                    y_max += 1.0;
734                } else {
735                    let pad = 0.08 * (y_max - y_min);
736                    y_min -= pad;
737                    y_max += pad;
738                }
739
740                let mut chart = ChartBuilder::on(area)
741                    .margin(8)
742                    .caption(
743                        format!("{} j{}", row_names[row], col + 1),
744                        ("sans-serif", 16),
745                    )
746                    .x_label_area_size(24)
747                    .y_label_area_size(38)
748                    .build_cartesian_2d(s[0]..s[s.len() - 1], y_min..y_max)?;
749
750                chart
751                    .configure_mesh()
752                    .x_desc(if row == 3 { "s" } else { "" })
753                    .y_desc("")
754                    .draw()?;
755
756                chart.draw_series(LineSeries::new(
757                    (0..s.len()).map(|j| (s[j], series[j])),
758                    &BLUE,
759                ))?;
760
761                if row == 0
762                    && let Some((s_wp, q_wp)) = waypoints
763                {
764                    chart.draw_series(
765                        s_wp.iter()
766                            .zip(q_wp.row(col).iter())
767                            .map(|(&xs, &ys)| Circle::new((xs, ys), 2, RED.filled())),
768                    )?;
769                }
770            }
771        }
772
773        root.titled(title, ("sans-serif", 28))?;
774        root.present()?;
775        Ok(())
776    }
777
778    fn mat_row(mat: &DMatrix<f64>, row: usize) -> Vec<f64> {
779        mat.row(row).iter().copied().collect()
780    }
781
782    fn min_max_slice(data: &[f64]) -> (f64, f64) {
783        data.iter()
784            .copied()
785            .fold((f64::INFINITY, f64::NEG_INFINITY), |(mn, mx), v| {
786                (mn.min(v), mx.max(v))
787            })
788    }
789}