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