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`](crate::path::Path::from_parametric) |
9//! | A set of waypoint positions | [`Path::from_waypoints`](crate::path::Path::from_waypoints) |
10//! | Explicit derivatives from an external evaluator | [`Path::from_evaluator_2nd`](crate::path::Path::from_evaluator_2nd) / [`Path::from_evaluator_3rd`](crate::path::Path::from_evaluator_3rd) |
11//!
12//! Once built, call one of the evaluation methods with a one-dimensional parameter slice:
13//!
14//! | Method | Output |
15//! |---|---|
16//! | [`Path::evaluate_q`](crate::path::Path::evaluate_q)         | position `q` only |
17//! | [`Path::evaluate_up_to_2nd`](crate::path::Path::evaluate_up_to_2nd) | `q`, `dq`, `ddq` |
18//! | [`Path::evaluate_up_to_3rd`](crate::path::Path::evaluate_up_to_3rd) | `q`, `dq`, `ddq`, `dddq` |
19//!
20//! # Examples
21//!
22//! ## Analytic path: automatic differentiation
23//!
24//! ```rust,no_run
25//! use copp::path::{Path, sin, cos};
26//! use copp::path::autodiff::Jet3;
27//!
28//! // Build a 2-DOF path: q0 = sin(s), q1 = cos(s), s in [0, 1]
29//! let path = Path::from_parametric(
30//!     |s: Jet3| vec![sin(s), cos(s)],
31//!     0.0, 1.0,
32//! ).unwrap();
33//!
34//! // Evaluate at 5 uniformly-spaced parameter values
35//! let s = [0.0, 0.25, 0.5, 0.75, 1.0];
36//! let out = path.evaluate_up_to_3rd(&s).unwrap();
37//! // out.q   : shape (2, 5)
38//! // out.dq  : Some, shape (2, 5)   first derivative w.r.t. s
39//! // out.dddq: Some, shape (2, 5)   third derivative w.r.t. s
40//! ```
41//!
42//! ## Spline path: waypoint interpolation
43//!
44//! ```rust,no_run
45//! use copp::path::{Path, SplineConfig};
46//! use nalgebra::DMatrix;
47//!
48//! // 2-DOF path with 5 waypoints; the spline passes exactly through each one.
49//! // waypoints shape: (dim=2, n_points=5)
50//! let waypoints = DMatrix::from_row_slice(2, 5, &[
51//!     0.0, 0.25, 0.5, 0.75, 1.0,   // dim 0
52//!     0.0, 0.1, -0.1, 0.2,  0.0,   // dim 1
53//! ]);
54//! let path = Path::from_waypoints(&waypoints, SplineConfig::default()).unwrap();
55//!
56//! let s = [0.0, 0.5, 1.0];
57//! let out = path.evaluate_q(&s).unwrap();
58//! // out.q shape (2, 3);  out.dq / ddq / dddq are all None
59//! ```
60
61pub mod autodiff;
62mod path_core;
63pub mod spline;
64
65pub use autodiff::{Jet3, cos, exp, ln, powi, sin, sqrt};
66pub use path_core::{
67    ParametricFn, Path, PathDerivatives, PathEvaluator, PathEvaluator2nd, PathEvaluator3rd,
68};
69pub use spline::{Parametrization, SplineConfig};
70
71#[cfg(test)]
72use crate::diag::PathError;
73
74/// Policy for handling path queries outside the configured `s` range.
75#[derive(Clone, Copy, Debug)]
76pub enum OutOfRangeMode {
77    /// Return an error when s is outside `[s_min, s_max]`.
78    Error,
79    /// Silently clamp s to `[s_min, s_max]`.
80    Clamp,
81}
82
83/// Build a random Lissajous analytic path.
84///
85/// This helper is intended for cross-module unit tests to avoid repeating the
86/// same hand-written path construction logic.
87#[cfg(test)]
88#[allow(clippy::type_complexity)]
89pub(crate) fn lissajous_path_for_test(
90    dim: usize,
91    s_len: usize,
92    rng: &mut impl rand::RngExt,
93) -> Result<(nalgebra::DMatrix<f64>, Path, Vec<f64>, Vec<f64>), PathError> {
94    let omega = (0..dim)
95        .map(|_| rng.random_range(0.1..(2.0 * std::f64::consts::PI)))
96        .collect::<Vec<f64>>();
97    let phi = (0..dim)
98        .map(|_| rng.random_range(0.0..(2.0 * std::f64::consts::PI)))
99        .collect::<Vec<f64>>();
100
101    let (s, path) = lissajous_path_fixed_for_test(dim, s_len, omega.clone(), phi.clone())?;
102
103    Ok((s, path, omega, phi))
104}
105
106/// Build a Lissajous analytic path from fixed frequencies/phases.
107///
108/// This helper is intended for cross-module unit tests to avoid repeating the
109/// same hand-written path construction logic.
110#[cfg(test)]
111pub(crate) fn lissajous_path_fixed_for_test(
112    dim: usize,
113    s_len: usize,
114    omega: Vec<f64>,
115    phi: Vec<f64>,
116) -> Result<(nalgebra::DMatrix<f64>, Path), PathError> {
117    let s = nalgebra::DMatrix::<f64>::from_fn(1, s_len, |_, j| j as f64 / (s_len - 1) as f64);
118
119    let path = Path::from_parametric(
120        move |s: Jet3| {
121            (0..dim)
122                .map(|i| sin(omega[i] * s + phi[i]))
123                .collect::<Vec<Jet3>>()
124        },
125        0.0,
126        1.0,
127    )?;
128
129    Ok((s, path))
130}
131
132/// Add symmetric axial limits (`+limit` / `-limit`) to all currently stored stations.
133///
134/// The helper applies velocity, acceleration and jerk constraints in one call.
135#[cfg(test)]
136pub(crate) fn add_symmetric_axial_limits_for_test<M: crate::robot::robot_core::RobotBasic>(
137    robot: &mut crate::robot::robot_core::Robot<M>,
138    vel_limit: f64,
139    acc_limit: f64,
140    jerk_limit: Option<f64>,
141) -> Result<(), crate::diag::ConstraintError> {
142    let dim = robot.dim();
143    let n = robot.constraints.len();
144    if n == 0 {
145        return Ok(());
146    }
147
148    let vel_max = vec![vel_limit; dim];
149    let vel_min = vec![-vel_limit; dim];
150
151    let acc_max = vec![acc_limit; dim];
152    let acc_min = vec![-acc_limit; dim];
153
154    let robot = robot
155        .with_axial_velocity((vel_max.as_slice(), n), (vel_min.as_slice(), n), 0)?
156        .with_axial_acceleration((acc_max.as_slice(), n), (acc_min.as_slice(), n), 0)?;
157
158    if let Some(jerk_limit) = jerk_limit {
159        let jerk_max = vec![jerk_limit; dim];
160        let jerk_min = vec![-jerk_limit; dim];
161        robot.with_axial_jerk((jerk_max.as_slice(), n), (jerk_min.as_slice(), n), 0)?;
162    }
163
164    Ok(())
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use crate::copp::copp2::stable::basic::{Topp2ProblemBuilder, s_to_t_topp2};
171    use crate::copp::copp2::stable::reach_set2::ReachSet2OptionsBuilder;
172    use crate::copp::copp2::stable::topp2_ra::topp2_ra;
173    use crate::copp::copp3::stable::basic::{
174        Topp3ProblemBuilder, Topp3Profile, s_to_t_topp3, t_to_s_topp3,
175    };
176    use crate::copp::copp3::stable::topp3_lp::topp3_lp;
177    use crate::copp::{ClarabelOptionsBuilder, InterpolationMode};
178    use crate::diag::ConstraintError;
179    use crate::diag::Verbosity;
180    use crate::robot::robot_core::Robot;
181    use nalgebra::DMatrix;
182    use plotters::prelude::*;
183    use rand::RngExt;
184    use std::error::Error;
185    use std::fs::create_dir_all;
186    use std::path::Path;
187    use std::time::Instant;
188
189    const DIM: usize = 6;
190    const N: usize = 1000;
191
192    #[test]
193    fn test_topp3_with_spline_path() -> Result<(), Box<dyn Error>> {
194        crate::verbosity_log!(
195            crate::diag::Verbosity::Summary,
196            "\n=== TOPP3 with Spline Path (6-DOF) ==="
197        );
198
199        let n_waypoints = 10;
200        let start = Instant::now();
201        let waypoints = make_random_waypoints(DIM, n_waypoints);
202        let sample_ms = start.elapsed().as_secs_f64() * 1e3;
203
204        let start = Instant::now();
205        let path = super::Path::from_waypoints(&waypoints, SplineConfig::default())?;
206        let build_ms = start.elapsed().as_secs_f64() * 1e3;
207
208        let s = make_s_vector(N);
209        let start = Instant::now();
210        let derivs = path.evaluate_up_to_3rd(s.as_slice())?;
211        let eval_ms = start.elapsed().as_secs_f64() * 1e3;
212
213        crate::verbosity_log!(
214            crate::diag::Verbosity::Summary,
215            "[Waypoint Sample] {sample_ms:.3} ms, [Spline Build] {build_ms:.3} ms, [Spline Eval] {eval_ms:.3} ms"
216        );
217
218        let mut robot = setup_robot_with_constraints(&s, &derivs)?;
219        let s_slice: Vec<f64> = (0..N).map(|j| s[(0, j)]).collect();
220        let (profile, timings) = solve_topp_pipeline(&mut robot, &s_slice)?;
221
222        crate::verbosity_log!(
223            crate::diag::Verbosity::Summary,
224            "[TOPP2-RA] {:.3} ms, t_motion = {:.3} s",
225            timings.topp2_ms,
226            timings.t_motion_2
227        );
228        crate::verbosity_log!(
229            crate::diag::Verbosity::Summary,
230            "[TOPP3-LP] {:.3} ms, t_motion = {:.3} s",
231            timings.topp3_ms,
232            timings.t_motion_3
233        );
234
235        let (t, q_t, dq_t, ddq_t, dddq_t, interp_ms) =
236            interpolate_to_time_domain(&path, &s_slice, &profile, 1e-3)?;
237        crate::verbosity_log!(
238            crate::diag::Verbosity::Summary,
239            "[Interpolation] {:.3} ms, {} samples",
240            interp_ms,
241            t.len()
242        );
243
244        plot_topp3_grid(
245            "data/path_topp_plots/spline_path_topp3.png",
246            "TOPP3 Result: Spline Path (6-DOF, 50 waypoints)",
247            &t,
248            &q_t,
249            &dq_t,
250            &ddq_t,
251            &dddq_t,
252        )?;
253
254        Ok(())
255    }
256
257    fn make_s_vector(n: usize) -> DMatrix<f64> {
258        DMatrix::<f64>::from_fn(1, n, |_, j| j as f64 / (n - 1) as f64)
259    }
260
261    fn make_parametric_path_6dof() -> Result<super::Path, PathError> {
262        super::Path::from_parametric(
263            |s: Jet3| {
264                vec![
265                    sin(s),
266                    cos(s),
267                    exp(0.3 * s) - 1.0,
268                    s + 0.1 * s * s - 0.01 * s * s * s * s,
269                    sin(2.0 * s) + 0.15 * cos(3.0 * s),
270                    sin(s) * cos(s),
271                ]
272            },
273            0.0,
274            1.0,
275        )
276    }
277
278    fn make_random_waypoints(dim: usize, n_pts: usize) -> DMatrix<f64> {
279        let mut rng = rand::rng();
280        // Build a random-walk waypoint matrix: each row is one dimension,
281        // each column is a waypoint; steps are small bounded increments.
282        let mut waypoints = DMatrix::<f64>::zeros(dim, n_pts);
283        for mut row in waypoints.row_iter_mut() {
284            row[0] = rng.random_range(-1.0..1.0);
285            for j in 1..n_pts {
286                let step = rng.random_range(-0.5..0.5);
287                row[j] = row[j - 1] + step;
288            }
289        }
290        waypoints
291    }
292
293    fn setup_robot_with_constraints(
294        s: &DMatrix<f64>,
295        derivs: &PathDerivatives,
296    ) -> Result<Robot<usize>, ConstraintError> {
297        let n = s.ncols();
298        let mut robot = Robot::with_capacity(DIM, n);
299
300        let vel_max = vec![1.0; DIM];
301        let vel_min = vec![-1.0; DIM];
302        let acc_max = vec![1.0; DIM];
303        let acc_min = vec![-1.0; DIM];
304        let jerk_max = vec![5.0; DIM];
305        let jerk_min = vec![-5.0; DIM];
306        robot
307            .with_s(&s.as_view())?
308            .with_q(
309                &derivs.q.as_view(),
310                &derivs.dq.as_ref().unwrap().as_view(),
311                &derivs.ddq.as_ref().unwrap().as_view(),
312                derivs.dddq.as_ref().map(|m| m.as_view()).as_ref(),
313                0,
314            )?
315            .with_axial_velocity((vel_max.as_slice(), n), (vel_min.as_slice(), n), 0)?
316            .with_axial_acceleration((acc_max.as_slice(), n), (acc_min.as_slice(), n), 0)?
317            .with_axial_jerk((jerk_max.as_slice(), n), (jerk_min.as_slice(), n), 0)?;
318
319        Ok(robot)
320    }
321
322    struct ToppTimings {
323        topp2_ms: f64,
324        topp3_ms: f64,
325        t_motion_2: f64,
326        t_motion_3: f64,
327    }
328
329    fn solve_topp_pipeline(
330        robot: &mut Robot<usize>,
331        s_slice: &[f64],
332    ) -> Result<(Topp3Profile, ToppTimings), Box<dyn Error>> {
333        let n = s_slice.len();
334
335        let topp2_problem = Topp2ProblemBuilder::new(robot, (0, n - 1), (0.0, 0.0)).build()?;
336
337        let options = ReachSet2OptionsBuilder::new()
338            .a_cmp_abs_tol(1e-9)
339            .a_cmp_rel_tol(1e-9)
340            .lp_feas_tol(1e-9)
341            .verbosity(Verbosity::Silent)
342            .build()?;
343
344        let start = Instant::now();
345        let a_ra = topp2_ra(&topp2_problem, &options)?;
346        let topp2_ms = start.elapsed().as_secs_f64() * 1e3;
347        let (t_motion_2, _) = s_to_t_topp2(s_slice, &a_ra, 0.0)?;
348
349        robot.constraints.amax_substitute(&a_ra, 0)?;
350
351        let topp3_problem = Topp3ProblemBuilder::new(robot, 0, &a_ra, (0.0, 0.0), (0.0, 0.0))
352            .with_num_stationary_max(2)
353            .build_with_linearization()?;
354        let options_lp = ClarabelOptionsBuilder::new()
355            .allow_almost_solved(true)
356            .build()?;
357
358        let start = Instant::now();
359        let profile = topp3_lp(&topp3_problem, &options_lp)?;
360        let topp3_ms = start.elapsed().as_secs_f64() * 1e3;
361        let (t_motion_3, _) = s_to_t_topp3(s_slice, profile.as_parts(), 0.0)?;
362
363        Ok((
364            profile,
365            ToppTimings {
366                topp2_ms,
367                topp3_ms,
368                t_motion_2,
369                t_motion_3,
370            },
371        ))
372    }
373
374    #[allow(clippy::type_complexity)]
375    fn interpolate_to_time_domain(
376        path: &super::Path,
377        s_slice: &[f64],
378        profile: &Topp3Profile,
379        dt: f64,
380    ) -> Result<
381        (
382            Vec<f64>,
383            DMatrix<f64>,
384            DMatrix<f64>,
385            DMatrix<f64>,
386            DMatrix<f64>,
387            f64,
388        ),
389        Box<dyn Error>,
390    > {
391        let start = Instant::now();
392        let (t_final, t_s) = s_to_t_topp3(s_slice, profile.as_parts(), 0.0)?;
393
394        let t_sample: Vec<f64> = (0..=((t_final / dt).floor() as usize))
395            .map(|i| i as f64 * dt)
396            .collect();
397
398        let s_t = t_to_s_topp3(
399            s_slice,
400            profile.as_parts(),
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            && !parent.as_os_str().is_empty()
436        {
437            create_dir_all(parent)?;
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    fn test_topp3_with_parametric_path() -> Result<(), Box<dyn Error>> {
540        crate::verbosity_log!(
541            crate::diag::Verbosity::Summary,
542            "\n=== TOPP3 with Parametric Path (6-DOF) ==="
543        );
544
545        let start = Instant::now();
546        let path = make_parametric_path_6dof()?;
547        let build_ms = start.elapsed().as_secs_f64() * 1e3;
548
549        let s = make_s_vector(N);
550        let start = Instant::now();
551        let derivs = path.evaluate_up_to_3rd(s.as_slice())?;
552        let eval_ms = start.elapsed().as_secs_f64() * 1e3;
553
554        crate::verbosity_log!(
555            crate::diag::Verbosity::Summary,
556            "[Path Build] {build_ms:.3} ms, [Path Eval] {eval_ms:.3} ms"
557        );
558
559        let mut robot = setup_robot_with_constraints(&s, &derivs)?;
560        let s_slice: Vec<f64> = (0..N).map(|j| s[(0, j)]).collect();
561        let (profile, timings) = solve_topp_pipeline(&mut robot, &s_slice)?;
562
563        crate::verbosity_log!(
564            crate::diag::Verbosity::Summary,
565            "[TOPP2-RA] {:.3} ms, t_motion = {:.3} s",
566            timings.topp2_ms,
567            timings.t_motion_2
568        );
569        crate::verbosity_log!(
570            crate::diag::Verbosity::Summary,
571            "[TOPP3-LP] {:.3} ms, t_motion = {:.3} s",
572            timings.topp3_ms,
573            timings.t_motion_3
574        );
575
576        let (t, q_t, dq_t, ddq_t, dddq_t, interp_ms) =
577            interpolate_to_time_domain(&path, &s_slice, &profile, 1e-3)?;
578        crate::verbosity_log!(
579            crate::diag::Verbosity::Summary,
580            "[Interpolation] {:.3} ms, {} samples",
581            interp_ms,
582            t.len()
583        );
584
585        plot_topp3_grid(
586            "data/path_topp_plots/parametric_path_topp3.png",
587            "TOPP3 Result: Parametric Path (6-DOF)",
588            &t,
589            &q_t,
590            &dq_t,
591            &ddq_t,
592            &dddq_t,
593        )?;
594
595        Ok(())
596    }
597}