Skip to main content

copp\path/
mod.rs

1//! Path abstraction for trajectory planning.
2//!
3//! The main entry point is [`Path`].  It supports two construction modes and a
4//! uniform evaluation API — pick the one that fits your use case:
5//!
6//! | You have … | Use |
7//! |---|---|
8//! | An analytic formula `q(s)` | [`Path::from_parametric`] |
9//! | A set of waypoint positions | [`Path::from_waypoints`] |
10//!
11//! Once built, call one of the evaluation methods with a one-dimensional parameter slice:
12//!
13//! | Method | Output |
14//! |---|---|
15//! | [`Path::evaluate_q`]         | position `q` only |
16//! | [`Path::evaluate_up_to_2nd`] | `q`, `dq`, `ddq` |
17//! | [`Path::evaluate_up_to_3rd`] | `q`, `dq`, `ddq`, `dddq` |
18//!
19//! # Examples
20//!
21//! ## Analytic path — automatic differentiation
22//!
23//! ```rust,no_run
24//! use copp::path::{Path, sin, cos};
25//! use copp::path::autodiff::Jet3;
26//!
27//! // Build a 2-DOF path: q0 = sin(s), q1 = cos(s), s ∈ [0, 1]
28//! let path = Path::from_parametric(
29//!     |s: Jet3| vec![sin(s), cos(s)],
30//!     0.0, 1.0,
31//! ).unwrap();
32//!
33//! // Evaluate at 5 uniformly-spaced parameter values
34//! let s = [0.0, 0.25, 0.5, 0.75, 1.0];
35//! let out = path.evaluate_up_to_3rd(&s).unwrap();
36//! // out.q    – shape (2, 5)
37//! // out.dq   – Some, shape (2, 5)   first derivative w.r.t. s
38//! // out.dddq – Some, shape (2, 5)   third derivative w.r.t. s
39//! ```
40//!
41//! ## Spline path — waypoint interpolation
42//!
43//! ```rust,no_run
44//! use copp::path::{Path, SplineConfig};
45//! use nalgebra::DMatrix;
46//!
47//! // 2-DOF path with 5 waypoints; the spline passes exactly through each one.
48//! // waypoints shape: (dim=2, n_points=5)
49//! let waypoints = DMatrix::from_row_slice(2, 5, &[
50//!     0.0, 0.25, 0.5, 0.75, 1.0,   // dim 0
51//!     0.0, 0.1, -0.1, 0.2,  0.0,   // dim 1
52//! ]);
53//! let path = Path::from_waypoints(&waypoints, SplineConfig::default()).unwrap();
54//!
55//! let s = [0.0, 0.5, 1.0];
56//! let out = path.evaluate_q(&s).unwrap();
57//! // out.q shape (2, 3);  out.dq / ddq / dddq are all None
58//! ```
59
60pub mod autodiff;
61mod path_core;
62pub mod spline;
63
64pub use autodiff::{Jet3, cos, exp, ln, powi, sin, sqrt};
65pub use path_core::{ParametricFn, Path, PathDerivatives};
66pub use spline::{Parametrization, SplineConfig};
67
68#[cfg(test)]
69use crate::diag::PathError;
70
71#[derive(Clone, Copy, Debug)]
72pub enum OutOfRangeMode {
73    /// Return an error when s is outside `[s_min, s_max]`.
74    Error,
75    /// Silently clamp s to `[s_min, s_max]`.
76    Clamp,
77}
78
79/// Build a Lissajous analytic path and evaluate derivatives up to third order.
80///
81/// This helper is intended for cross-module unit tests to avoid repeating the
82/// same hand-written `q/dq/ddq/dddq` generation logic.
83#[cfg(test)]
84pub(crate) fn lissajous_path_for_test(
85    dim: usize,
86    s_len: usize,
87    rng: &mut impl rand::RngExt,
88) -> Result<(nalgebra::DMatrix<f64>, PathDerivatives, Vec<f64>, Vec<f64>), PathError> {
89    let omega = (0..dim)
90        .map(|_| rng.random_range(0.1..(2.0 * std::f64::consts::PI)))
91        .collect::<Vec<f64>>();
92    let phi = (0..dim)
93        .map(|_| rng.random_range(0.0..(2.0 * std::f64::consts::PI)))
94        .collect::<Vec<f64>>();
95
96    let (s, derivs) = lissajous_path_fixed_for_test(dim, s_len, omega.clone(), phi.clone())?;
97
98    Ok((s, derivs, omega, phi))
99}
100
101/// Build a Lissajous analytic path and evaluate derivatives up to third order.
102///
103/// This helper is intended for cross-module unit tests to avoid repeating the
104/// same hand-written `q/dq/ddq/dddq` generation logic.
105#[cfg(test)]
106pub(crate) fn lissajous_path_fixed_for_test(
107    dim: usize,
108    s_len: usize,
109    omega: Vec<f64>,
110    phi: Vec<f64>,
111) -> Result<(nalgebra::DMatrix<f64>, PathDerivatives), PathError> {
112    let s = nalgebra::DMatrix::<f64>::from_fn(1, s_len, |_, j| j as f64 / (s_len - 1) as f64);
113
114    let path = Path::from_parametric(
115        move |s: Jet3| {
116            (0..dim)
117                .map(|i| sin(omega[i] * s + phi[i]))
118                .collect::<Vec<Jet3>>()
119        },
120        0.0,
121        1.0,
122    )?;
123
124    let derivs = path.evaluate_up_to_3rd(s.as_slice())?;
125    Ok((s, derivs))
126}
127
128/// Add symmetric axial limits (`+limit` / `-limit`) to all currently stored stations.
129///
130/// The helper applies velocity, acceleration and jerk constraints in one call.
131#[cfg(test)]
132pub(crate) fn add_symmetric_axial_limits_for_test<M: crate::robot::robot_core::RobotBasic>(
133    robot: &mut crate::robot::robot_core::Robot<M>,
134    vel_limit: f64,
135    acc_limit: f64,
136    jerk_limit: Option<f64>,
137) -> Result<(), crate::diag::ConstraintError> {
138    let dim = robot.dim();
139    let n = robot.constraints.len();
140    if n == 0 {
141        return Ok(());
142    }
143
144    let vel_max = vec![vel_limit; dim];
145    let vel_min = vec![-vel_limit; dim];
146    robot.with_axial_velocity((vel_max.as_slice(), n), (vel_min.as_slice(), n), 0)?;
147
148    let acc_max = vec![acc_limit; dim];
149    let acc_min = vec![-acc_limit; dim];
150    robot.with_axial_acceleration((acc_max.as_slice(), n), (acc_min.as_slice(), n), 0)?;
151
152    if let Some(jerk_limit) = jerk_limit {
153        let jerk_max = vec![jerk_limit; dim];
154        let jerk_min = vec![-jerk_limit; dim];
155        robot.with_axial_jerk((jerk_max.as_slice(), n), (jerk_min.as_slice(), n), 0)?;
156    }
157
158    Ok(())
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use crate::copp::copp2::stable::basic::{Topp2ProblemBuilder, s_to_t_topp2};
165    use crate::copp::copp2::stable::reach_set2::ReachSet2OptionsBuilder;
166    use crate::copp::copp2::stable::topp2_ra::topp2_ra;
167    use crate::copp::copp3::stable::basic::{Topp3ProblemBuilder, s_to_t_topp3, t_to_s_topp3};
168    use crate::copp::copp3::stable::topp3_lp::topp3_lp;
169    use crate::copp::{ClarabelOptionsBuilder, InterpolationMode};
170    use crate::diag::ConstraintError;
171    use crate::diag::Verbosity;
172    use crate::robot::robot_core::Robot;
173    use nalgebra::DMatrix;
174    use plotters::prelude::*;
175    use rand::RngExt;
176    use std::error::Error;
177    use std::fs::create_dir_all;
178    use std::path::Path;
179    use std::time::Instant;
180
181    const DIM: usize = 6;
182    const N: usize = 1000;
183
184    #[test]
185    #[ignore = "plotting"]
186    fn test_topp3_with_spline_path() -> Result<(), Box<dyn Error>> {
187        crate::verbosity_log!(
188            crate::diag::Verbosity::Summary,
189            "\n=== TOPP3 with Spline Path (6-DOF) ==="
190        );
191
192        let n_waypoints = 10;
193        let start = Instant::now();
194        let waypoints = make_random_waypoints(DIM, n_waypoints);
195        let sample_ms = start.elapsed().as_secs_f64() * 1e3;
196
197        let start = Instant::now();
198        let path = super::Path::from_waypoints(&waypoints, SplineConfig::default())?;
199        let build_ms = start.elapsed().as_secs_f64() * 1e3;
200
201        let s = make_s_vector(N);
202        let start = Instant::now();
203        let derivs = path.evaluate_up_to_3rd(s.as_slice())?;
204        let eval_ms = start.elapsed().as_secs_f64() * 1e3;
205
206        crate::verbosity_log!(
207            crate::diag::Verbosity::Summary,
208            "[Waypoint Sample] {sample_ms:.3} ms, [Spline Build] {build_ms:.3} ms, [Spline Eval] {eval_ms:.3} ms"
209        );
210
211        let mut robot = setup_robot_with_constraints(&s, &derivs)?;
212        let s_slice: Vec<f64> = (0..N).map(|j| s[(0, j)]).collect();
213        let (a, b, num_stat, timings) = solve_topp_pipeline(&mut robot, &s_slice)?;
214
215        crate::verbosity_log!(
216            crate::diag::Verbosity::Summary,
217            "[TOPP2-RA] {:.3} ms, t_motion = {:.3} s",
218            timings.topp2_ms,
219            timings.t_motion_2
220        );
221        crate::verbosity_log!(
222            crate::diag::Verbosity::Summary,
223            "[TOPP3-LP] {:.3} ms, t_motion = {:.3} s",
224            timings.topp3_ms,
225            timings.t_motion_3
226        );
227
228        let (t, q_t, dq_t, ddq_t, dddq_t, interp_ms) =
229            interpolate_to_time_domain(&path, &s_slice, &a, &b, num_stat, 1e-3)?;
230        crate::verbosity_log!(
231            crate::diag::Verbosity::Summary,
232            "[Interpolation] {:.3} ms, {} samples",
233            interp_ms,
234            t.len()
235        );
236
237        plot_topp3_grid(
238            "data/path_topp_plots/spline_path_topp3.png",
239            "TOPP3 Result: Spline Path (6-DOF, 50 waypoints)",
240            &t,
241            &q_t,
242            &dq_t,
243            &ddq_t,
244            &dddq_t,
245        )?;
246
247        Ok(())
248    }
249
250    fn make_s_vector(n: usize) -> DMatrix<f64> {
251        DMatrix::<f64>::from_fn(1, n, |_, j| j as f64 / (n - 1) as f64)
252    }
253
254    fn make_parametric_path_6dof() -> Result<super::Path, PathError> {
255        super::Path::from_parametric(
256            |s: Jet3| {
257                vec![
258                    sin(s),
259                    cos(s),
260                    exp(0.3 * s) - 1.0,
261                    s + 0.1 * s * s - 0.01 * s * s * s * s,
262                    sin(2.0 * s) + 0.15 * cos(3.0 * s),
263                    sin(s) * cos(s),
264                ]
265            },
266            0.0,
267            1.0,
268        )
269    }
270
271    fn make_random_waypoints(dim: usize, n_pts: usize) -> DMatrix<f64> {
272        let mut rng = rand::rng();
273        // Build a random-walk waypoint matrix: each row is one dimension,
274        // each column is a waypoint; steps are small bounded increments.
275        let mut waypoints = DMatrix::<f64>::zeros(dim, n_pts);
276        for mut row in waypoints.row_iter_mut() {
277            row[0] = rng.random_range(-1.0..1.0);
278            for j in 1..n_pts {
279                let step = rng.random_range(-0.5..0.5);
280                row[j] = row[j - 1] + step;
281            }
282        }
283        waypoints
284    }
285
286    fn setup_robot_with_constraints(
287        s: &DMatrix<f64>,
288        derivs: &PathDerivatives,
289    ) -> Result<Robot<usize>, ConstraintError> {
290        let n = s.ncols();
291        let mut robot = Robot::with_capacity(DIM, n);
292
293        robot.with_s(&s.as_view())?;
294        robot.with_q(
295            &derivs.q.as_view(),
296            &derivs.dq.as_ref().unwrap().as_view(),
297            &derivs.ddq.as_ref().unwrap().as_view(),
298            derivs.dddq.as_ref().map(|m| m.as_view()).as_ref(),
299            0,
300        )?;
301
302        let vel_max = DMatrix::<f64>::from_element(DIM, n, 1.0);
303        let vel_min = DMatrix::<f64>::from_element(DIM, n, -1.0);
304        robot.with_axial_velocity(&vel_max.as_view(), &vel_min.as_view(), 0)?;
305
306        let acc_max = DMatrix::<f64>::from_element(DIM, n, 1.0);
307        let acc_min = DMatrix::<f64>::from_element(DIM, n, -1.0);
308        robot.with_axial_acceleration(&acc_max.as_view(), &acc_min.as_view(), 0)?;
309
310        let jerk_max = DMatrix::<f64>::from_element(DIM, n, 5.0);
311        let jerk_min = DMatrix::<f64>::from_element(DIM, n, -5.0);
312        robot.with_axial_jerk(&jerk_max.as_view(), &jerk_min.as_view(), 0)?;
313
314        Ok(robot)
315    }
316
317    struct ToppTimings {
318        topp2_ms: f64,
319        topp3_ms: f64,
320        t_motion_2: f64,
321        t_motion_3: f64,
322    }
323
324    fn solve_topp_pipeline(
325        robot: &mut Robot<usize>,
326        s_slice: &[f64],
327    ) -> Result<(Vec<f64>, Vec<f64>, (usize, usize), ToppTimings), Box<dyn Error>> {
328        let n = s_slice.len();
329
330        let topp2_problem = Topp2ProblemBuilder::new(&robot, (0, n - 1), (0.0, 0.0)).build()?;
331
332        let options = ReachSet2OptionsBuilder::new()
333            .a_cmp_abs_tol(1e-9)
334            .a_cmp_rel_tol(1e-9)
335            .lp_feas_tol(1e-9)
336            .verbosity(Verbosity::Silent)
337            .build()?;
338
339        let start = Instant::now();
340        let a_ra = topp2_ra(&topp2_problem, &options)?;
341        let topp2_ms = start.elapsed().as_secs_f64() * 1e3;
342        let (t_motion_2, _) = s_to_t_topp2(s_slice, &a_ra, 0.0);
343
344        robot.constraints.amax_substitute(&a_ra, 0)?;
345
346        let topp3_problem = Topp3ProblemBuilder::new(robot, 0, &a_ra, (0.0, 0.0), (0.0, 0.0))
347            .with_num_stationary_max(2)
348            .build_with_linearization()?;
349        let options_lp = ClarabelOptionsBuilder::new()
350            .allow_almost_solved(true)
351            .build()?;
352
353        let start = Instant::now();
354        let (a_lp, b_lp, num_stationary) = topp3_lp(&topp3_problem, &options_lp)?;
355        let topp3_ms = start.elapsed().as_secs_f64() * 1e3;
356        let (t_motion_3, _) = s_to_t_topp3(s_slice, &a_lp, &b_lp, num_stationary, 0.0);
357
358        Ok((
359            a_lp,
360            b_lp,
361            num_stationary,
362            ToppTimings {
363                topp2_ms,
364                topp3_ms,
365                t_motion_2,
366                t_motion_3,
367            },
368        ))
369    }
370
371    fn interpolate_to_time_domain(
372        path: &super::Path,
373        s_slice: &[f64],
374        a: &[f64],
375        b: &[f64],
376        num_stationary: (usize, usize),
377        dt: f64,
378    ) -> Result<
379        (
380            Vec<f64>,
381            DMatrix<f64>,
382            DMatrix<f64>,
383            DMatrix<f64>,
384            DMatrix<f64>,
385            f64,
386        ),
387        Box<dyn Error>,
388    > {
389        let start = Instant::now();
390        let (t_final, t_s) = s_to_t_topp3(s_slice, a, b, num_stationary, 0.0);
391
392        let t_sample: Vec<f64> = (0..=((t_final / dt).floor() as usize))
393            .map(|i| i as f64 * dt)
394            .collect();
395
396        let s_t = t_to_s_topp3(
397            s_slice,
398            a,
399            b,
400            num_stationary,
401            &t_s,
402            InterpolationMode::NonUniformTimeGrid(&t_sample),
403        );
404
405        // Evaluate q(s(t)) – only position needed, derivatives computed via finite diff
406        let n_t = t_sample.len();
407        let s_t_matrix = DMatrix::<f64>::from_row_slice(1, n_t, &s_t);
408        let q_t = path.evaluate_q(s_t_matrix.as_slice())?.q;
409
410        // Compute time-domain derivatives using finite differences
411        let mut dq_t = DMatrix::<f64>::zeros(DIM, n_t);
412        let mut ddq_t = DMatrix::<f64>::zeros(DIM, n_t);
413        let mut dddq_t = DMatrix::<f64>::zeros(DIM, n_t);
414
415        // Compute dq_t, ddq_t, dddq_t via central finite differences (forward/backward at ends).
416        finite_diff_inplace(&q_t, &mut dq_t, dt);
417        finite_diff_inplace(&dq_t, &mut ddq_t, dt);
418        finite_diff_inplace(&ddq_t, &mut dddq_t, dt);
419
420        let interp_ms = start.elapsed().as_secs_f64() * 1e3;
421
422        Ok((t_sample, q_t, dq_t, ddq_t, dddq_t, interp_ms))
423    }
424
425    fn plot_topp3_grid(
426        filename: &str,
427        title: &str,
428        t: &[f64],
429        q_t: &DMatrix<f64>,
430        dq_t: &DMatrix<f64>,
431        ddq_t: &DMatrix<f64>,
432        dddq_t: &DMatrix<f64>,
433    ) -> Result<(), Box<dyn Error>> {
434        if let Some(parent) = Path::new(filename).parent() {
435            if !parent.as_os_str().is_empty() {
436                create_dir_all(parent)?;
437            }
438        }
439
440        let root = BitMapBackend::new(filename, (2400, 1600)).into_drawing_area();
441        root.fill(&WHITE)?;
442
443        let areas = root.split_evenly((4, DIM));
444        let mats = [q_t, dq_t, ddq_t, dddq_t];
445        let labels = ["q(t)", "dq/dt", "d²q/dt²", "d³q/dt³"];
446        let constraints_bounds = [
447            None,
448            Some((-1.0, 1.0)),
449            Some((-1.0, 1.0)),
450            Some((-5.0, 5.0)),
451        ];
452
453        for row in 0..4 {
454            for col in 0..DIM {
455                let area = &areas[row * DIM + col];
456                let series: Vec<f64> = (0..mats[row].ncols())
457                    .map(|j| mats[row][(col, j)])
458                    .collect();
459
460                let (mut y_min, mut y_max) = min_max(&series);
461                if let Some((lb, ub)) = constraints_bounds[row] {
462                    y_min = y_min.min(lb);
463                    y_max = y_max.max(ub);
464                }
465                let pad = 0.1 * (y_max - y_min).max(0.1);
466                y_min -= pad;
467                y_max += pad;
468
469                let mut chart = ChartBuilder::on(area)
470                    .margin(10)
471                    .caption(
472                        format!("{} axis {}", labels[row], col + 1),
473                        ("sans-serif", 16),
474                    )
475                    .x_label_area_size(30)
476                    .y_label_area_size(45)
477                    .build_cartesian_2d(t[0]..t[t.len() - 1], y_min..y_max)?;
478
479                chart
480                    .configure_mesh()
481                    .x_desc(if row == 3 { "t (s)" } else { "" })
482                    .draw()?;
483
484                chart.draw_series(LineSeries::new(
485                    t.iter().zip(series.iter()).map(|(&x, &y)| (x, y)),
486                    &BLACK,
487                ))?;
488
489                if let Some((lb, ub)) = constraints_bounds[row] {
490                    chart.draw_series(LineSeries::new(
491                        vec![(t[0], lb), (t[t.len() - 1], lb)],
492                        ShapeStyle::from(&RED.mix(0.5)).stroke_width(2).filled(),
493                    ))?;
494                    chart.draw_series(LineSeries::new(
495                        vec![(t[0], ub), (t[t.len() - 1], ub)],
496                        ShapeStyle::from(&RED.mix(0.5)).stroke_width(2).filled(),
497                    ))?;
498                }
499            }
500        }
501
502        root.titled(title, ("sans-serif", 32))?;
503        root.present()?;
504        crate::verbosity_log!(crate::diag::Verbosity::Summary, "Saved plot: {filename}");
505        Ok(())
506    }
507
508    /// Compute first-order finite differences of `src` into `dst` (in-place).
509    /// Uses forward difference at index 0, backward at index n-1, central elsewhere.
510    fn finite_diff_inplace(src: &DMatrix<f64>, dst: &mut DMatrix<f64>, h: f64) {
511        let n = src.ncols();
512        if n < 2 {
513            return;
514        }
515        let inv2h = 1.0 / (2.0 * h);
516        // Iterate over each row (dimension) paired between src and dst.
517        for (src_row, mut dst_row) in src.row_iter().zip(dst.row_iter_mut()) {
518            // Forward difference at left boundary
519            dst_row[0] = (src_row[1] - src_row[0]) / h;
520            // Backward difference at right boundary
521            dst_row[n - 1] = (src_row[n - 1] - src_row[n - 2]) / h;
522            // Central differences for all interior points
523            for j in 1..n - 1 {
524                dst_row[j] = (src_row[j + 1] - src_row[j - 1]) * inv2h;
525            }
526        }
527    }
528
529    fn min_max(data: &[f64]) -> (f64, f64) {
530        data.iter()
531            .copied()
532            .filter(|v| v.is_finite())
533            .fold((f64::INFINITY, f64::NEG_INFINITY), |(mn, mx), v| {
534                (mn.min(v), mx.max(v))
535            })
536    }
537
538    #[test]
539    #[ignore = "plotting"]
540    fn test_topp3_with_parametric_path() -> Result<(), Box<dyn Error>> {
541        crate::verbosity_log!(
542            crate::diag::Verbosity::Summary,
543            "\n=== TOPP3 with Parametric Path (6-DOF) ==="
544        );
545
546        let start = Instant::now();
547        let path = make_parametric_path_6dof()?;
548        let build_ms = start.elapsed().as_secs_f64() * 1e3;
549
550        let s = make_s_vector(N);
551        let start = Instant::now();
552        let derivs = path.evaluate_up_to_3rd(s.as_slice())?;
553        let eval_ms = start.elapsed().as_secs_f64() * 1e3;
554
555        crate::verbosity_log!(
556            crate::diag::Verbosity::Summary,
557            "[Path Build] {build_ms:.3} ms, [Path Eval] {eval_ms:.3} ms"
558        );
559
560        let mut robot = setup_robot_with_constraints(&s, &derivs)?;
561        let s_slice: Vec<f64> = (0..N).map(|j| s[(0, j)]).collect();
562        let (a, b, num_stat, timings) = solve_topp_pipeline(&mut robot, &s_slice)?;
563
564        crate::verbosity_log!(
565            crate::diag::Verbosity::Summary,
566            "[TOPP2-RA] {:.3} ms, t_motion = {:.3} s",
567            timings.topp2_ms,
568            timings.t_motion_2
569        );
570        crate::verbosity_log!(
571            crate::diag::Verbosity::Summary,
572            "[TOPP3-LP] {:.3} ms, t_motion = {:.3} s",
573            timings.topp3_ms,
574            timings.t_motion_3
575        );
576
577        let (t, q_t, dq_t, ddq_t, dddq_t, interp_ms) =
578            interpolate_to_time_domain(&path, &s_slice, &a, &b, num_stat, 1e-3)?;
579        crate::verbosity_log!(
580            crate::diag::Verbosity::Summary,
581            "[Interpolation] {:.3} ms, {} samples",
582            interp_ms,
583            t.len()
584        );
585
586        plot_topp3_grid(
587            "data/path_topp_plots/parametric_path_topp3.png",
588            "TOPP3 Result: Parametric Path (6-DOF)",
589            &t,
590            &q_t,
591            &dq_t,
592            &ddq_t,
593            &dddq_t,
594        )?;
595
596        Ok(())
597    }
598}