Skip to main content

repose_trajectory/
path_timing.rs

1//! Profile timing of sampled Cartesian paths.
2//!
3//! Geometry and timing are independent steps.
4//! [`blend_cartesian_path`](crate::blend_cartesian_path)
5//! turns waypoints and their per-point TCP zones into a joint-space path;
6//! [`parameterize_cartesian_path`] times that path with any
7//! [`CuTrajectoryProfile`]. A zone belongs to its waypoint, not to the
8//! profile: every profile flies through every positive zone and stops
9//! exactly at every zero-radius waypoint.
10//!
11//! The knots are joined by a C2 natural cubic spline `q(s)` over a
12//! path parameter `s`. Exact stops split the path into rest-to-rest pieces.
13//! Inside a piece each TCP zone is traversed at a constant corner speed
14//! bounded by the zone's joint-space curvature, and every move between a stop
15//! or corner and the next follows the selected profile from one boundary speed
16//! to the other. Joint velocity and acceleration bounds follow from analytic
17//! derivative bounds of `q(s)` and of the path-speed profile; S-curve joint jerk is bounded on the executed quintic reconstruction.
18//! Every profile is finally certified on the reconstruction its follower executes — quintic
19//! Hermite for the S-curve, cubic Hermite for the others — and stretched uniformly when a peak
20//! exceeds a limit.
21
22use crate::CuTrajectoryInterpolation;
23use crate::CuTrajectoryPayload;
24use crate::CuTrajectoryPoint;
25use crate::cartesian_blend::CuCartesianBlendPath;
26use crate::cartesian_blend::CuCartesianPathPoint;
27use crate::parameterize::CuJointTrajectoryLimits;
28use crate::parameterize::CuTimingParams;
29use crate::parameterize::CuTimingStatus;
30use crate::parameterize::CuTrajectoryProfile;
31use crate::parameterize::cubic_eval;
32use crate::parameterize::quintic_eval;
33use crate::quintic::cubic_limit_ratio;
34use crate::quintic::polynomial_extrema;
35use cu_spatial_payloads::Transform3D;
36use cu29::prelude::*;
37use cu29::units::si::f64::Time;
38use cu29::units::si::length::meter;
39use cu29::units::si::time::second;
40use repose_collision::CuCollisionEnv;
41use repose_collision::CuRobotCollisionModel;
42use repose_kinematics::CuJointAcceleration;
43use repose_kinematics::CuJointJerk;
44use repose_kinematics::CuJointPosition;
45use repose_kinematics::CuJointResolution;
46use repose_kinematics::CuJointVelocity;
47use repose_kinematics::CuKinematicChain;
48use serde::Deserialize;
49use serde::Serialize;
50
51/// Parameters of [`parameterize_cartesian_path`]: the shared joint timing
52/// plus the collision recheck resolution.
53///
54/// ```ron
55/// {
56///     "timing": {
57///         "limits": {
58///             "max_velocities": [{"revolute": 1.0}],
59///             "max_accelerations": [{"revolute": 2.0}],
60///             "max_jerks": [{"revolute": 10.0}],
61///         },
62///         "profile": "s_curve",
63///         "sample_period": 0.02,
64///     },
65///     "collision_step": {"angular": 0.08, "linear": 0.01},
66/// }
67/// ```
68#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Reflect)]
69#[reflect(no_field_bounds, from_reflect = false)]
70// Field-level defaults: serde does not support container `default` on const-generic structs.
71#[serde(deny_unknown_fields)]
72pub struct CuPathTimingParams<const N: usize> {
73    /// Limits, path-speed profile of every move, and sample period.
74    pub timing: CuTimingParams<N>,
75    /// Joint resolution for the collision recheck of the interpolated path.
76    #[serde(default)]
77    pub collision_step: CuJointResolution,
78}
79
80impl<const N: usize> Default for CuPathTimingParams<N> {
81    fn default() -> Self {
82        Self {
83            timing: CuTimingParams::default(),
84            collision_step: CuJointResolution::default(),
85        }
86    }
87}
88
89impl<const N: usize> CuPathTimingParams<N> {
90    /// Checks the timing parameters and the collision resolution.
91    pub fn validate(&self) -> CuResult<()> {
92        self.timing.validate()?;
93        self.collision_step.metric().validate()
94    }
95}
96
97/// Times a sampled Cartesian path with one profile along the whole path.
98///
99/// Every move between an exact stop or zone corner and the next follows
100/// `params.timing.profile` in the path parameter; zones are crossed at a
101/// constant corner speed. Joint velocity and acceleration stay within
102/// `params.timing.limits`; S-curve additionally bounds joint jerk on the
103/// quintic reconstruction, including curvature contributions. The geometric
104/// spline is rechecked for collisions against `env` (pass
105/// [`CuCollisionEnv::EMPTY`] for self-collision only) and for zone
106/// containment with discrete samples, not swept-volume continuous collision
107/// detection. This check does not certify the reconstructed execution curve
108/// against geometry. `out.interpolation` is set to the reconstruction the
109/// limits were validated on: [`CuTrajectoryInterpolation::Quintic`] for the
110/// S-curve, [`CuTrajectoryInterpolation::Cubic`] for the other profiles, so
111/// [`CuTrajectoryPayload::sample`] (and the default follower) execute the
112/// validated position, velocity and acceleration. All scratch storage is
113/// const-sized; `out` is empty unless the status is `Success`.
114pub fn parameterize_cartesian_path<
115    const N: usize,
116    const M: usize,
117    const CAP: usize,
118    const OUT: usize,
119>(
120    chain: &CuKinematicChain<N>,
121    model: &CuRobotCollisionModel<N, M>,
122    env: &CuCollisionEnv<'_>,
123    path: &CuCartesianBlendPath<N, CAP>,
124    params: &CuPathTimingParams<N>,
125    out: &mut CuTrajectoryPayload<N, OUT>,
126) -> CuResult<CuTimingStatus> {
127    out.clear();
128    out.interpolation = if params.timing.profile == CuTrajectoryProfile::SCurve {
129        CuTrajectoryInterpolation::Quintic
130    } else {
131        CuTrajectoryInterpolation::Cubic
132    };
133    let result = time_path(chain, model, env, path, params, out);
134    if !matches!(result, Ok(CuTimingStatus::Success)) {
135        out.clear();
136    }
137    result
138}
139
140/// Path-speed bounds for one span: `ds/dt <= vel`, `|d2s/dt2| <= acc`,
141/// `|d3s/dt3| <= jerk`.
142#[derive(Debug, Clone, Copy, Default)]
143struct SpeedLimits {
144    vel: f64,
145    acc: f64,
146    jerk: f64,
147}
148
149/// A run of pieces timed as one unit: a move, or a zone corner.
150#[derive(Debug, Clone, Copy, Default)]
151struct Span {
152    start: usize,
153    end: usize,
154    corner: Option<usize>,
155    limits: SpeedLimits,
156    /// Corner speed; unused for moves.
157    speed: f64,
158}
159
160/// Constant-jerk phase starting with acceleration `acc`.
161#[derive(Debug, Clone, Copy, Default)]
162struct Phase {
163    duration: f64,
164    jerk: f64,
165    acc: f64,
166}
167
168/// Path-speed motion of one span from boundary speed `v0` to `v1`.
169#[derive(Debug, Clone, Copy)]
170enum Motion {
171    Constant { speed: f64 },
172    Cubic { v0: f64, v1: f64 },
173    Quintic { v0: f64, v1: f64 },
174    Phased { v0: f64, phases: [Phase; 7] },
175}
176
177fn time_path<const N: usize, const M: usize, const CAP: usize, const OUT: usize>(
178    chain: &CuKinematicChain<N>,
179    model: &CuRobotCollisionModel<N, M>,
180    env: &CuCollisionEnv<'_>,
181    path: &CuCartesianBlendPath<N, CAP>,
182    config: &CuPathTimingParams<N>,
183    out: &mut CuTrajectoryPayload<N, OUT>,
184) -> CuResult<CuTimingStatus> {
185    let limits = &config.timing.limits;
186    let profile = config.timing.profile;
187    let count = path.len;
188    let dt = config.timing.sample_period.get::<second>();
189    if count < 2
190        || count > CAP
191        || !path.points[0].stop
192        || !path.points[count - 1].stop
193        || !dt.is_finite()
194        || dt <= 0.0
195        || config.collision_step.metric().validate().is_err()
196    {
197        return Ok(CuTimingStatus::InvalidInput);
198    }
199    let first = path.points[0].positions;
200    for (j, &joint) in first.iter().enumerate() {
201        let valid_kind = matches!(
202            (joint, limits.max_velocities[j]),
203            (CuJointPosition::Revolute(_), CuJointVelocity::Revolute(_))
204                | (CuJointPosition::Prismatic(_), CuJointVelocity::Prismatic(_))
205                | (CuJointPosition::Fixed, CuJointVelocity::Fixed)
206        ) && core::mem::discriminant(&limits.max_accelerations[j])
207            == core::mem::discriminant(&CuJointAcceleration::from_coordinate(joint, 0.0))
208            && (profile != CuTrajectoryProfile::SCurve
209                || matches!(joint, CuJointPosition::Fixed)
210                || limits.max_jerks.is_none_or(|jerks| {
211                    core::mem::discriminant(&jerks[j])
212                        == core::mem::discriminant(&CuJointJerk::from_coordinate(joint, 0.0))
213                }));
214        if !valid_kind {
215            return Ok(CuTimingStatus::InvalidInput);
216        }
217        if matches!(joint, CuJointPosition::Fixed) {
218            continue;
219        }
220        let v = limits.max_velocities[j].si_value();
221        let a = limits.acc(j);
222        let jerk = limits.jerk(j);
223        if !v.is_finite()
224            || v <= 0.0
225            || !a.is_finite()
226            || a <= 0.0
227            || (profile == CuTrajectoryProfile::SCurve && (jerk.is_nan() || jerk <= 0.0))
228        {
229            return Ok(CuTimingStatus::InvalidInput);
230        }
231    }
232
233    // Path parameter: speed-weighted joint-space arc length.
234    let points = path.points();
235    let mut s = [0.0_f64; CAP];
236    for i in 0..count {
237        let q = points[i].positions;
238        if !chain.is_within_limits(&q)
239            || (0..N).any(|j| core::mem::discriminant(&q[j]) != core::mem::discriminant(&first[j]))
240        {
241            return Ok(CuTimingStatus::InvalidInput);
242        }
243        if i > 0 {
244            let h = step(&points[i - 1].positions, &q, limits);
245            if !h.is_finite() || h <= 1e-15 {
246                return Ok(CuTimingStatus::InvalidInput);
247            }
248            s[i] = s[i - 1] + h;
249        }
250    }
251
252    // C2 natural cubic spline q(s) through the knots, one rest-to-rest run at
253    // a time. A non-stop knot where every joint stops moving cannot be
254    // crossed at speed.
255    let curvatures = spline_curvatures(points, &s);
256    let curve = Curve {
257        points,
258        s: &s,
259        curvatures: &curvatures,
260    };
261    for (i, _) in points.iter().enumerate().take(count - 1).skip(1) {
262        if points[i].stop {
263            continue;
264        }
265        let (_, slope, _) = curve.eval(i, 0.0);
266        if slope.iter().all(|m| libm::fabs(*m) <= 1e-12) {
267            return Ok(match points[i].zone {
268                Some(zone) => CuTimingStatus::NoContinuousMotion {
269                    waypoint: zone.waypoint,
270                },
271                None => CuTimingStatus::UnexpectedStop { sample: i },
272            });
273        }
274    }
275
276    // Geometry recheck: joint limits, collisions and zone containment along q(s).
277    let metric = config.collision_step.metric();
278    for i in 0..count - 1 {
279        let h = s[i + 1] - s[i];
280        let (slope, _, _) = curve.bounds(i);
281        let mut checks = 1.0_f64;
282        for (j, &peak) in slope.iter().enumerate() {
283            checks = checks.max(libm::ceil(peak * h / metric.scale(first[j])));
284        }
285        if !checks.is_finite() || checks > 100_000.0 {
286            return Ok(CuTimingStatus::InvalidInput);
287        }
288        let zone = points[i + 1].zone;
289        let mut previous = points[i].positions;
290        for k in 1..=checks as usize {
291            let (q, _, _) = curve.eval(i, h * k as f64 / checks);
292            if !chain.is_within_limits(&q) {
293                return Ok(CuTimingStatus::InvalidInput);
294            }
295            if !model.is_configuration_collision_free(chain, &q, env)?
296                || !model.is_segment_collision_free(
297                    chain,
298                    &previous,
299                    &q,
300                    config.collision_step,
301                    env,
302                )?
303            {
304                return Ok(CuTimingStatus::Collision { sample: i });
305            }
306            if let Some(zone) = zone {
307                let mut pose = Transform3D::<f64>::identity();
308                chain.forward_kinematics(&q, &mut pose)?;
309                let m = pose.to_matrix();
310                let c = zone.center.map(|c| c.get::<meter>());
311                let d = [m[0][3] - c[0], m[1][3] - c[1], m[2][3] - c[2]];
312                if libm::sqrt(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]) > zone.radius.get::<meter>()
313                {
314                    return Ok(CuTimingStatus::ZoneExceeded {
315                        waypoint: zone.waypoint,
316                    });
317                }
318            }
319            previous = q;
320        }
321    }
322
323    // Spans: runs of pieces sharing one corner, split at exact stops.
324    let mut spans = [Span::default(); CAP];
325    let mut nspans = 0;
326    let mut start = 0;
327    for i in 0..count - 1 {
328        let corner = corner_of(points, i);
329        let last = i + 1 == count - 1 || points[i + 1].stop || corner_of(points, i + 1) != corner;
330        if last {
331            spans[nspans] = Span {
332                start,
333                end: i + 1,
334                corner,
335                ..Span::default()
336            };
337            nspans += 1;
338            start = i + 1;
339        }
340    }
341    let spans = &mut spans[..nspans];
342    let scurve = profile == CuTrajectoryProfile::SCurve;
343    // Moves keep half the acceleration budget for curvature; corners spend
344    // all of it there because their path speed is constant. Joint jerk is
345    // `q3·v³ + 3·q2·v·a + q1·j` in the path derivatives `q1..q3` and the
346    // path speed `v`, acceleration `a` and jerk `j`: the S-curve gives each term a third of the joint jerk limit,
347    // and the path jerk takes what the other two leave unused. Speed bounds
348    // come from the move interior; the acceleration and jerk budgets hold on
349    // every piece at the speed that piece allows.
350    for p in 0..spans.len() {
351        let (lo, hi) = interior(spans, p);
352        let span = &mut spans[p];
353        let mut vel = f64::INFINITY;
354        let mut corner_vel = f64::INFINITY;
355        for i in lo..hi {
356            let (move_cap, corner_cap) = speed_caps(&curve.bounds(i), limits, &first, scurve);
357            vel = vel.min(move_cap);
358            corner_vel = corner_vel.min(corner_cap);
359        }
360        if !vel.is_finite() || vel <= 0.0 {
361            return Ok(CuTimingStatus::InvalidInput);
362        }
363        let mut acc = f64::INFINITY;
364        for i in span.start..span.end {
365            let bounds = curve.bounds(i);
366            let (slope, curvature, _) = bounds;
367            let v = vel.min(speed_caps(&bounds, limits, &first, scurve).0);
368            for j in 0..N {
369                if matches!(first[j], CuJointPosition::Fixed) {
370                    continue;
371                }
372                let jmax = limits.jerk(j);
373                if slope[j] > 1e-300 {
374                    acc = acc.min((limits.acc(j) - curvature[j] * v * v) / slope[j]);
375                }
376                if scurve && jmax.is_finite() && curvature[j] > 1e-300 {
377                    acc = acc.min(jmax / (9.0 * curvature[j] * v));
378                }
379            }
380        }
381        if !acc.is_finite() || acc <= 0.0 {
382            return Ok(CuTimingStatus::InvalidInput);
383        }
384        let mut jerk = f64::INFINITY;
385        if scurve {
386            for i in span.start..span.end {
387                let bounds = curve.bounds(i);
388                let (slope, curvature, third) = bounds;
389                let v = vel.min(speed_caps(&bounds, limits, &first, scurve).0);
390                for j in 0..N {
391                    let jmax = limits.jerk(j);
392                    if matches!(first[j], CuJointPosition::Fixed)
393                        || slope[j] <= 1e-300
394                        || !jmax.is_finite()
395                    {
396                        continue;
397                    }
398                    let left = jmax - third[j] * v * v * v - 3.0 * curvature[j] * v * acc;
399                    jerk = jerk.min(left / slope[j]);
400                }
401            }
402            if jerk.is_nan() || jerk <= 0.0 {
403                return Ok(CuTimingStatus::InvalidInput);
404            }
405        }
406        span.limits = SpeedLimits { vel, acc, jerk };
407        if span.corner.is_some() {
408            span.speed = corner_vel;
409        }
410    }
411    // Adjacent corners form a run crossed at one speed, between two moves;
412    // a run never touches a stop.
413    let mut p = 0;
414    while p < spans.len() {
415        if spans[p].corner.is_none() {
416            p += 1;
417            continue;
418        }
419        let first_corner = p;
420        while p + 1 < spans.len() && spans[p + 1].corner.is_some() {
421            p += 1;
422        }
423        if first_corner == 0
424            || p + 1 == spans.len()
425            || points[spans[first_corner].start].stop
426            || points[spans[p].end].stop
427        {
428            return Ok(CuTimingStatus::InvalidInput);
429        }
430        let speed = spans[first_corner..=p]
431            .iter()
432            .fold(f64::INFINITY, |v, span| v.min(span.speed))
433            .min(spans[first_corner - 1].limits.vel)
434            .min(spans[p + 1].limits.vel);
435        set_run_speed(spans, first_corner, speed);
436        p += 1;
437    }
438
439    // Lower corner speeds until every move can join its boundary speeds.
440    let mut settled = false;
441    let mut blocked = None;
442    for _ in 0..400 {
443        settled = true;
444        for p in 0..spans.len() {
445            if spans[p].corner.is_some() {
446                continue;
447            }
448            let (v0, v1) = boundary_speeds(spans, p);
449            if plan_span(&curve, &s, spans, p, (v0, v1), config).is_some() {
450                continue;
451            }
452            settled = false;
453            let previous = (p > 0 && spans[p - 1].corner.is_some()).then(|| p - 1);
454            let next = (p + 1 < spans.len() && spans[p + 1].corner.is_some()).then_some(p + 1);
455            // Lower the faster boundary corner.
456            let lower = match (previous, next) {
457                (Some(before), Some(after)) => {
458                    if v0 >= v1 {
459                        before
460                    } else {
461                        after
462                    }
463                }
464                (Some(corner), None) | (None, Some(corner)) => corner,
465                (None, None) => return Ok(CuTimingStatus::InvalidInput),
466            };
467            set_run_speed(spans, lower, spans[lower].speed * 0.9);
468            blocked = spans[lower].corner;
469        }
470        if settled {
471            break;
472        }
473    }
474    if !settled {
475        return Ok(match blocked {
476            Some(waypoint) => CuTimingStatus::NoContinuousMotion { waypoint },
477            None => CuTimingStatus::InvalidInput,
478        });
479    }
480
481    if OUT == 0 {
482        return Ok(CuTimingStatus::CapacityExceeded);
483    }
484    out.push(CuTrajectoryPoint {
485        time_from_start: Time::default(),
486        positions: first,
487        velocities: first.map(|q| CuJointVelocity::from_coordinate(q, 0.0)),
488        accelerations: first.map(|q| CuJointAcceleration::from_coordinate(q, 0.0)),
489    })?;
490    let mut elapsed = 0.0;
491    for p in 0..spans.len() {
492        let span = spans[p];
493        let length = s[span.end] - s[span.start];
494        let (v0, v1) = boundary_speeds(spans, p);
495        let (motion, duration) = if span.corner.is_some() {
496            (Motion::Constant { speed: span.speed }, length / span.speed)
497        } else {
498            match plan_span(&curve, &s, spans, p, (v0, v1), config) {
499                Some(planned) => planned,
500                None => return Ok(CuTimingStatus::InvalidInput),
501            }
502        };
503        let steps = libm::ceil(duration / dt).max(1.0);
504        let step_dt = duration / steps;
505        // The quintic reconstruction of the S-curve is exact only where the
506        // motion is smooth, so knots are added where the path jerk changes
507        // (phase boundaries) and where the path crosses a spline knot (the
508        // third path derivative changes).
509        let mut phase_times = [0.0_f64; 6];
510        let mut phase_count = 0;
511        let mut knot_times = [0.0_f64; CAP];
512        let mut knot_count = 0;
513        if scurve {
514            if let Motion::Phased { phases, .. } = motion {
515                let mut at = 0.0;
516                for phase in &phases[..6] {
517                    at += phase.duration;
518                    phase_times[phase_count] = at;
519                    phase_count += 1;
520                }
521            }
522            for knot in span.start + 1..span.end {
523                knot_times[knot_count] =
524                    crossing_time(&motion, length, duration, s[knot] - s[span.start]);
525                knot_count += 1;
526            }
527        }
528        if !steps.is_finite()
529            || steps + (phase_count + knot_count) as f64 > OUT.saturating_sub(out.len()) as f64
530        {
531            return Ok(CuTimingStatus::CapacityExceeded);
532        }
533        let steps = steps as usize;
534        let (mut k, mut next_phase, mut next_knot) = (1, 0, 0);
535        let mut emitted = 0.0_f64;
536        let mut piece = span.start;
537        while k <= steps {
538            let grid = duration * k as f64 / steps as f64;
539            // Next extra time, skipping any that would crowd an emitted knot
540            // or the span end, so knot times strictly increase.
541            let mut extra = f64::INFINITY;
542            let mut from_phase = false;
543            loop {
544                let phase = phase_times
545                    .get(next_phase)
546                    .filter(|_| next_phase < phase_count);
547                let knot = knot_times.get(next_knot).filter(|_| next_knot < knot_count);
548                let (candidate, is_phase) = match (phase, knot) {
549                    (Some(&p), Some(&q)) if p <= q => (p, true),
550                    (_, Some(&q)) => (q, false),
551                    (Some(&p), None) => (p, true),
552                    (None, None) => break,
553                };
554                if candidate <= emitted + 0.1 * step_dt || candidate >= duration - 0.1 * step_dt {
555                    if is_phase {
556                        next_phase += 1;
557                    } else {
558                        next_knot += 1;
559                    }
560                    continue;
561                }
562                extra = candidate;
563                from_phase = is_phase;
564                break;
565            }
566            let mut take_extra = || {
567                if from_phase {
568                    next_phase += 1;
569                } else {
570                    next_knot += 1;
571                }
572                extra
573            };
574            let mut t = grid;
575            let mut last = false;
576            if extra < grid - 0.1 * step_dt {
577                t = take_extra();
578            } else {
579                if extra <= grid + 0.1 * step_dt {
580                    t = take_extra();
581                }
582                last = k == steps;
583                k += 1;
584            }
585            emitted = t;
586            let (positions, velocities, accelerations) = if last {
587                let end = span.end - 1;
588                let (_, slope, curvature) = curve.eval(end, s[span.end] - s[end]);
589                let (_, _, acc) = motion.eval(length, duration, duration);
590                joint_state(points[span.end].positions, slope, curvature, v1, acc)
591            } else {
592                let (offset, speed, acc) = motion.eval(length, duration, t);
593                let at = s[span.start] + offset.clamp(0.0, length);
594                while piece + 1 < span.end && at > s[piece + 1] {
595                    piece += 1;
596                }
597                let (q, slope, curvature) = curve.eval(piece, at - s[piece]);
598                joint_state(q, slope, curvature, speed.max(0.0), acc)
599            };
600            out.push(CuTrajectoryPoint {
601                time_from_start: Time::new::<second>(elapsed + t),
602                positions,
603                velocities,
604                accelerations,
605            })?;
606        }
607        elapsed += duration;
608    }
609
610    // The follower executes a Hermite motion through these knots — quintic
611    // for the S-curve, cubic for the others — so the limits are checked on
612    // exactly that reconstruction. Stretching time by the worst ratio of peak
613    // to limit makes every peak fit: velocity, acceleration and jerk scale by
614    // 1/k, 1/k^2 and 1/k^3, keeping the shape.
615    let ratio = if scurve {
616        out.quintic_limit_ratio(limits)
617    } else {
618        cubic_limit_ratio(out, limits)
619    };
620    match ratio {
621        Some(ratio) if ratio.is_finite() => {
622            if ratio > 1.0 {
623                out.stretch_time(ratio * (1.0 + 1e-9))?;
624            }
625        }
626        _ => return Ok(CuTimingStatus::InvalidInput),
627    }
628    Ok(CuTimingStatus::Success)
629}
630
631/// Time at which `motion` reaches path offset `target`; the motion's offset
632/// never decreases, so bisection converges to the first crossing.
633fn crossing_time(motion: &Motion, length: f64, duration: f64, target: f64) -> f64 {
634    let (mut lo, mut hi) = (0.0, duration);
635    for _ in 0..64 {
636        let mid = 0.5 * (lo + hi);
637        if motion.eval(length, duration, mid).0 < target {
638            lo = mid;
639        } else {
640            hi = mid;
641        }
642    }
643    hi
644}
645
646/// Zone corner owning piece `i`: the zone of either end knot, extended by
647/// one piece before the zone entry, where the spline already bends.
648fn corner_of<const N: usize>(points: &[CuCartesianPathPoint<N>], i: usize) -> Option<usize> {
649    let zone = |k: usize| points.get(k).and_then(|p| p.zone).map(|z| z.waypoint);
650    zone(i + 1)
651        .or_else(|| zone(i).filter(|_| !points[i + 1].stop))
652        .or_else(|| zone(i + 2).filter(|_| !points[i].stop))
653}
654
655/// Path-speed caps `(move, corner)` of one piece from its derivative bounds.
656///
657/// A move keeps half the acceleration and a third of the jerk budget for the
658/// curvature terms; a corner at constant speed spends them all there.
659fn speed_caps<const N: usize>(
660    (slope, curvature, third): &([f64; N], [f64; N], [f64; N]),
661    limits: &CuJointTrajectoryLimits<N>,
662    first: &[CuJointPosition; N],
663    scurve: bool,
664) -> (f64, f64) {
665    let mut move_cap = f64::INFINITY;
666    let mut corner_cap = f64::INFINITY;
667    for j in 0..N {
668        if matches!(first[j], CuJointPosition::Fixed) {
669            continue;
670        }
671        let v = limits.max_velocities[j].si_value();
672        let a = limits.acc(j);
673        let jmax = limits.jerk(j);
674        if slope[j] > 1e-300 {
675            move_cap = move_cap.min(v / slope[j]);
676            corner_cap = corner_cap.min(v / slope[j]);
677        }
678        if curvature[j] > 1e-300 {
679            move_cap = move_cap.min(libm::sqrt(a / (2.0 * curvature[j])));
680            corner_cap = corner_cap.min(libm::sqrt(a / curvature[j]));
681        }
682        if scurve && jmax.is_finite() && third[j] > 1e-300 {
683            move_cap = move_cap.min(libm::cbrt(jmax / (3.0 * third[j])));
684            corner_cap = corner_cap.min(libm::cbrt(jmax / third[j]));
685        }
686    }
687    (move_cap, corner_cap)
688}
689
690/// Pieces of a move next to a corner that are excluded from the move's
691/// constant speed limits and checked against the exact joint limits instead.
692///
693/// The C2 spline smooths the curvature jump between a straight line and a
694/// zone arc; its third derivative decays by roughly a factor of four per
695/// knot into the line. Taking those pieces into the move's worst-case bounds
696/// would cap the speed of the whole straight move, although the profile is
697/// still near the corner speed while crossing them.
698const TRANSITION_PIECES: usize = 4;
699
700/// Piece range of span `p` that sets its speed limits: the move without its
701/// transition pieces next to a corner (the whole span when that leaves
702/// nothing).
703fn interior(spans: &[Span], p: usize) -> (usize, usize) {
704    let span = spans[p];
705    if span.corner.is_some() {
706        return (span.start, span.end);
707    }
708    let mut lo = span.start;
709    let mut hi = span.end;
710    if p > 0 && spans[p - 1].corner.is_some() {
711        lo += TRANSITION_PIECES;
712    }
713    if p + 1 < spans.len() && spans[p + 1].corner.is_some() {
714        hi = hi.saturating_sub(TRANSITION_PIECES);
715    }
716    if lo < hi {
717        (lo, hi)
718    } else {
719        (span.start, span.end)
720    }
721}
722
723/// Sets the speed of the corner run containing span `p`.
724fn set_run_speed(spans: &mut [Span], p: usize, speed: f64) {
725    let mut first = p;
726    while first > 0 && spans[first - 1].corner.is_some() {
727        first -= 1;
728    }
729    let mut last = p;
730    while last + 1 < spans.len() && spans[last + 1].corner.is_some() {
731        last += 1;
732    }
733    for span in &mut spans[first..=last] {
734        span.speed = speed;
735    }
736}
737
738/// Boundary path speeds of span `p`: corner speed next to a corner, zero at stops.
739fn boundary_speeds(spans: &[Span], p: usize) -> (f64, f64) {
740    if spans[p].corner.is_some() {
741        return (spans[p].speed, spans[p].speed);
742    }
743    let v0 = if p > 0 && spans[p - 1].corner.is_some() {
744        spans[p - 1].speed
745    } else {
746        0.0
747    };
748    let v1 = if p + 1 < spans.len() && spans[p + 1].corner.is_some() {
749        spans[p + 1].speed
750    } else {
751        0.0
752    };
753    (v0, v1)
754}
755
756/// Path-parameter step between two knots: joint-space arc length weighted
757/// by the speed limits, so constant path speed means constant joint speed
758/// and curvature contributes no tangential acceleration.
759fn step<const N: usize>(
760    a: &[CuJointPosition; N],
761    b: &[CuJointPosition; N],
762    limits: &CuJointTrajectoryLimits<N>,
763) -> f64 {
764    let mut sum = 0.0_f64;
765    for j in 0..N {
766        if !matches!(a[j], CuJointPosition::Fixed) {
767            let d = (b[j].si_value() - a[j].si_value()) / limits.max_velocities[j].si_value();
768            sum += d * d;
769        }
770    }
771    libm::sqrt(sum)
772}
773
774/// Joint state of one sample: positions, velocities and accelerations.
775type JointState<const N: usize> = (
776    [CuJointPosition; N],
777    [CuJointVelocity; N],
778    [CuJointAcceleration; N],
779);
780
781/// Joint state from path derivatives: `v = q'·ds/dt`, `a = q''·(ds/dt)² + q'·d2s/dt2`.
782fn joint_state<const N: usize>(
783    q: [CuJointPosition; N],
784    slope: [f64; N],
785    curvature: [f64; N],
786    speed: f64,
787    acc: f64,
788) -> JointState<N> {
789    (
790        q,
791        core::array::from_fn(|j| CuJointVelocity::from_coordinate(q[j], slope[j] * speed)),
792        core::array::from_fn(|j| {
793            CuJointAcceleration::from_coordinate(
794                q[j],
795                curvature[j] * speed * speed + slope[j] * acc,
796            )
797        }),
798    )
799}
800
801/// Second derivatives `d2q/ds2` at the knots of the natural cubic spline
802/// through the path, solved with the Thomas algorithm on const-sized scratch.
803///
804/// Exact stops pin the curvature to zero, which decouples the path into
805/// rest-to-rest runs; path speed and acceleration vanish at a stop, so the
806/// curvature jump there never reaches the joint motion.
807fn spline_curvatures<const N: usize, const CAP: usize>(
808    points: &[CuCartesianPathPoint<N>],
809    s: &[f64; CAP],
810) -> [[f64; N]; CAP] {
811    let count = points.len();
812    let mut curvatures = [[0.0_f64; N]; CAP];
813    let mut upper = [0.0_f64; CAP];
814    let mut rhs = [0.0_f64; CAP];
815    for (j, _) in points[0].positions.iter().enumerate() {
816        let y = |i: usize| points[i].positions[j].si_value();
817        for i in 0..count {
818            if i == 0 || i + 1 == count || points[i].stop {
819                upper[i] = 0.0;
820                rhs[i] = 0.0;
821                continue;
822            }
823            let (h0, h1) = (s[i] - s[i - 1], s[i + 1] - s[i]);
824            let diagonal = 2.0 * (h0 + h1) - h0 * upper[i - 1];
825            let slopes = (y(i + 1) - y(i)) / h1 - (y(i) - y(i - 1)) / h0;
826            upper[i] = h1 / diagonal;
827            rhs[i] = (6.0 * slopes - h0 * rhs[i - 1]) / diagonal;
828        }
829        let mut next = 0.0;
830        for i in (0..count).rev() {
831            next = rhs[i] - upper[i] * next;
832            curvatures[i][j] = next;
833        }
834    }
835    curvatures
836}
837
838/// The C2 natural cubic spline `q(s)` through the path knots.
839struct Curve<'a, const N: usize, const CAP: usize> {
840    points: &'a [CuCartesianPathPoint<N>],
841    s: &'a [f64; CAP],
842    curvatures: &'a [[f64; N]; CAP],
843}
844
845impl<const N: usize, const CAP: usize> Curve<'_, N, CAP> {
846    /// Cubic of piece `i`, joint `j`, in the offset `t` from its start:
847    /// `(h, y0, b, m0, d)` with `q = y0 + b·t + m0·t²/2 + d·t³/6`.
848    fn coefficients(&self, i: usize, j: usize) -> (f64, f64, f64, f64, f64) {
849        let h = self.s[i + 1] - self.s[i];
850        let y0 = self.points[i].positions[j].si_value();
851        let y1 = self.points[i + 1].positions[j].si_value();
852        let (m0, m1) = (self.curvatures[i][j], self.curvatures[i + 1][j]);
853        (
854            h,
855            y0,
856            (y1 - y0) / h - h * (2.0 * m0 + m1) / 6.0,
857            m0,
858            (m1 - m0) / h,
859        )
860    }
861
862    /// Joint positions, `dq/ds` and `d2q/ds2` at `offset` along piece `i`.
863    fn eval(&self, i: usize, offset: f64) -> ([CuJointPosition; N], [f64; N], [f64; N]) {
864        let mut q = self.points[i].positions;
865        let mut slope = [0.0; N];
866        let mut curvature = [0.0; N];
867        for j in 0..N {
868            let (h, y0, b, m0, d) = self.coefficients(i, j);
869            let t = offset.clamp(0.0, h);
870            q[j] = q[j].with_si_value(y0 + t * (b + t * (0.5 * m0 + t * d / 6.0)));
871            slope[j] = b + t * (m0 + 0.5 * t * d);
872            curvature[j] = m0 + t * d;
873        }
874        (q, slope, curvature)
875    }
876
877    /// Per-joint peaks of `|dq/ds|`, `|d2q/ds2|` and `|d3q/ds3|` over piece `i`.
878    ///
879    /// The slope is quadratic in `s` (peak at an end or the vertex), the
880    /// curvature linear (peak at an end) and the third derivative constant,
881    /// so all three are exact.
882    fn bounds(&self, i: usize) -> ([f64; N], [f64; N], [f64; N]) {
883        let mut slope = [0.0; N];
884        let mut curvature = [0.0; N];
885        let mut jerk = [0.0; N];
886        for j in 0..N {
887            let (h, _, b, m0, d) = self.coefficients(i, j);
888            let at = |t: f64| b + t * (m0 + 0.5 * t * d);
889            let mut peak = libm::fabs(b).max(libm::fabs(at(h)));
890            if libm::fabs(d) > 1e-300 {
891                let t = -m0 / d;
892                if t > 0.0 && t < h {
893                    peak = peak.max(libm::fabs(at(t)));
894                }
895            }
896            slope[j] = peak;
897            curvature[j] = libm::fabs(m0).max(libm::fabs(m0 + h * d));
898            jerk[j] = libm::fabs(d);
899        }
900        (slope, curvature, jerk)
901    }
902}
903
904impl Motion {
905    /// Path offset, speed and acceleration at `t` seconds into the span.
906    fn eval(&self, length: f64, duration: f64, t: f64) -> (f64, f64, f64) {
907        let (offset, speed, acc, _) = self.eval_jerk(length, duration, t);
908        (offset, speed, acc)
909    }
910
911    /// Path offset, speed, acceleration and jerk at `t` seconds into the span.
912    fn eval_jerk(&self, length: f64, duration: f64, t: f64) -> (f64, f64, f64, f64) {
913        let tt = duration * duration;
914        match *self {
915            Motion::Constant { speed } => (speed * t, speed, 0.0, 0.0),
916            Motion::Cubic { v0, v1 } => {
917                let u = t / duration;
918                let (offset, speed) = cubic_eval(u, 0.0, length, v0, v1, duration);
919                let a = v0 * duration;
920                let b = 3.0 * length - 2.0 * a - v1 * duration;
921                let c = -2.0 * length + a + v1 * duration;
922                (
923                    offset,
924                    speed,
925                    (2.0 * b + 6.0 * c * u) / tt,
926                    6.0 * c / (tt * duration),
927                )
928            }
929            Motion::Quintic { v0, v1 } => {
930                let u = t / duration;
931                let (offset, speed) = quintic_eval(u, 0.0, length, v0, v1, duration);
932                let start = length - v0 * duration;
933                let end = (v1 - v0) * duration;
934                let a3 = 10.0 * start - 4.0 * end;
935                let a4 = -15.0 * start + 7.0 * end;
936                let a5 = 6.0 * start - 3.0 * end;
937                (
938                    offset,
939                    speed,
940                    u * (6.0 * a3 + u * (12.0 * a4 + 20.0 * a5 * u)) / tt,
941                    (6.0 * a3 + u * (24.0 * a4 + 60.0 * a5 * u)) / (tt * duration),
942                )
943            }
944            Motion::Phased { v0, phases } => {
945                let mut remaining = t;
946                let mut offset = 0.0;
947                let mut speed = v0;
948                let mut acc = 0.0;
949                let mut jerk = 0.0;
950                for phase in phases {
951                    let tau = remaining.min(phase.duration);
952                    offset += speed * tau
953                        + 0.5 * phase.acc * tau * tau
954                        + phase.jerk * tau * tau * tau / 6.0;
955                    speed += phase.acc * tau + 0.5 * phase.jerk * tau * tau;
956                    acc = phase.acc + phase.jerk * tau;
957                    jerk = phase.jerk;
958                    remaining -= tau;
959                    if remaining <= 0.0 {
960                        break;
961                    }
962                }
963                (offset, speed, acc, jerk)
964            }
965        }
966    }
967}
968
969/// Plans move `p` from boundary speeds `(v0, v1)` with its interior limits,
970/// then checks the exact joint limits on its transition pieces; `None` when
971/// either fails, which lowers the adjacent corner speed.
972fn plan_span<const N: usize, const CAP: usize>(
973    curve: &Curve<'_, N, CAP>,
974    s: &[f64; CAP],
975    spans: &[Span],
976    p: usize,
977    (v0, v1): (f64, f64),
978    config: &CuPathTimingParams<N>,
979) -> Option<(Motion, f64)> {
980    let span = spans[p];
981    let length = s[span.end] - s[span.start];
982    let (motion, duration) = plan(config.timing.profile, length, v0, v1, &span.limits)?;
983    let (lo, hi) = interior(spans, p);
984    if (lo, hi) == (span.start, span.end) {
985        return Some((motion, duration));
986    }
987    let limits = &config.timing.limits;
988    let jerk_limited = config.timing.profile == CuTrajectoryProfile::SCurve;
989    let mut piece = span.start;
990    // Dense samples of the planned motion; pieces inside the interior are
991    // already covered by the span limits.
992    const SAMPLES: usize = 512;
993    for k in 0..=SAMPLES {
994        let t = duration * k as f64 / SAMPLES as f64;
995        let (offset, speed, acc, jerk) = motion.eval_jerk(length, duration, t);
996        let at = s[span.start] + offset.clamp(0.0, length);
997        while piece + 1 < span.end && at > s[piece + 1] {
998            piece += 1;
999        }
1000        if piece >= lo && piece < hi {
1001            continue;
1002        }
1003        let (_, slope, curvature) = curve.eval(piece, at - s[piece]);
1004        for j in 0..N {
1005            let (_, _, _, _, third) = curve.coefficients(piece, j);
1006            let velocity = libm::fabs(slope[j] * speed);
1007            let acceleration = libm::fabs(curvature[j] * speed * speed + slope[j] * acc);
1008            let joint_jerk = libm::fabs(
1009                third * speed * speed * speed + 3.0 * curvature[j] * speed * acc + slope[j] * jerk,
1010            );
1011            let tolerance = 1.0 + 1e-9;
1012            if velocity > limits.max_velocities[j].si_value() * tolerance
1013                || acceleration > limits.acc(j) * tolerance
1014                || (jerk_limited && joint_jerk > limits.jerk(j) * tolerance)
1015            {
1016                return None;
1017            }
1018        }
1019    }
1020    Some((motion, duration))
1021}
1022
1023/// Plans one move of `length` from speed `v0` to `v1`; `None` if the
1024/// boundary speeds cannot be joined within `limits`.
1025fn plan(
1026    profile: CuTrajectoryProfile,
1027    length: f64,
1028    v0: f64,
1029    v1: f64,
1030    limits: &SpeedLimits,
1031) -> Option<(Motion, f64)> {
1032    match profile {
1033        CuTrajectoryProfile::Trapezoidal => plan_phased(length, v0, v1, limits, f64::INFINITY),
1034        CuTrajectoryProfile::SCurve => plan_phased(length, v0, v1, limits, limits.jerk),
1035        CuTrajectoryProfile::Cubic => {
1036            plan_polynomial(false, length, v0, v1, limits).map(|t| (Motion::Cubic { v0, v1 }, t))
1037        }
1038        CuTrajectoryProfile::MinimumJerk => {
1039            plan_polynomial(true, length, v0, v1, limits).map(|t| (Motion::Quintic { v0, v1 }, t))
1040        }
1041    }
1042}
1043
1044/// Symmetric velocity change by `dv >= 0`: `(jerk_time, accel_time, peak_acc)`.
1045/// Infinite jerk gives a constant-acceleration (trapezoidal) ramp.
1046fn ramp(dv: f64, acc: f64, jerk: f64) -> (f64, f64, f64) {
1047    if dv <= 0.0 {
1048        (0.0, 0.0, 0.0)
1049    } else if !jerk.is_finite() {
1050        (0.0, dv / acc, acc)
1051    } else if dv >= acc * acc / jerk {
1052        (acc / jerk, dv / acc - acc / jerk, acc)
1053    } else {
1054        let tj = libm::sqrt(dv / jerk);
1055        (tj, 0.0, jerk * tj)
1056    }
1057}
1058
1059/// Distance covered by a ramp between two speeds.
1060fn ramp_distance(from: f64, to: f64, acc: f64, jerk: f64) -> f64 {
1061    let (tj, ta, _) = ramp(libm::fabs(to - from), acc, jerk);
1062    0.5 * (from + to) * (2.0 * tj + ta)
1063}
1064
1065/// Trapezoidal (`jerk = inf`) or S-curve move: ramp to a peak speed, cruise,
1066/// ramp to the end speed.
1067fn plan_phased(
1068    length: f64,
1069    v0: f64,
1070    v1: f64,
1071    limits: &SpeedLimits,
1072    jerk: f64,
1073) -> Option<(Motion, f64)> {
1074    let acc = limits.acc;
1075    let distance =
1076        |peak: f64| ramp_distance(v0, peak, acc, jerk) + ramp_distance(peak, v1, acc, jerk);
1077    let low = v0.max(v1);
1078    if low > limits.vel * (1.0 + 1e-9) || distance(low) > length * (1.0 + 1e-12) {
1079        return None;
1080    }
1081    let peak = if distance(limits.vel) <= length {
1082        limits.vel
1083    } else {
1084        let (mut lo, mut hi) = (low, limits.vel);
1085        for _ in 0..80 {
1086            let mid = 0.5 * (lo + hi);
1087            if distance(mid) <= length {
1088                lo = mid;
1089            } else {
1090                hi = mid;
1091            }
1092        }
1093        lo
1094    };
1095    if peak <= 0.0 {
1096        return None;
1097    }
1098    let cruise = (length - distance(peak)).max(0.0) / peak;
1099    let (uj, ua, up) = ramp(peak - v0, acc, jerk);
1100    let (dj, da, dp) = ramp(peak - v1, acc, jerk);
1101    let jerk = if jerk.is_finite() { jerk } else { 0.0 };
1102    let phases = [
1103        Phase {
1104            duration: uj,
1105            jerk,
1106            acc: 0.0,
1107        },
1108        Phase {
1109            duration: ua,
1110            jerk: 0.0,
1111            acc: up,
1112        },
1113        Phase {
1114            duration: uj,
1115            jerk: -jerk,
1116            acc: up,
1117        },
1118        Phase {
1119            duration: cruise,
1120            jerk: 0.0,
1121            acc: 0.0,
1122        },
1123        Phase {
1124            duration: dj,
1125            jerk: -jerk,
1126            acc: 0.0,
1127        },
1128        Phase {
1129            duration: da,
1130            jerk: 0.0,
1131            acc: -dp,
1132        },
1133        Phase {
1134            duration: dj,
1135            jerk,
1136            acc: -dp,
1137        },
1138    ];
1139    let duration = 2.0 * uj + ua + cruise + 2.0 * dj + da;
1140    Some((Motion::Phased { v0, phases }, duration))
1141}
1142
1143/// Shortest duration for a cubic or quintic Hermite move whose speed stays
1144/// within `[0, vel]` and acceleration within `acc`.
1145fn plan_polynomial(
1146    quintic: bool,
1147    length: f64,
1148    v0: f64,
1149    v1: f64,
1150    limits: &SpeedLimits,
1151) -> Option<f64> {
1152    let fits = |t: f64| {
1153        let (high, low, acc) = polynomial_extrema(quintic, length, v0, v1, t);
1154        high <= limits.vel * (1.0 + 1e-9) && acc <= limits.acc * (1.0 + 1e-9) && low >= 0.0
1155    };
1156    let mut t = length / limits.vel;
1157    let mut found = None;
1158    for _ in 0..400 {
1159        if fits(t) {
1160            found = Some(t);
1161            break;
1162        }
1163        t *= 1.05;
1164    }
1165    let mut hi = found?;
1166    let mut lo = hi / 1.05;
1167    for _ in 0..50 {
1168        let mid = 0.5 * (lo + hi);
1169        if fits(mid) {
1170            hi = mid;
1171        } else {
1172            lo = mid;
1173        }
1174    }
1175    Some(hi)
1176}
1177
1178#[cfg(test)]
1179mod tests {
1180    use super::*;
1181    use crate::CuCartesianBlendParams;
1182    use crate::CuCartesianBlendStatus;
1183    use crate::CuCartesianWaypoint;
1184    use crate::CuTcpZone;
1185    use crate::blend_cartesian_path;
1186    use crate::test_support::limits;
1187    use crate::test_support::obstacle;
1188    use crate::test_support::q;
1189    use crate::test_support::robot;
1190    use crate::test_support::waypoints;
1191    use cu29::units::si::f64::Length;
1192    use cu29::units::si::length::meter;
1193
1194    const PROFILES: [CuTrajectoryProfile; 4] = [
1195        CuTrajectoryProfile::Trapezoidal,
1196        CuTrajectoryProfile::SCurve,
1197        CuTrajectoryProfile::MinimumJerk,
1198        CuTrajectoryProfile::Cubic,
1199    ];
1200
1201    fn timing(profile: CuTrajectoryProfile) -> CuPathTimingParams<3> {
1202        let jerk = CuJointJerk::from_coordinate(q(0.0, 0.0)[0], 4.0);
1203        CuPathTimingParams {
1204            timing: CuTimingParams {
1205                limits: limits().with_jerks([jerk; 3]),
1206                profile,
1207                sample_period: Time::new::<second>(0.02),
1208            },
1209            collision_step: CuCartesianBlendParams::default().collision_step,
1210        }
1211    }
1212
1213    fn timed<const K: usize>(
1214        points: &[[CuJointPosition; 3]; K],
1215        zones: [f64; K],
1216        profile: CuTrajectoryProfile,
1217    ) -> (CuTimingStatus, CuTrajectoryPayload<3, 512>) {
1218        let (chain, model) = robot();
1219        let env = CuCollisionEnv::EMPTY;
1220        let mut path = CuCartesianBlendPath::<3, 256>::default();
1221        assert_eq!(
1222            blend_cartesian_path(
1223                &chain,
1224                &model,
1225                &env,
1226                &waypoints(points, zones),
1227                &CuCartesianBlendParams::default(),
1228                &mut path
1229            )
1230            .unwrap(),
1231            CuCartesianBlendStatus::Success
1232        );
1233        let mut out = CuTrajectoryPayload::<3, 512>::new();
1234        let status =
1235            parameterize_cartesian_path(&chain, &model, &env, &path, &timing(profile), &mut out)
1236                .unwrap();
1237        (status, out)
1238    }
1239
1240    fn duration(out: &CuTrajectoryPayload<3, 512>) -> f64 {
1241        out.points().last().unwrap().time_from_start.get::<second>()
1242    }
1243
1244    fn at_rest(p: &CuTrajectoryPoint<3>, x: f64, y: f64) -> bool {
1245        (p.positions[0].si_value() - x).abs() < 1e-9
1246            && (p.positions[1].si_value() - y).abs() < 1e-9
1247            && p.velocities.iter().all(|v| v.si_value().abs() < 1e-9)
1248    }
1249
1250    /// Sampled velocity and finite-difference acceleration stay within limits.
1251    fn assert_within_limits(out: &CuTrajectoryPayload<3, 512>) {
1252        for w in out.points().windows(2) {
1253            let dt = (w[1].time_from_start - w[0].time_from_start).get::<second>();
1254            assert!(dt > 0.0, "times must be strictly increasing");
1255            for j in 0..3 {
1256                let v = w[1].velocities[j].si_value();
1257                assert!(v.abs() <= 0.4 * (1.0 + 1e-6), "velocity {v}");
1258                let a = (v - w[0].velocities[j].si_value()) / dt;
1259                assert!(a.abs() <= 1.0 * (1.0 + 1e-3), "acceleration {a}");
1260            }
1261        }
1262    }
1263
1264    #[test]
1265    fn every_profile_flies_through_a_positive_zone() {
1266        let points = [q(0.0, 0.0), q(0.3, 0.0), q(0.3, 0.3)];
1267        for profile in PROFILES {
1268            let (status, out) = timed(&points, [0.0, 0.1, 0.0], profile);
1269            assert_eq!(status, CuTimingStatus::Success, "{profile:?}");
1270            assert_within_limits(&out);
1271            assert!(at_rest(&out.points()[0], 0.0, 0.0));
1272            assert!(at_rest(out.points().last().unwrap(), 0.3, 0.3));
1273            let interior = &out.points()[1..out.len() - 1];
1274            assert!(
1275                interior
1276                    .iter()
1277                    .all(|p| p.velocities.iter().any(|v| v.si_value().abs() > 1e-6)),
1278                "{profile:?} must not stop before the path end"
1279            );
1280        }
1281    }
1282
1283    #[test]
1284    fn every_profile_stops_exactly_at_a_zero_zone() {
1285        let points = [q(0.0, 0.0), q(0.3, 0.0), q(0.3, 0.3)];
1286        for profile in PROFILES {
1287            let (status, out) = timed(&points, [0.0; 3], profile);
1288            assert_eq!(status, CuTimingStatus::Success, "{profile:?}");
1289            assert_within_limits(&out);
1290            assert!(out.points().iter().any(|p| at_rest(p, 0.3, 0.0)));
1291        }
1292    }
1293
1294    #[test]
1295    fn flying_through_a_zone_is_faster_than_stopping() {
1296        let points = [q(0.0, 0.0), q(0.3, 0.0), q(0.3, 0.3)];
1297        for profile in PROFILES {
1298            let (_, blended) = timed(&points, [0.0, 0.1, 0.0], profile);
1299            let (_, stopped) = timed(&points, [0.0; 3], profile);
1300            assert!(
1301                duration(&blended) < duration(&stopped),
1302                "{profile:?}: {} >= {}",
1303                duration(&blended),
1304                duration(&stopped)
1305            );
1306        }
1307    }
1308
1309    #[test]
1310    fn zones_are_per_waypoint_on_one_path() {
1311        let points = [q(0.0, 0.0), q(0.3, 0.0), q(0.3, 0.3), q(0.0, 0.3)];
1312        for profile in PROFILES {
1313            let (status, out) = timed(&points, [0.0, 0.1, 0.0, 0.0], profile);
1314            assert_eq!(status, CuTimingStatus::Success, "{profile:?}");
1315            assert_within_limits(&out);
1316            assert!(!out.points().iter().any(|p| at_rest(p, 0.3, 0.0)));
1317            assert!(out.points().iter().any(|p| at_rest(p, 0.3, 0.3)));
1318            assert!(at_rest(out.points().last().unwrap(), 0.0, 0.3));
1319        }
1320    }
1321
1322    #[test]
1323    fn sample_cubic_is_continuous_across_stored_points() {
1324        let points = [q(0.0, 0.0), q(0.3, 0.0), q(0.3, 0.3)];
1325        let (status, out) = timed(&points, [0.0, 0.1, 0.0], CuTrajectoryProfile::Cubic);
1326        assert_eq!(status, CuTimingStatus::Success);
1327        for p in &out.points()[1..out.len() - 1] {
1328            let t = p.time_from_start.get::<second>();
1329            let mut before = (q(0.0, 0.0), [CuJointVelocity::default(); 3]);
1330            let mut after = before;
1331            out.sample_cubic(Time::new::<second>(t - 1e-7), &mut before.0, &mut before.1)
1332                .unwrap();
1333            out.sample_cubic(Time::new::<second>(t + 1e-7), &mut after.0, &mut after.1)
1334                .unwrap();
1335            for j in 0..3 {
1336                assert!((before.0[j].si_value() - after.0[j].si_value()).abs() < 1e-5);
1337                assert!((before.1[j].si_value() - after.1[j].si_value()).abs() < 1e-4);
1338            }
1339        }
1340    }
1341
1342    #[test]
1343    fn scurve_path_acceleration_is_continuous() {
1344        let points = [q(0.0, 0.0), q(0.3, 0.0)];
1345        let (status, out) = timed(&points, [0.0, 0.0], CuTrajectoryProfile::SCurve);
1346        assert_eq!(status, CuTimingStatus::Success);
1347        // Straight line: joint 0 carries the path motion, so the change of
1348        // its stored acceleration between knots is bounded by the jerk limit.
1349        for w in out.points().windows(2) {
1350            let dt = (w[1].time_from_start - w[0].time_from_start).get::<second>();
1351            let jerk = (w[1].accelerations[0].si_value() - w[0].accelerations[0].si_value()) / dt;
1352            assert!(jerk.abs() <= 4.0 * (1.0 + 1e-6), "jerk {jerk}");
1353        }
1354    }
1355
1356    #[test]
1357    fn interpolated_path_is_rechecked_for_collisions() {
1358        let (chain, model) = robot();
1359        let points = [q(0.0, 0.0), q(0.3, 0.0), q(0.3, 0.3)];
1360        let mut path = CuCartesianBlendPath::<3, 256>::default();
1361        blend_cartesian_path(
1362            &chain,
1363            &model,
1364            &CuCollisionEnv::EMPTY,
1365            &waypoints(&points, [0.0, 0.1, 0.0]),
1366            &CuCartesianBlendParams::default(),
1367            &mut path,
1368        )
1369        .unwrap();
1370        let obstacles = [obstacle()];
1371        let env = CuCollisionEnv::new(&obstacles);
1372        let mut out = CuTrajectoryPayload::<3, 512>::new();
1373        let status = parameterize_cartesian_path(
1374            &chain,
1375            &model,
1376            &env,
1377            &path,
1378            &timing(CuTrajectoryProfile::Cubic),
1379            &mut out,
1380        )
1381        .unwrap();
1382        assert!(matches!(status, CuTimingStatus::Collision { .. }));
1383        assert!(out.is_empty());
1384    }
1385
1386    #[test]
1387    fn reversal_inside_a_zone_cannot_keep_moving() {
1388        let (chain, model) = robot();
1389        let env = CuCollisionEnv::EMPTY;
1390        let zone = CuTcpZone {
1391            waypoint: 1,
1392            center: [0.3, 0.0, 0.0].map(Length::new::<meter>),
1393            radius: Length::new::<meter>(0.1),
1394        };
1395        let mut path = CuCartesianBlendPath::<3, 8>::default();
1396        path.points[0] = CuCartesianPathPoint {
1397            positions: q(0.0, 0.0),
1398            stop: true,
1399            zone: None,
1400        };
1401        path.points[1] = CuCartesianPathPoint {
1402            positions: q(0.3, 0.0),
1403            stop: false,
1404            zone: Some(zone),
1405        };
1406        path.points[2] = CuCartesianPathPoint {
1407            positions: q(0.0, 0.0),
1408            stop: true,
1409            zone: None,
1410        };
1411        path.len = 3;
1412        let mut out = CuTrajectoryPayload::<3, 512>::new();
1413        let status = parameterize_cartesian_path(
1414            &chain,
1415            &model,
1416            &env,
1417            &path,
1418            &timing(CuTrajectoryProfile::Trapezoidal),
1419            &mut out,
1420        )
1421        .unwrap();
1422        assert_eq!(status, CuTimingStatus::NoContinuousMotion { waypoint: 1 });
1423        assert!(out.is_empty());
1424    }
1425
1426    #[test]
1427    fn invalid_period_and_small_capacity_leave_no_output() {
1428        let (chain, model) = robot();
1429        let env = CuCollisionEnv::EMPTY;
1430        let points = [q(0.0, 0.0), q(0.3, 0.0), q(0.3, 0.3)];
1431        let mut path = CuCartesianBlendPath::<3, 256>::default();
1432        blend_cartesian_path(
1433            &chain,
1434            &model,
1435            &env,
1436            &waypoints(&points, [0.0, 0.1, 0.0]),
1437            &CuCartesianBlendParams::default(),
1438            &mut path,
1439        )
1440        .unwrap();
1441        let mut config = timing(CuTrajectoryProfile::Cubic);
1442        config.timing.sample_period = Time::new::<second>(0.0);
1443        let mut out = CuTrajectoryPayload::<3, 512>::new();
1444        assert_eq!(
1445            parameterize_cartesian_path(&chain, &model, &env, &path, &config, &mut out).unwrap(),
1446            CuTimingStatus::InvalidInput
1447        );
1448        assert!(out.is_empty());
1449        let mut small = CuTrajectoryPayload::<3, 8>::new();
1450        assert_eq!(
1451            parameterize_cartesian_path(
1452                &chain,
1453                &model,
1454                &env,
1455                &path,
1456                &timing(CuTrajectoryProfile::Cubic),
1457                &mut small
1458            )
1459            .unwrap(),
1460            CuTimingStatus::CapacityExceeded
1461        );
1462        assert!(small.is_empty());
1463    }
1464
1465    #[test]
1466    fn rebel_flyby_is_timed_with_every_profile() {
1467        use cu29::units::si::angle::radian;
1468        use cu29::units::si::angular_velocity::radian_per_second;
1469        use cu29::units::si::f64::Angle;
1470        use cu29::units::si::f64::AngularVelocity;
1471        let chain = repose_kinematics::igus_rebel_chain();
1472        let model = repose_collision::igus_rebel_collision_model();
1473        let env = CuCollisionEnv::EMPTY;
1474        let to_q =
1475            |j1: f64| [j1, 0.75, -0.25, 0.0, -0.3, 0.0].map(|v| Angle::new::<radian>(v).into());
1476        let points = [to_q(-0.75), to_q(0.0), to_q(0.75)];
1477        let zones = [0.0, 0.03, 0.0].map(Length::new::<meter>);
1478        let waypoints: [CuCartesianWaypoint<6>; 3] =
1479            core::array::from_fn(|i| CuCartesianWaypoint::new(points[i], zones[i]));
1480        let mut blend = CuCartesianBlendParams::default();
1481        blend.ik.max_iterations = 256;
1482        let mut path = CuCartesianBlendPath::<6, 256>::default();
1483        assert_eq!(
1484            blend_cartesian_path(&chain, &model, &env, &waypoints, &blend, &mut path).unwrap(),
1485            CuCartesianBlendStatus::Success
1486        );
1487        let velocity = CuJointVelocity::Revolute(AngularVelocity::new::<radian_per_second>(1.0));
1488        for profile in PROFILES {
1489            let rev = points[0][0];
1490            let config = CuPathTimingParams {
1491                timing: CuTimingParams {
1492                    limits: CuJointTrajectoryLimits::new(
1493                        [velocity; 6],
1494                        [CuJointAcceleration::from_coordinate(rev, 2.0); 6],
1495                    )
1496                    .with_jerks([CuJointJerk::from_coordinate(rev, 10.0); 6]),
1497                    profile,
1498                    sample_period: Time::new::<second>(0.02),
1499                },
1500                collision_step: blend.collision_step,
1501            };
1502            let mut out = CuTrajectoryPayload::<6, 1024>::new();
1503            assert_eq!(
1504                parameterize_cartesian_path(&chain, &model, &env, &path, &config, &mut out)
1505                    .unwrap(),
1506                CuTimingStatus::Success,
1507                "{profile:?}"
1508            );
1509        }
1510    }
1511
1512    #[test]
1513    fn output_interpolation_matches_the_validated_reconstruction() {
1514        let points = [q(0.0, 0.0), q(0.3, 0.0)];
1515        for profile in PROFILES {
1516            let (status, out) = timed(&points, [0.0, 0.0], profile);
1517            assert_eq!(status, CuTimingStatus::Success);
1518            let expected = if profile == CuTrajectoryProfile::SCurve {
1519                CuTrajectoryInterpolation::Quintic
1520            } else {
1521                CuTrajectoryInterpolation::Cubic
1522            };
1523            assert_eq!(out.interpolation, expected, "{profile:?}");
1524        }
1525    }
1526}