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, DMatrixView};
6use rayon::prelude::*;
7use std::sync::Arc;
8
9const EPS_RANGE: f64 = 1e-12;
10
11/// Shared analytic path function used by [`Path::from_parametric`].
12///
13/// The input is a seeded [`Jet3`] scalar representing path parameter `s`, and
14/// the returned vector contains one [`Jet3`] per path dimension.
15pub type ParametricFn = Arc<dyn Fn(Jet3) -> Vec<Jet3> + Send + Sync>;
16
17/// User-provided path evaluator with explicit derivatives up to second order.
18///
19/// Implement this trait when path derivatives are already available from an
20/// external model, library, or hand-written analytic formula. Unlike
21/// [`Path::from_parametric`], no automatic differentiation is performed: the
22/// evaluator writes `q`, `dq`, and `ddq` directly into pre-allocated
23/// column-major buffers.
24///
25/// Buffer layout is always `dim x s.len()` in column-major order:
26/// `buffer[row + col * dim]` corresponds to path dimension `row` at sample
27/// `s[col]`. Empty `s` slices are valid no-ops.
28///
29/// See [`Path::from_evaluator_2nd`] for a complete constructor example.
30pub trait PathEvaluator2nd: Send + Sync {
31    /// Return the path dimension.
32    ///
33    /// The dimension must remain stable for the lifetime of the evaluator and
34    /// must be greater than zero.
35    fn dim(&self) -> usize;
36
37    /// Evaluate position only.
38    ///
39    /// The default implementation calls [`PathEvaluator2nd::evaluate_up_to_2nd`]
40    /// with temporary derivative buffers. Override this method if computing
41    /// only `q` is substantially cheaper for the evaluator.
42    fn evaluate_q(&self, s: &[f64], q: &mut [f64]) -> Result<(), PathError> {
43        let mut dq = vec![0.0; q.len()];
44        let mut ddq = vec![0.0; q.len()];
45        self.evaluate_up_to_2nd(s, q, &mut dq, &mut ddq)
46    }
47
48    /// Evaluate `q`, `dq`, and `ddq` at all supplied path parameters.
49    ///
50    /// All output buffers have length `dim() * s.len()` and use column-major
51    /// layout.
52    fn evaluate_up_to_2nd(
53        &self,
54        s: &[f64],
55        q: &mut [f64],
56        dq: &mut [f64],
57        ddq: &mut [f64],
58    ) -> Result<(), PathError>;
59}
60
61/// User-provided path evaluator with explicit derivatives up to third order.
62///
63/// This extends [`PathEvaluator2nd`] with jerk-level path derivatives. Use it
64/// when the path will be sampled by TOPP3/COPP3 workflows or any API that calls
65/// [`Path::evaluate_up_to_3rd`].
66///
67/// See [`Path::from_evaluator_3rd`] for a complete constructor example.
68pub trait PathEvaluator3rd: PathEvaluator2nd {
69    /// Evaluate `q`, `dq`, `ddq`, and `dddq` at all supplied path parameters.
70    ///
71    /// All output buffers have length `dim() * s.len()` and use column-major
72    /// layout.
73    fn evaluate_up_to_3rd(
74        &self,
75        s: &[f64],
76        q: &mut [f64],
77        dq: &mut [f64],
78        ddq: &mut [f64],
79        dddq: &mut [f64],
80    ) -> Result<(), PathError>;
81}
82
83/// Compatibility alias for third-order explicit path evaluators.
84///
85/// New code should prefer [`PathEvaluator2nd`] or [`PathEvaluator3rd`] to make
86/// the supported derivative order explicit.
87pub trait PathEvaluator: PathEvaluator3rd {}
88
89impl<T: PathEvaluator3rd + ?Sized> PathEvaluator for T {}
90
91/// Output of path evaluation.
92///
93/// `dq`, `ddq`, `dddq` are `None` when the evaluation did not request them
94/// (e.g. `evaluate_q` only fills `q`; `evaluate_up_to_2nd` fills `q/dq/ddq`).
95#[derive(Debug)]
96pub struct PathDerivatives {
97    /// Position samples with shape `(dim, s.len())`.
98    pub q: DMatrix<f64>,
99    /// First derivative samples `dq/ds`, populated by second- and third-order evaluation.
100    pub dq: Option<DMatrix<f64>>,
101    /// Second derivative samples `d^2q/ds^2`, populated by second- and third-order evaluation.
102    pub ddq: Option<DMatrix<f64>>,
103    /// Third derivative samples `d^3q/ds^3`, populated only by third-order evaluation.
104    pub dddq: Option<DMatrix<f64>>,
105}
106
107/// How many derivative orders to compute.
108#[derive(Clone, Copy, PartialEq, Eq)]
109enum Order {
110    Zero,  // q only
111    Two,   // q, dq, ddq
112    Three, // q, dq, ddq, dddq
113}
114
115/// Unified path abstraction over parametric, spline, and evaluator representations.
116///
117/// Construct via [`Path::from_parametric`](crate::path::Path::from_parametric),
118/// [`Path::from_waypoints`](crate::path::Path::from_waypoints),
119/// [`Path::from_evaluator_2nd`](crate::path::Path::from_evaluator_2nd), or
120/// [`Path::from_evaluator_3rd`](crate::path::Path::from_evaluator_3rd), then
121/// query a batch of parameter values with the `evaluate_*` family of methods.
122///
123/// The valid parameter domain is `[s_min, s_max]` (set at construction time).
124/// Out-of-range behaviour is controlled by [`OutOfRangeMode`](crate::path::OutOfRangeMode): the default is to
125/// return an error; it can be changed to silent clamping.
126pub struct Path {
127    dim: usize,
128    s_min: f64,
129    s_max: f64,
130    out_of_range_mode: OutOfRangeMode,
131    repr: PathRepr,
132}
133
134enum PathRepr {
135    /// Closure-based path evaluated via third-order forward AD.
136    Parametric(ParametricFn),
137    /// Piecewise-polynomial path built from waypoints.
138    Spline(SplinePath),
139    /// User-provided path evaluator with explicit derivatives up to second order.
140    Evaluator2nd(Arc<dyn PathEvaluator2nd>),
141    /// User-provided path evaluator with explicit derivatives up to third order.
142    Evaluator3rd(Arc<dyn PathEvaluator3rd>),
143}
144
145impl Path {
146    /// Build a parametric path from an analytic closure.
147    ///
148    /// Derivatives up to third order are computed automatically via [`Jet3`](crate::path::Jet3)
149    /// forward-mode AD.  The closure only needs to express `q(s)` symbolically;
150    /// no manual differentiation is required.
151    ///
152    /// # Arguments
153    /// - `q_fn`  : closure mapping scalar `s` to a `dim`-dimensional position vector
154    /// - `s_min` : lower bound of the path parameter
155    /// - `s_max` : upper bound of the path parameter (`s_max > s_min` required)
156    ///
157    /// # Errors
158    /// - [`PathError::InvalidRange`](crate::diag::PathError::InvalidRange)     : `s_min >= s_max` or either value is non-finite
159    /// - [`PathError::InvalidDimension`](crate::diag::PathError::InvalidDimension) : closure returned an empty vector
160    ///
161    /// # Example
162    /// The example below builds a two-dimensional analytic path and evaluates
163    /// derivatives produced by automatic differentiation.
164    ///
165    /// ```rust
166    /// # fn main() -> Result<(), copp::diag::CoppError> {
167    /// use copp::path::autodiff::Jet3;
168    /// use copp::path::{cos, sin, Path};
169    ///
170    /// let path = Path::from_parametric(|s: Jet3| vec![sin(s), cos(s)], 0.0, 1.0)?;
171    ///
172    /// let s = [0.0, 0.25, 0.5, 0.75, 1.0];
173    /// let out = path.evaluate_up_to_3rd(s.as_slice())?;
174    /// assert_eq!(out.q.shape(), (2, 5));
175    /// assert!(out.dddq.is_some());
176    /// # Ok(())
177    /// # }
178    /// ```
179    pub fn from_parametric<F>(q_fn: F, s_min: f64, s_max: f64) -> Result<Self, PathError>
180    where
181        F: Fn(Jet3) -> Vec<Jet3> + Send + Sync + 'static,
182    {
183        validate_range(s_min, s_max)?;
184
185        let sample = q_fn(Jet3::constant((s_min + s_max) * 0.5));
186        if sample.is_empty() {
187            return Err(PathError::InvalidDimension { dim: 0 });
188        }
189        let dim = sample.len();
190
191        Ok(Self {
192            dim,
193            s_min,
194            s_max,
195            out_of_range_mode: OutOfRangeMode::Error,
196            repr: PathRepr::Parametric(Arc::new(q_fn)),
197        })
198    }
199
200    /// Build a path from an evaluator that provides explicit derivatives up to second order.
201    ///
202    /// Use this constructor when derivatives are already available from an
203    /// external source and automatic differentiation is not desired. The
204    /// evaluator is owned by the returned [`Path`] through an internal
205    /// [`Arc`], so the path can be passed around without borrowing the original
206    /// value.
207    ///
208    /// # Arguments
209    /// - `evaluator`: object that writes column-major derivative buffers
210    /// - `s_min`: lower bound of the path parameter
211    /// - `s_max`: upper bound of the path parameter (`s_max > s_min` required)
212    ///
213    /// # Errors
214    /// - [`PathError::InvalidRange`](crate::diag::PathError::InvalidRange): `s_min >= s_max` or either value is non-finite
215    /// - [`PathError::InvalidDimension`](crate::diag::PathError::InvalidDimension): evaluator dimension is zero
216    ///
217    /// # Example
218    /// The example below wraps a two-dimensional external evaluator that writes
219    /// explicit `q/dq/ddq` buffers in column-major order.
220    ///
221    /// ```rust
222    /// # fn main() -> Result<(), copp::diag::CoppError> {
223    /// use copp::diag::PathError;
224    /// use copp::path::{Path, PathEvaluator2nd};
225    ///
226    /// struct NormalizedEvaluator2nd;
227    ///
228    /// impl PathEvaluator2nd for NormalizedEvaluator2nd {
229    ///     fn dim(&self) -> usize {
230    ///         2
231    ///     }
232    ///
233    ///     fn evaluate_up_to_2nd(
234    ///         &self,
235    ///         s: &[f64],
236    ///         q: &mut [f64],
237    ///         dq: &mut [f64],
238    ///         ddq: &mut [f64],
239    ///     ) -> Result<(), PathError> {
240    ///         for (col, &sj) in s.iter().enumerate() {
241    ///             let row0 = 2 * col;
242    ///             q[row0] = 0.5 * sj * sj;
243    ///             q[row0 + 1] = sj;
244    ///             dq[row0] = sj;
245    ///             dq[row0 + 1] = 1.0;
246    ///             ddq[row0] = 1.0;
247    ///             ddq[row0 + 1] = 0.0;
248    ///         }
249    ///         Ok(())
250    ///     }
251    /// }
252    ///
253    /// let path = Path::from_evaluator_2nd(NormalizedEvaluator2nd, 0.0, 1.0)?;
254    /// let out = path.evaluate_up_to_2nd(&[0.0, 0.5])?;
255    /// assert_eq!(out.q.shape(), (2, 2));
256    /// assert_eq!(out.q[(0, 1)], 0.125);
257    /// assert!(out.dddq.is_none());
258    /// # Ok(())
259    /// # }
260    /// ```
261    pub fn from_evaluator_2nd<E>(evaluator: E, s_min: f64, s_max: f64) -> Result<Self, PathError>
262    where
263        E: PathEvaluator2nd + 'static,
264    {
265        Self::from_shared_evaluator_2nd(Arc::new(evaluator), s_min, s_max)
266    }
267
268    /// Build a path from a shared explicit-derivative evaluator up to second order.
269    ///
270    /// This is the same representation as [`Path::from_evaluator_2nd`], but accepts
271    /// an already shared evaluator. It is useful when multiple paths or
272    /// application components need to hold the same evaluator object.
273    ///
274    /// # Errors
275    /// - [`PathError::InvalidRange`](crate::diag::PathError::InvalidRange): `s_min >= s_max` or either value is non-finite
276    /// - [`PathError::InvalidDimension`](crate::diag::PathError::InvalidDimension): evaluator dimension is zero
277    pub fn from_shared_evaluator_2nd(
278        evaluator: Arc<dyn PathEvaluator2nd>,
279        s_min: f64,
280        s_max: f64,
281    ) -> Result<Self, PathError> {
282        validate_range(s_min, s_max)?;
283
284        let dim = evaluator.dim();
285        if dim == 0 {
286            return Err(PathError::InvalidDimension { dim });
287        }
288
289        Ok(Self {
290            dim,
291            s_min,
292            s_max,
293            out_of_range_mode: OutOfRangeMode::Error,
294            repr: PathRepr::Evaluator2nd(evaluator),
295        })
296    }
297
298    /// Build a path from an evaluator that provides explicit derivatives up to third order.
299    ///
300    /// This is the constructor to use when the path will be evaluated by
301    /// third-order APIs such as TOPP3/COPP3 sampling.  For TOPP2/COPP2-only
302    /// usage, [`Path::from_evaluator_2nd`] avoids requiring a third derivative
303    /// implementation.
304    ///
305    /// # Errors
306    /// - [`PathError::InvalidRange`](crate::diag::PathError::InvalidRange): `s_min >= s_max` or either value is non-finite
307    /// - [`PathError::InvalidDimension`](crate::diag::PathError::InvalidDimension): evaluator dimension is zero
308    ///
309    /// # Example
310    /// The example below extends a two-dimensional explicit evaluator to third
311    /// order by writing jerk samples into the `dddq` buffer.
312    ///
313    /// ```rust
314    /// # fn main() -> Result<(), copp::diag::CoppError> {
315    /// use copp::diag::PathError;
316    /// use copp::path::{Path, PathEvaluator2nd, PathEvaluator3rd};
317    ///
318    /// struct NormalizedEvaluator3rd;
319    ///
320    /// impl PathEvaluator2nd for NormalizedEvaluator3rd {
321    ///     fn dim(&self) -> usize {
322    ///         2
323    ///     }
324    ///
325    ///     fn evaluate_up_to_2nd(
326    ///         &self,
327    ///         s: &[f64],
328    ///         q: &mut [f64],
329    ///         dq: &mut [f64],
330    ///         ddq: &mut [f64],
331    ///     ) -> Result<(), PathError> {
332    ///         for (col, &sj) in s.iter().enumerate() {
333    ///             let row0 = 2 * col;
334    ///             q[row0] = 0.5 * sj * sj;
335    ///             q[row0 + 1] = sj;
336    ///             dq[row0] = sj;
337    ///             dq[row0 + 1] = 1.0;
338    ///             ddq[row0] = 1.0;
339    ///             ddq[row0 + 1] = 0.0;
340    ///         }
341    ///         Ok(())
342    ///     }
343    /// }
344    ///
345    /// impl PathEvaluator3rd for NormalizedEvaluator3rd {
346    ///     fn evaluate_up_to_3rd(
347    ///         &self,
348    ///         s: &[f64],
349    ///         q: &mut [f64],
350    ///         dq: &mut [f64],
351    ///         ddq: &mut [f64],
352    ///         dddq: &mut [f64],
353    ///     ) -> Result<(), PathError> {
354    ///         self.evaluate_up_to_2nd(s, q, dq, ddq)?;
355    ///         dddq.fill(0.0);
356    ///         Ok(())
357    ///     }
358    /// }
359    ///
360    /// let path = Path::from_evaluator_3rd(NormalizedEvaluator3rd, 0.0, 1.0)?;
361    /// let out = path.evaluate_up_to_3rd(&[0.0, 0.5])?;
362    /// assert_eq!(out.q.shape(), (2, 2));
363    /// assert_eq!(out.q[(0, 1)], 0.125);
364    /// assert!(out.dddq.is_some());
365    /// # Ok(())
366    /// # }
367    /// ```
368    pub fn from_evaluator_3rd<E>(evaluator: E, s_min: f64, s_max: f64) -> Result<Self, PathError>
369    where
370        E: PathEvaluator3rd + 'static,
371    {
372        Self::from_shared_evaluator_3rd(Arc::new(evaluator), s_min, s_max)
373    }
374
375    /// Build a path from a shared explicit-derivative evaluator up to third order.
376    ///
377    /// # Errors
378    /// - [`PathError::InvalidRange`](crate::diag::PathError::InvalidRange): `s_min >= s_max` or either value is non-finite
379    /// - [`PathError::InvalidDimension`](crate::diag::PathError::InvalidDimension): evaluator dimension is zero
380    pub fn from_shared_evaluator_3rd(
381        evaluator: Arc<dyn PathEvaluator3rd>,
382        s_min: f64,
383        s_max: f64,
384    ) -> Result<Self, PathError> {
385        validate_range(s_min, s_max)?;
386
387        let dim = evaluator.dim();
388        if dim == 0 {
389            return Err(PathError::InvalidDimension { dim });
390        }
391
392        Ok(Self {
393            dim,
394            s_min,
395            s_max,
396            out_of_range_mode: OutOfRangeMode::Error,
397            repr: PathRepr::Evaluator3rd(evaluator),
398        })
399    }
400
401    /// Build a path from a third-order explicit-derivative evaluator.
402    ///
403    /// This compatibility constructor is equivalent to
404    /// [`Path::from_evaluator_3rd`]. New code should prefer
405    /// [`Path::from_evaluator_2nd`] or [`Path::from_evaluator_3rd`] to make the
406    /// supported derivative order explicit.
407    pub fn from_evaluator<E>(evaluator: E, s_min: f64, s_max: f64) -> Result<Self, PathError>
408    where
409        E: PathEvaluator3rd + 'static,
410    {
411        Self::from_evaluator_3rd(evaluator, s_min, s_max)
412    }
413
414    /// Build a path from a shared third-order explicit-derivative evaluator.
415    ///
416    /// This compatibility constructor is equivalent to
417    /// [`Path::from_shared_evaluator_3rd`].
418    pub fn from_shared_evaluator(
419        evaluator: Arc<dyn PathEvaluator3rd>,
420        s_min: f64,
421        s_max: f64,
422    ) -> Result<Self, PathError> {
423        Self::from_shared_evaluator_3rd(evaluator, s_min, s_max)
424    }
425
426    /// Build a spline path by interpolating a waypoint matrix.
427    ///
428    /// Internally solves the Hermite spline system with an O(N) block-Thomas
429    /// algorithm; all dimensions are solved in parallel.
430    /// The default configuration ([`SplineConfig::default`](crate::path::SplineConfig::default)) uses a quintic
431    /// (order-5) spline with `s in [0, 1]`.
432    ///
433    /// # Arguments
434    /// - `waypoints` : matrix of shape `(dim, n_points)`; each column is one waypoint
435    /// - `cfg`       : spline configuration (order, parameter range, boundary derivatives, out-of-range mode)
436    ///
437    /// # Errors
438    /// - [`PathError::InvalidDimension`](crate::diag::PathError::InvalidDimension)   : `waypoints` has zero rows
439    /// - [`PathError::NotEnoughWaypoints`](crate::diag::PathError::NotEnoughWaypoints) : fewer than 2 columns
440    /// - [`PathError::InvalidOrder`](crate::diag::PathError::InvalidOrder)       : `order < 3`
441    /// - [`PathError::InvalidRange`](crate::diag::PathError::InvalidRange)       : invalid parameter range
442    /// - [`PathError::SingularSystem`](crate::diag::PathError::SingularSystem)     : spline system is singular (extremely rare)
443    ///
444    /// # Example
445    /// The example below builds a spline through two-dimensional waypoints and
446    /// evaluates position samples.
447    ///
448    /// ```rust
449    /// # fn main() -> Result<(), copp::diag::CoppError> {
450    /// use copp::path::{Path, SplineConfig};
451    /// use nalgebra::DMatrix;
452    ///
453    /// let waypoints = DMatrix::from_row_slice(
454    ///     2,
455    ///     5,
456    ///     &[
457    ///         0.0, 0.25, 0.5, 0.75, 1.0,
458    ///         0.0, 0.1, -0.1, 0.2, 0.0,
459    ///     ],
460    /// );
461    /// let path = Path::from_waypoints(&waypoints, SplineConfig::default())?;
462    ///
463    /// let s = [0.0, 0.5, 1.0];
464    /// let out = path.evaluate_q(s.as_slice())?;
465    /// assert_eq!(out.q.shape(), (2, 3));
466    /// assert!(out.dq.is_none());
467    /// # Ok(())
468    /// # }
469    /// ```
470    pub fn from_waypoints(waypoints: &DMatrix<f64>, cfg: SplineConfig) -> Result<Self, PathError> {
471        Self::from_waypoints_view(waypoints.as_view(), cfg)
472    }
473
474    /// Build a spline path from a borrowed waypoint matrix view.
475    ///
476    /// This accepts nalgebra views such as `waypoints.as_view()` and compatible
477    /// strided column-major views. See [`Path::from_waypoints`] for the full
478    /// interpolation semantics, configuration, and error conditions.
479    pub fn from_waypoints_view(
480        waypoints: DMatrixView<'_, f64>,
481        cfg: SplineConfig,
482    ) -> Result<Self, PathError> {
483        if waypoints.nrows() == 0 {
484            return Err(PathError::InvalidDimension {
485                dim: waypoints.nrows(),
486            });
487        }
488        if waypoints.ncols() < 2 {
489            return Err(PathError::NotEnoughWaypoints {
490                n: waypoints.ncols(),
491            });
492        }
493        if cfg.order < 3 {
494            return Err(PathError::InvalidOrder { order: cfg.order });
495        }
496        validate_range(cfg.s_min, cfg.s_max)?;
497
498        let spline = SplinePath::from_waypoints_view(waypoints, &cfg)?;
499
500        Ok(Self {
501            dim: waypoints.nrows(),
502            s_min: spline.s_min,
503            s_max: spline.s_max,
504            out_of_range_mode: spline.out_of_range_mode,
505            repr: PathRepr::Spline(spline),
506        })
507    }
508
509    /// Returns the spatial dimension (number of joints) of the path.
510    #[inline(always)]
511    pub fn dim(&self) -> usize {
512        self.dim
513    }
514
515    /// Returns the valid parameter range `(s_min, s_max)`.
516    #[inline(always)]
517    pub fn s_range(&self) -> (f64, f64) {
518        (self.s_min, self.s_max)
519    }
520
521    /// Evaluate position `q` only at the query points (cheapest; no derivatives).
522    ///
523    /// # Arguments
524    /// - `s` : one-dimensional parameter samples (length `N`)
525    ///
526    /// # Returns
527    /// [`PathDerivatives`](crate::path::PathDerivatives) with `dq / ddq / dddq` all `None`;
528    /// `q` has shape `(dim, N)`.
529    ///
530    /// # Errors
531    /// - [`PathError::OutOfRangeS`] : a query value is out of range (only in `Error` mode)
532    pub fn evaluate_q(&self, s: &[f64]) -> Result<PathDerivatives, PathError> {
533        self.evaluate_impl(s, Order::Zero)
534    }
535
536    /// Evaluate position, velocity, and acceleration (`q`, `dq`, `ddq`); jerk is not computed.
537    ///
538    /// # Arguments
539    /// - `s` : one-dimensional parameter samples (length `N`)
540    ///
541    /// # Returns
542    /// [`PathDerivatives`](crate::path::PathDerivatives) with `dddq = None`;
543    /// `q / dq / ddq` each have shape `(dim, N)`.
544    pub fn evaluate_up_to_2nd(&self, s: &[f64]) -> Result<PathDerivatives, PathError> {
545        self.evaluate_impl(s, Order::Two)
546    }
547
548    /// Evaluate position and all three derivative orders (`q`, `dq`, `ddq`, `dddq`).
549    ///
550    /// This is the most expensive evaluation method.  If jerk is not needed,
551    /// prefer [`evaluate_up_to_2nd`].
552    ///
553    /// # Arguments
554    /// - `s` : one-dimensional parameter samples (length `N`)
555    ///
556    /// # Returns
557    /// [`PathDerivatives`](crate::path::PathDerivatives) with all four fields populated; each matrix has shape `(dim, N)`.
558    ///
559    /// # Errors
560    /// - [`PathError::OutOfRangeS`] : a query value is out of range
561    ///
562    /// # Example
563    /// The example below evaluates an analytic path up to jerk using automatic
564    /// differentiation.
565    ///
566    /// ```rust, no_run
567    /// use copp::path::{Path, sin, cos};
568    /// use copp::path::autodiff::Jet3;
569    ///
570    /// let path = Path::from_parametric(
571    ///     |s: Jet3| vec![sin(s), cos(s)],
572    ///     0.0, 1.0,
573    /// ).unwrap();
574    ///
575    /// let s = [0.0, 0.25, 0.5, 0.75, 1.0];
576    /// let out = path.evaluate_up_to_3rd(&s).unwrap();
577    ///
578    /// let dq   = out.dq.as_ref().unwrap();
579    /// let dddq = out.dddq.as_ref().unwrap();
580    /// // dim 0 is sin(s); its first derivative is cos(s)
581    /// assert!((dq[(0, 0)] - 1.0_f64.cos()).abs() < 1e-10);
582    /// // dim 1 is cos(s); its third derivative is sin(s)
583    /// assert!((dddq[(1, 0)] - 0.0_f64.sin()).abs() < 1e-10);
584    /// ```
585    ///
586    /// [`evaluate_up_to_2nd`]: Path::evaluate_up_to_2nd
587    pub fn evaluate_up_to_3rd(&self, s: &[f64]) -> Result<PathDerivatives, PathError> {
588        self.evaluate_impl(s, Order::Three)
589    }
590
591    // ── internal ─────────────────────────────────────────────────────────────
592
593    fn evaluate_impl(&self, s: &[f64], order: Order) -> Result<PathDerivatives, PathError> {
594        let n = s.len();
595        let dim = self.dim;
596
597        // Allocate output buffers; skip higher-order buffers when not needed.
598        let mut q = vec![0.0f64; dim * n];
599        let mut dq = (order != Order::Zero).then(|| vec![0.0f64; dim * n]);
600        let mut ddq = (order != Order::Zero).then(|| vec![0.0f64; dim * n]);
601        let mut dddq = (order == Order::Three).then(|| vec![0.0f64; dim * n]);
602
603        match &self.repr {
604            PathRepr::Parametric(eval_fn) => {
605                eval_parametric(
606                    eval_fn,
607                    self,
608                    dim,
609                    (s, &mut q, &mut dq, &mut ddq, &mut dddq),
610                )?;
611            }
612            PathRepr::Spline(spline) => {
613                eval_spline(spline, self, dim, (s, &mut q, &mut dq, &mut ddq, &mut dddq))?;
614            }
615            PathRepr::Evaluator2nd(evaluator) => {
616                eval_evaluator_2nd(
617                    evaluator.as_ref(),
618                    self,
619                    dim,
620                    (s, &mut q, &mut dq, &mut ddq, &mut dddq),
621                )?;
622            }
623            PathRepr::Evaluator3rd(evaluator) => {
624                eval_evaluator_3rd(
625                    evaluator.as_ref(),
626                    self,
627                    dim,
628                    (s, &mut q, &mut dq, &mut ddq, &mut dddq),
629                )?;
630            }
631        }
632
633        Ok(PathDerivatives {
634            q: DMatrix::from_vec(dim, n, q),
635            dq: dq.map(|v| DMatrix::from_vec(dim, n, v)),
636            ddq: ddq.map(|v| DMatrix::from_vec(dim, n, v)),
637            dddq: dddq.map(|v| DMatrix::from_vec(dim, n, v)),
638        })
639    }
640
641    #[inline(always)]
642    fn validate_s(&self, s: f64, index: usize) -> Result<f64, PathError> {
643        match self.out_of_range_mode {
644            OutOfRangeMode::Error => {
645                if s < self.s_min - EPS_RANGE || s > self.s_max + EPS_RANGE {
646                    return Err(PathError::OutOfRangeS {
647                        s_min: self.s_min,
648                        s_max: self.s_max,
649                        index,
650                        value: s,
651                    });
652                }
653                Ok(s.clamp(self.s_min, self.s_max))
654            }
655            OutOfRangeMode::Clamp => Ok(s.clamp(self.s_min, self.s_max)),
656        }
657    }
658}
659
660// ── free evaluation functions ─────────────────────────────────────────────────
661
662/// The input of evaluation functions.
663type EvalInput<'a> = (
664    &'a [f64],                // s
665    &'a mut [f64],            // q
666    &'a mut Option<Vec<f64>>, // dq
667    &'a mut Option<Vec<f64>>, // ddq
668    &'a mut Option<Vec<f64>>, // dddq
669);
670
671/// Evaluate a parametric path into pre-allocated column-major buffers.
672///
673/// Per-column parallelism via Rayon: validate + AD-evaluate + write in one pass.
674/// No intermediate `Vec<Vec<Jet3>>` allocation; results go directly into output buffers.
675fn eval_parametric(
676    eval_fn: &ParametricFn,
677    path: &Path,
678    dim: usize,
679    input_eval: EvalInput,
680) -> Result<(), PathError> {
681    let (s_values, q, dq, ddq, dddq) = input_eval;
682    let n = s_values.len();
683
684    // Chunk each output buffer by `dim` so column j maps to slice [j*dim .. (j+1)*dim].
685    // When a derivative level is not requested (`None`), we still need an
686    // `IndexedParallelIterator` of the same length for `zip`; a Vec<None> is the
687    // simplest way to satisfy Rayon's type constraints here.
688    let dq_chunks: Vec<Option<&mut [f64]>> = dq.as_deref_mut().map_or_else(
689        || (0..n).map(|_| None).collect(),
690        |v| v.chunks_mut(dim).map(Some).collect(),
691    );
692    let ddq_chunks: Vec<Option<&mut [f64]>> = ddq.as_deref_mut().map_or_else(
693        || (0..n).map(|_| None).collect(),
694        |v| v.chunks_mut(dim).map(Some).collect(),
695    );
696    let dddq_chunks: Vec<Option<&mut [f64]>> = dddq.as_deref_mut().map_or_else(
697        || (0..n).map(|_| None).collect(),
698        |v| v.chunks_mut(dim).map(Some).collect(),
699    );
700
701    s_values
702        .par_iter()
703        .enumerate()
704        .zip(q.par_chunks_mut(dim))
705        .zip(dq_chunks.into_par_iter())
706        .zip(ddq_chunks.into_par_iter())
707        .zip(dddq_chunks.into_par_iter())
708        .map(
709            |(((((j, &s_raw), q_col), mut dq_col), mut ddq_col), mut dddq_col)| {
710                let s_curr = path.validate_s(s_raw, j)?;
711                let vals = eval_fn(Jet3::seed(s_curr));
712                if vals.len() != dim {
713                    return Err(PathError::DimensionMismatch);
714                }
715                for (i, jet) in vals.iter().enumerate() {
716                    q_col[i] = jet.v;
717                    if let Some(ref mut b) = dq_col {
718                        b[i] = jet.d1;
719                    }
720                    if let Some(ref mut b) = ddq_col {
721                        b[i] = jet.d2;
722                    }
723                    if let Some(ref mut b) = dddq_col {
724                        b[i] = jet.d3;
725                    }
726                }
727                Ok(())
728            },
729        )
730        .collect()
731}
732
733/// Evaluate spline representation into pre-allocated column-major buffers.
734///
735/// Dispatches to [`SplinePath::eval_at`](crate::path::spline::SplinePath::eval_at)`::<ORDER>` with the minimum derivative
736/// order that satisfies the request: zero run-time branching per sample:
737///   - [`Order::Zero`](Order::Zero) => `eval_at::<0>` (q only)
738///   - [`Order::Two`](Order::Two) => `eval_at::<2>` (q, dq, ddq)
739///   - [`Order::Three`](Order::Three) => `eval_at::<3>` (q, dq, ddq, dddq)
740fn eval_spline(
741    spline: &SplinePath,
742    path: &Path,
743    dim: usize,
744    input_eval: EvalInput,
745) -> Result<(), PathError> {
746    let (s_values, q, dq, ddq, dddq) = input_eval;
747    match (dq.as_deref_mut(), ddq.as_deref_mut(), dddq.as_deref_mut()) {
748        // ── Order::Zero: q only ───────────────────────────────────────────
749        (None, None, None) => s_values
750            .par_iter()
751            .enumerate()
752            .zip(q.par_chunks_mut(dim))
753            .map(|((j, &s_raw), q_col)| -> Result<(), PathError> {
754                let s_curr = path.validate_s(s_raw, j)?;
755                // eval_at::<0> only writes q_col; the derivative slices are never
756                // accessed, so zero-length arrays satisfy the borrow checker.
757                let (mut no_dq, mut no_ddq, mut no_dddq): ([f64; 0], [f64; 0], [f64; 0]) =
758                    ([], [], []);
759                spline.eval_at::<0>(s_curr, dim, q_col, &mut no_dq, &mut no_ddq, &mut no_dddq);
760                Ok(())
761            })
762            .collect(),
763        // ── Order::Two: q, dq, ddq ────────────────────────────────────────
764        (Some(dq_buf), Some(ddq_buf), None) => {
765            let dq_chunks: Vec<&mut [f64]> = dq_buf.chunks_mut(dim).collect();
766            let ddq_chunks: Vec<&mut [f64]> = ddq_buf.chunks_mut(dim).collect();
767            s_values
768                .par_iter()
769                .enumerate()
770                .zip(q.par_chunks_mut(dim))
771                .zip(dq_chunks.into_par_iter())
772                .zip(ddq_chunks.into_par_iter())
773                .map(
774                    |((((j, &s_raw), q_col), dq_col), ddq_col)| -> Result<(), PathError> {
775                        let s_curr = path.validate_s(s_raw, j)?;
776                        let mut s4 = [];
777                        spline.eval_at::<2>(s_curr, dim, q_col, dq_col, ddq_col, &mut s4);
778                        Ok(())
779                    },
780                )
781                .collect()
782        }
783        // ── Order::Three: q, dq, ddq, dddq ───────────────────────────────
784        (Some(dq_buf), Some(ddq_buf), Some(dddq_buf)) => {
785            let dq_chunks: Vec<&mut [f64]> = dq_buf.chunks_mut(dim).collect();
786            let ddq_chunks: Vec<&mut [f64]> = ddq_buf.chunks_mut(dim).collect();
787            let dddq_chunks: Vec<&mut [f64]> = dddq_buf.chunks_mut(dim).collect();
788            s_values
789                .par_iter()
790                .enumerate()
791                .zip(q.par_chunks_mut(dim))
792                .zip(dq_chunks.into_par_iter())
793                .zip(ddq_chunks.into_par_iter())
794                .zip(dddq_chunks.into_par_iter())
795                .map(
796                    |(((((j, &s_raw), q_col), dq_col), ddq_col), dddq_col)| -> Result<(), PathError> {
797                        let s_curr = path.validate_s(s_raw, j)?;
798                        spline.eval_at::<3>(s_curr, dim, q_col, dq_col, ddq_col, dddq_col);
799                        Ok(())
800                    },
801                )
802                .collect()
803        }
804        // Unreachable: evaluate_impl only produces the three patterns above.
805        _ => unreachable!("unexpected dq/ddq/dddq combination"),
806    }
807}
808
809// ── helpers ───────────────────────────────────────────────────────────────────
810
811/// Evaluate a user-provided second-order explicit-derivative evaluator.
812///
813/// The evaluator receives already validated/clamped path parameters. It is
814/// called once for the whole batch so custom evaluators can use their own
815/// vectorized implementation.
816fn eval_evaluator_2nd(
817    evaluator: &dyn PathEvaluator2nd,
818    path: &Path,
819    dim: usize,
820    input_eval: EvalInput,
821) -> Result<(), PathError> {
822    let (s_values, q, dq, ddq, dddq) = input_eval;
823    let s_valid = s_values
824        .iter()
825        .enumerate()
826        .map(|(j, &s)| path.validate_s(s, j))
827        .collect::<Result<Vec<_>, _>>()?;
828
829    if evaluator.dim() != dim {
830        return Err(PathError::DimensionMismatch);
831    }
832
833    match (dq.as_deref_mut(), ddq.as_deref_mut(), dddq.as_deref_mut()) {
834        (None, None, None) => evaluator.evaluate_q(&s_valid, q),
835        (Some(dq_buf), Some(ddq_buf), None) => {
836            evaluator.evaluate_up_to_2nd(&s_valid, q, dq_buf, ddq_buf)
837        }
838        (Some(_), Some(_), Some(_)) => Err(PathError::UnsupportedDerivativeOrder {
839            requested: 3,
840            available: 2,
841        }),
842        // Unreachable: evaluate_impl only produces the three patterns above.
843        _ => unreachable!("unexpected dq/ddq/dddq combination"),
844    }
845}
846
847/// Evaluate a user-provided third-order explicit-derivative evaluator.
848///
849/// The evaluator receives already validated/clamped path parameters. It is
850/// called once for the whole batch so custom evaluators can use their own
851/// vectorized implementation.
852fn eval_evaluator_3rd(
853    evaluator: &dyn PathEvaluator3rd,
854    path: &Path,
855    dim: usize,
856    input_eval: EvalInput,
857) -> Result<(), PathError> {
858    let (s_values, q, dq, ddq, dddq) = input_eval;
859    let s_valid = s_values
860        .iter()
861        .enumerate()
862        .map(|(j, &s)| path.validate_s(s, j))
863        .collect::<Result<Vec<_>, _>>()?;
864
865    if evaluator.dim() != dim {
866        return Err(PathError::DimensionMismatch);
867    }
868
869    match (dq.as_deref_mut(), ddq.as_deref_mut(), dddq.as_deref_mut()) {
870        (None, None, None) => evaluator.evaluate_q(&s_valid, q),
871        (Some(dq_buf), Some(ddq_buf), None) => {
872            evaluator.evaluate_up_to_2nd(&s_valid, q, dq_buf, ddq_buf)
873        }
874        (Some(dq_buf), Some(ddq_buf), Some(dddq_buf)) => {
875            evaluator.evaluate_up_to_3rd(&s_valid, q, dq_buf, ddq_buf, dddq_buf)
876        }
877        // Unreachable: evaluate_impl only produces the three patterns above.
878        _ => unreachable!("unexpected dq/ddq/dddq combination"),
879    }
880}
881
882fn validate_range(s_min: f64, s_max: f64) -> Result<(), PathError> {
883    if !s_min.is_finite() || !s_max.is_finite() || s_max <= s_min {
884        return Err(PathError::InvalidRange { s_min, s_max });
885    }
886    Ok(())
887}
888
889#[cfg(test)]
890mod tests {
891    use super::PathDerivatives;
892    use crate::path::{
893        Jet3, Path as PathModel, PathError, PathEvaluator2nd, PathEvaluator3rd, SplineConfig, cos,
894        exp, sin,
895    };
896    use nalgebra::{Const, DMatrix, DMatrixView, Dyn};
897    use plotters::prelude::*;
898    use rand::RngExt;
899    use std::error::Error;
900    use std::fs::create_dir_all;
901    use std::hint::black_box;
902    use std::path::Path as StdPath;
903    use std::time::Instant;
904
905    const DIM: usize = 6;
906
907    fn make_s(n: usize) -> DMatrix<f64> {
908        DMatrix::<f64>::from_fn(1, n, |_, j| j as f64 / (n - 1) as f64)
909    }
910
911    fn make_parametric_path() -> Result<PathModel, PathError> {
912        PathModel::from_parametric(
913            |s: Jet3| {
914                vec![
915                    sin(s),
916                    cos(s),
917                    exp(0.3 * s) - 1.0,
918                    s + 0.1 * s * s - 0.01 * s * s * s * s,
919                    sin(2.0 * s) + 0.15 * cos(3.0 * s),
920                    sin(s) * cos(s),
921                ]
922            },
923            0.0,
924            1.0,
925        )
926    }
927
928    struct PolynomialEvaluator;
929    struct QuadraticEvaluator2nd;
930
931    impl PathEvaluator2nd for PolynomialEvaluator {
932        fn dim(&self) -> usize {
933            2
934        }
935
936        fn evaluate_up_to_2nd(
937            &self,
938            s: &[f64],
939            q: &mut [f64],
940            dq: &mut [f64],
941            ddq: &mut [f64],
942        ) -> Result<(), PathError> {
943            if q.len() != 2 * s.len() || dq.len() != q.len() || ddq.len() != q.len() {
944                return Err(PathError::DimensionMismatch);
945            }
946
947            for (j, &x) in s.iter().enumerate() {
948                let col = 2 * j;
949                q[col] = x * x * x;
950                dq[col] = 3.0 * x * x;
951                ddq[col] = 6.0 * x;
952
953                q[col + 1] = x * x + 1.0;
954                dq[col + 1] = 2.0 * x;
955                ddq[col + 1] = 2.0;
956            }
957            Ok(())
958        }
959    }
960
961    impl PathEvaluator3rd for PolynomialEvaluator {
962        fn evaluate_up_to_3rd(
963            &self,
964            s: &[f64],
965            q: &mut [f64],
966            dq: &mut [f64],
967            ddq: &mut [f64],
968            dddq: &mut [f64],
969        ) -> Result<(), PathError> {
970            self.evaluate_up_to_2nd(s, q, dq, ddq)?;
971            if dddq.len() != 2 * s.len() {
972                return Err(PathError::DimensionMismatch);
973            }
974
975            for j in 0..s.len() {
976                let col = 2 * j;
977                dddq[col] = 6.0;
978                dddq[col + 1] = 0.0;
979            }
980            Ok(())
981        }
982    }
983
984    impl PathEvaluator2nd for QuadraticEvaluator2nd {
985        fn dim(&self) -> usize {
986            1
987        }
988
989        fn evaluate_up_to_2nd(
990            &self,
991            s: &[f64],
992            q: &mut [f64],
993            dq: &mut [f64],
994            ddq: &mut [f64],
995        ) -> Result<(), PathError> {
996            if q.len() != s.len() || dq.len() != q.len() || ddq.len() != q.len() {
997                return Err(PathError::DimensionMismatch);
998            }
999
1000            for (j, &x) in s.iter().enumerate() {
1001                q[j] = x * x + 1.0;
1002                dq[j] = 2.0 * x;
1003                ddq[j] = 2.0;
1004            }
1005            Ok(())
1006        }
1007    }
1008
1009    fn make_waypoints(n_pts: usize) -> DMatrix<f64> {
1010        let mut rng = rand::rng();
1011        // Random-walk waypoints: each row is one DOF, each column is a waypoint.
1012        let mut waypoints = DMatrix::<f64>::zeros(DIM, n_pts);
1013        for mut row in waypoints.row_iter_mut() {
1014            row[0] = rng.random_range(-1.0..1.0);
1015            for j in 1..n_pts {
1016                let step = rng.random_range(-0.35..0.35);
1017                row[j] = row[j - 1] + step;
1018            }
1019        }
1020        waypoints
1021    }
1022
1023    #[test]
1024    fn test_waypoints_view_interpolates_padded_column_major() -> Result<(), PathError> {
1025        const DIM_LOCAL: usize = 2;
1026        const N_PTS: usize = 4;
1027        const LEADING_DIM: usize = 3;
1028        let data = [
1029            0.0, 1.0, -99.0, //
1030            0.5, 1.5, -99.0, //
1031            1.0, 2.0, -99.0, //
1032            1.5, 2.5, -99.0,
1033        ];
1034        let waypoints = DMatrixView::from_slice_with_strides_generic(
1035            &data,
1036            Dyn(DIM_LOCAL),
1037            Dyn(N_PTS),
1038            Const::<1>,
1039            Dyn(LEADING_DIM),
1040        );
1041
1042        let path = PathModel::from_waypoints_view(waypoints, SplineConfig::default())?;
1043        let s = [0.0, 1.0 / 3.0, 2.0 / 3.0, 1.0];
1044        let out = path.evaluate_q(&s)?;
1045
1046        for j in 0..N_PTS {
1047            assert!((out.q[(0, j)] - data[j * LEADING_DIM]).abs() < 1e-10);
1048            assert!((out.q[(1, j)] - data[j * LEADING_DIM + 1]).abs() < 1e-10);
1049        }
1050
1051        Ok(())
1052    }
1053
1054    #[test]
1055    fn test_evaluator_path_explicit_derivatives() -> Result<(), PathError> {
1056        let path = PathModel::from_evaluator_3rd(PolynomialEvaluator, -1.0, 1.0)?;
1057        let s = [-1.0, 0.0, 0.5];
1058
1059        let out = path.evaluate_up_to_3rd(&s)?;
1060        let dq = out.dq.as_ref().unwrap();
1061        let ddq = out.ddq.as_ref().unwrap();
1062        let dddq = out.dddq.as_ref().unwrap();
1063
1064        for (j, &x) in s.iter().enumerate() {
1065            assert!((out.q[(0, j)] - x.powi(3)).abs() < 1e-12);
1066            assert!((dq[(0, j)] - 3.0 * x * x).abs() < 1e-12);
1067            assert!((ddq[(0, j)] - 6.0 * x).abs() < 1e-12);
1068            assert!((dddq[(0, j)] - 6.0).abs() < 1e-12);
1069
1070            assert!((out.q[(1, j)] - (x * x + 1.0)).abs() < 1e-12);
1071            assert!((dq[(1, j)] - 2.0 * x).abs() < 1e-12);
1072            assert!((ddq[(1, j)] - 2.0).abs() < 1e-12);
1073            assert!(dddq[(1, j)].abs() < 1e-12);
1074        }
1075
1076        let q_only = path.evaluate_q(&s)?;
1077        assert!(q_only.dq.is_none());
1078        assert!(q_only.ddq.is_none());
1079        assert!(q_only.dddq.is_none());
1080        assert!((q_only.q[(0, 2)] - 0.125).abs() < 1e-12);
1081
1082        Ok(())
1083    }
1084
1085    #[test]
1086    fn test_evaluator_path_2nd_does_not_require_3rd() -> Result<(), PathError> {
1087        let path = PathModel::from_evaluator_2nd(QuadraticEvaluator2nd, -1.0, 1.0)?;
1088        let s = [-1.0, 0.0, 0.5];
1089
1090        let out = path.evaluate_up_to_2nd(&s)?;
1091        let dq = out.dq.as_ref().unwrap();
1092        let ddq = out.ddq.as_ref().unwrap();
1093
1094        assert!(out.dddq.is_none());
1095        assert!((out.q[(0, 2)] - 1.25).abs() < 1e-12);
1096        assert!((dq[(0, 2)] - 1.0).abs() < 1e-12);
1097        assert!((ddq[(0, 2)] - 2.0).abs() < 1e-12);
1098
1099        let err = path.evaluate_up_to_3rd(&s).unwrap_err();
1100        match err {
1101            PathError::UnsupportedDerivativeOrder {
1102                requested: 3,
1103                available: 2,
1104            } => {}
1105            other => panic!("unexpected error: {other}"),
1106        }
1107
1108        Ok(())
1109    }
1110
1111    #[test]
1112    fn test_parametric_autodiff_dim6() -> Result<(), PathError> {
1113        let path = make_parametric_path()?;
1114
1115        let n = 300;
1116        let s = make_s(n);
1117        let out = path.evaluate_up_to_3rd(s.as_slice())?;
1118        let dq = out.dq.as_ref().unwrap();
1119        let ddq = out.ddq.as_ref().unwrap();
1120        let dddq = out.dddq.as_ref().unwrap();
1121
1122        // Build expected values for all query points and check all 4 derivative orders.
1123        s.as_slice().iter().enumerate().for_each(|(j, &x)| {
1124            let e03x = (0.3 * x).exp();
1125            let expected_q = [
1126                x.sin(),
1127                x.cos(),
1128                e03x - 1.0,
1129                x + 0.1 * x * x - 0.01 * x * x * x * x,
1130                (2.0 * x).sin() + 0.15 * (3.0 * x).cos(),
1131                x.sin() * x.cos(),
1132            ];
1133            let expected_dq = [
1134                x.cos(),
1135                -x.sin(),
1136                0.3 * e03x,
1137                1.0 + 0.2 * x - 0.04 * x * x * x,
1138                2.0 * (2.0 * x).cos() - 0.45 * (3.0 * x).sin(),
1139                (2.0 * x).cos(),
1140            ];
1141            let expected_ddq = [
1142                -x.sin(),
1143                -x.cos(),
1144                0.09 * e03x,
1145                0.2 - 0.12 * x * x,
1146                -4.0 * (2.0 * x).sin() - 1.35 * (3.0 * x).cos(),
1147                -2.0 * (2.0 * x).sin(),
1148            ];
1149            let expected_dddq = [
1150                -x.cos(),
1151                x.sin(),
1152                0.027 * e03x,
1153                -0.24 * x,
1154                -8.0 * (2.0 * x).cos() + 4.05 * (3.0 * x).sin(),
1155                -4.0 * (2.0 * x).cos(),
1156            ];
1157
1158            for i in 0..DIM {
1159                assert!(
1160                    (out.q[(i, j)] - expected_q[i]).abs() < 1e-10,
1161                    "q    dim={i} idx={j}"
1162                );
1163                assert!(
1164                    (dq[(i, j)] - expected_dq[i]).abs() < 1e-10,
1165                    "dq   dim={i} idx={j}"
1166                );
1167                assert!(
1168                    (ddq[(i, j)] - expected_ddq[i]).abs() < 1e-10,
1169                    "ddq  dim={i} idx={j}"
1170                );
1171                assert!(
1172                    (dddq[(i, j)] - expected_dddq[i]).abs() < 1e-10,
1173                    "dddq dim={i} idx={j}"
1174                );
1175            }
1176        });
1177
1178        Ok(())
1179    }
1180
1181    #[test]
1182    fn test_evaluate_q_only() -> Result<(), PathError> {
1183        let path = make_parametric_path()?;
1184        let n = 100;
1185        let s = make_s(n);
1186        let out = path.evaluate_q(s.as_slice())?;
1187
1188        assert!(out.dq.is_none());
1189        assert!(out.ddq.is_none());
1190        assert!(out.dddq.is_none());
1191
1192        for j in 0..n {
1193            let x = s[(0, j)];
1194            assert!((out.q[(0, j)] - x.sin()).abs() < 1e-10);
1195            assert!((out.q[(1, j)] - x.cos()).abs() < 1e-10);
1196        }
1197
1198        Ok(())
1199    }
1200
1201    #[test]
1202    fn test_evaluate_up_to_2nd() -> Result<(), PathError> {
1203        let path = make_parametric_path()?;
1204        let n = 100;
1205        let s = make_s(n);
1206        let out = path.evaluate_up_to_2nd(s.as_slice())?;
1207        let dq = out.dq.as_ref().unwrap();
1208        let ddq = out.ddq.as_ref().unwrap();
1209
1210        assert!(out.dddq.is_none());
1211
1212        for j in 0..n {
1213            let x = s[(0, j)];
1214            assert!((out.q[(0, j)] - x.sin()).abs() < 1e-10);
1215            assert!((dq[(0, j)] - x.cos()).abs() < 1e-10);
1216            assert!((ddq[(0, j)] - (-x.sin())).abs() < 1e-10);
1217        }
1218
1219        Ok(())
1220    }
1221
1222    #[test]
1223    fn test_quintic_spline_interpolates_waypoints_dim6() -> Result<(), PathError> {
1224        let n_pts = 25;
1225        let waypoints = make_waypoints(n_pts);
1226
1227        let cfg = SplineConfig::default();
1228        let path = PathModel::from_waypoints(&waypoints, cfg)?;
1229
1230        let s = DMatrix::<f64>::from_fn(1, n_pts, |_, j| j as f64 / (n_pts - 1) as f64);
1231        let out = path.evaluate_up_to_3rd(s.as_slice())?;
1232        let dq = out.dq.as_ref().unwrap();
1233        let ddq = out.ddq.as_ref().unwrap();
1234        let dddq = out.dddq.as_ref().unwrap();
1235
1236        // The spline must interpolate every waypoint exactly (up to floating-point rounding)
1237        // and all derivatives must be finite (no blowup).
1238        for (i, j) in (0..DIM).flat_map(|i| (0..n_pts).map(move |j| (i, j))) {
1239            assert!((out.q[(i, j)] - waypoints[(i, j)]).abs() < 1e-8);
1240            assert!(dq[(i, j)].is_finite());
1241            assert!(ddq[(i, j)].is_finite());
1242            assert!(dddq[(i, j)].is_finite());
1243        }
1244
1245        Ok(())
1246    }
1247
1248    #[test]
1249    fn test_s_out_of_range_error_dim6() -> Result<(), PathError> {
1250        let waypoints = make_waypoints(12);
1251        let path = PathModel::from_waypoints(&waypoints, SplineConfig::default())?;
1252        let s = DMatrix::<f64>::from_row_slice(1, 3, &[-0.1, 0.5, 1.1]);
1253        let err = path.evaluate_up_to_3rd(s.as_slice()).unwrap_err();
1254        match err {
1255            PathError::OutOfRangeS { .. } => {}
1256            _ => panic!("expected OutOfRangeS"),
1257        }
1258        Ok(())
1259    }
1260
1261    #[test]
1262    fn test_benchmark_parametric_and_spline_dim6() -> Result<(), PathError> {
1263        let n_eval = 3000;
1264        let n_repeat = 8;
1265        let s = make_s(n_eval);
1266
1267        let start = Instant::now();
1268        let param_path = make_parametric_path()?;
1269        let tc_build_param = start.elapsed().as_secs_f64() * 1e3;
1270
1271        let start = Instant::now();
1272        for _ in 0..n_repeat {
1273            let out = param_path.evaluate_up_to_3rd(s.as_slice())?;
1274            black_box(out.q[(0, 0)]);
1275        }
1276        let tc_eval_param = start.elapsed().as_secs_f64() * 1e3 / n_repeat as f64;
1277        crate::verbosity_log!(
1278            crate::diag::Verbosity::Summary,
1279            "[bench][parametric][dim=6] build={tc_build_param:.3} ms eval={tc_eval_param:.3} ms (N={n_eval})"
1280        );
1281
1282        let n_waypoints_list = [16usize, 32, 64, 128, 192, 256, 512, 1024];
1283        for &n_pts in &n_waypoints_list {
1284            let waypoints = make_waypoints(n_pts);
1285            let start = Instant::now();
1286            let spline_path = PathModel::from_waypoints(&waypoints, SplineConfig::default())?;
1287            let tc_build = start.elapsed().as_secs_f64() * 1e3;
1288
1289            let start = Instant::now();
1290            for _ in 0..n_repeat {
1291                let out = spline_path.evaluate_up_to_3rd(s.as_slice())?;
1292                black_box(out.q[(0, 0)]);
1293            }
1294            let tc_eval = start.elapsed().as_secs_f64() * 1e3 / n_repeat as f64;
1295
1296            crate::verbosity_log!(
1297                crate::diag::Verbosity::Summary,
1298                "[bench][spline][dim=6][n_pts={n_pts}] build={tc_build:.3} ms eval={tc_eval:.3} ms"
1299            );
1300        }
1301
1302        Ok(())
1303    }
1304
1305    #[test]
1306    fn test_plot_parametric_and_spline_derivatives() -> Result<(), Box<dyn Error>> {
1307        let dir = "data/path_plots";
1308        create_dir_all(dir)?;
1309
1310        let n = 600;
1311        let s = make_s(n);
1312        let s_vec: Vec<f64> = (0..n).map(|j| s[(0, j)]).collect();
1313
1314        let param_path = make_parametric_path()?;
1315        let param_out = param_path.evaluate_up_to_3rd(s.as_slice())?;
1316        plot_grid_4x6(
1317            &format!("{dir}/parametric_dim6_grid.png"),
1318            "parametric dim=6",
1319            &s_vec,
1320            &param_out,
1321            None,
1322        )?;
1323
1324        let n_pts = 10;
1325        let waypoints = make_waypoints(n_pts);
1326        let spline_path = PathModel::from_waypoints(&waypoints, SplineConfig::default())?;
1327        let spline_out = spline_path.evaluate_up_to_3rd(s.as_slice())?;
1328        let wp_s: Vec<f64> = (0..n_pts).map(|j| j as f64 / (n_pts - 1) as f64).collect();
1329        plot_grid_4x6(
1330            &format!("{dir}/spline_order5_dim6_grid.png"),
1331            "spline order=5 dim=6",
1332            &s_vec,
1333            &spline_out,
1334            Some((&wp_s, &waypoints)),
1335        )?;
1336
1337        Ok(())
1338    }
1339
1340    fn plot_grid_4x6(
1341        file: &str,
1342        title: &str,
1343        s: &[f64],
1344        data: &PathDerivatives,
1345        waypoints: Option<(&[f64], &DMatrix<f64>)>,
1346    ) -> Result<(), Box<dyn Error>> {
1347        if let Some(parent) = StdPath::new(file).parent() {
1348            create_dir_all(parent)?;
1349        }
1350
1351        let root = BitMapBackend::new(file, (2400, 1400)).into_drawing_area();
1352        root.fill(&WHITE)?;
1353
1354        let empty = DMatrix::<f64>::zeros(0, 0);
1355        let dq = data.dq.as_ref().unwrap_or(&empty);
1356        let ddq = data.ddq.as_ref().unwrap_or(&empty);
1357        let dddq = data.dddq.as_ref().unwrap_or(&empty);
1358
1359        let areas = root.split_evenly((4, DIM));
1360        let mats = [&data.q, dq, ddq, dddq];
1361        let row_names = ["q", "dq", "ddq", "dddq"];
1362
1363        for row in 0..4 {
1364            for col in 0..DIM {
1365                let area = &areas[row * DIM + col];
1366                let series = mat_row(mats[row], col);
1367                let (mut y_min, mut y_max) = min_max_slice(&series);
1368                if (y_max - y_min).abs() < 1e-12 {
1369                    y_min -= 1.0;
1370                    y_max += 1.0;
1371                } else {
1372                    let pad = 0.08 * (y_max - y_min);
1373                    y_min -= pad;
1374                    y_max += pad;
1375                }
1376
1377                let mut chart = ChartBuilder::on(area)
1378                    .margin(8)
1379                    .caption(
1380                        format!("{} j{}", row_names[row], col + 1),
1381                        ("sans-serif", 16),
1382                    )
1383                    .x_label_area_size(24)
1384                    .y_label_area_size(38)
1385                    .build_cartesian_2d(s[0]..s[s.len() - 1], y_min..y_max)?;
1386
1387                chart
1388                    .configure_mesh()
1389                    .x_desc(if row == 3 { "s" } else { "" })
1390                    .y_desc("")
1391                    .draw()?;
1392
1393                chart.draw_series(LineSeries::new(
1394                    (0..s.len()).map(|j| (s[j], series[j])),
1395                    &BLUE,
1396                ))?;
1397
1398                if row == 0
1399                    && let Some((s_wp, q_wp)) = waypoints
1400                {
1401                    chart.draw_series(
1402                        s_wp.iter()
1403                            .zip(q_wp.row(col).iter())
1404                            .map(|(&xs, &ys)| Circle::new((xs, ys), 2, RED.filled())),
1405                    )?;
1406                }
1407            }
1408        }
1409
1410        root.titled(title, ("sans-serif", 28))?;
1411        root.present()?;
1412        Ok(())
1413    }
1414
1415    fn mat_row(mat: &DMatrix<f64>, row: usize) -> Vec<f64> {
1416        mat.row(row).iter().copied().collect()
1417    }
1418
1419    fn min_max_slice(data: &[f64]) -> (f64, f64) {
1420        data.iter()
1421            .copied()
1422            .fold((f64::INFINITY, f64::NEG_INFINITY), |(mn, mx), v| {
1423                (mn.min(v), mx.max(v))
1424            })
1425    }
1426}