Skip to main content

repose_trajectory/
parameterize.rs

1//! Synchronized time-parameterization for joint-space paths.
2//!
3//! [`parameterize`] converts an untimed waypoint sequence into a timestamped
4//! [`CuTrajectoryPayload`] with a selectable [`CuTrajectoryProfile`] family
5//! (trapezoidal, jerk-limited S-curve, minimum-jerk quintic, cubic Hermite)
6//! under the per-joint [`CuJointTrajectoryLimits`] of a [`CuTimingParams`].
7//! Per-call [`CuTimingOverrides`] fix segment durations and waypoint
8//! velocities; the default overrides give synchronized rest-to-rest timing.
9
10use bincode::Decode;
11use bincode::Encode;
12use cu29::prelude::*;
13use cu29::units::si::f64::Time;
14use cu29::units::si::time::second;
15use repose_kinematics::CuJointAcceleration;
16use repose_kinematics::CuJointJerk;
17use repose_kinematics::CuJointPosition;
18use repose_kinematics::CuJointVelocity;
19use serde::Deserialize;
20use serde::Serialize;
21
22use crate::CuTrajectoryInterpolation;
23use crate::CuTrajectoryPayload;
24use crate::CuTrajectoryPoint;
25use crate::quintic::polynomial_extrema;
26
27/// Per-joint speed, acceleration, and jerk limits for time-parameterization.
28///
29/// Every entry uses the typed joint representation, so revolute (`rad/s`,
30/// `rad/s^2`, `rad/s^3`), prismatic (`m/s`, `m/s^2`, `m/s^3`), and fixed joints
31/// each carry their own unit; the entry kind must match the joint kind of the
32/// parameterized waypoints. `max_jerks` is only used by
33/// [`CuTrajectoryProfile::SCurve`]; `None` (the default, and an omitted RON
34/// key) means "no jerk bound" and lets the S-curve degrade gracefully to
35/// trapezoidal timing and shape. A single `f64::INFINITY` entry leaves that
36/// joint unbounded.
37///
38/// ```ron
39/// {
40///     "max_velocities": [{"revolute": 0.8}, {"prismatic": 0.2}],
41///     "max_accelerations": [{"revolute": 2.0}, {"prismatic": 1.0}],
42///     "max_jerks": [{"revolute": 10.0}, {"prismatic": 5.0}], // optional
43/// }
44/// ```
45#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Encode, Decode, Reflect)]
46#[reflect(no_field_bounds, from_reflect = false)]
47#[serde(deny_unknown_fields)]
48pub struct CuJointTrajectoryLimits<const N: usize> {
49    #[serde(with = "repose_kinematics::serde_arrays::velocity_array")]
50    pub max_velocities: [CuJointVelocity; N],
51    #[serde(with = "repose_kinematics::serde_arrays::acceleration_array")]
52    pub max_accelerations: [CuJointAcceleration; N],
53    /// Per-joint jerk bounds; `None` means unbounded.
54    #[serde(default, with = "crate::serde_arrays::option_jerk_array")]
55    pub max_jerks: Option<[CuJointJerk; N]>,
56}
57
58impl<const N: usize> Default for CuJointTrajectoryLimits<N> {
59    /// Zero limits: a placeholder that [`CuJointTrajectoryLimits::validate`]
60    /// rejects, since limits have no sensible default.
61    fn default() -> Self {
62        Self::new(
63            [CuJointVelocity::default(); N],
64            [CuJointAcceleration::default(); N],
65        )
66    }
67}
68
69impl<const N: usize> CuJointTrajectoryLimits<N> {
70    /// Creates limits from per-joint typed velocities and accelerations with
71    /// no jerk bound; add one with [`CuJointTrajectoryLimits::with_jerks`].
72    pub const fn new(
73        max_velocities: [CuJointVelocity; N],
74        max_accelerations: [CuJointAcceleration; N],
75    ) -> Self {
76        Self {
77            max_velocities,
78            max_accelerations,
79            max_jerks: None,
80        }
81    }
82
83    /// Sets per-joint jerk bounds. Each entry must be positive (finite or
84    /// `f64::INFINITY`) for revolute and prismatic joints; fixed-joint entries
85    /// are ignored.
86    pub const fn with_jerks(mut self, max_jerks: [CuJointJerk; N]) -> Self {
87        self.max_jerks = Some(max_jerks);
88        self
89    }
90
91    /// SI acceleration bound of joint `j`.
92    pub(crate) fn acc(&self, j: usize) -> f64 {
93        self.max_accelerations[j].si_value()
94    }
95
96    /// SI jerk bound of joint `j`; `f64::INFINITY` when unbounded.
97    pub(crate) fn jerk(&self, j: usize) -> f64 {
98        match self.max_jerks {
99            Some(jerks) => match jerks[j] {
100                CuJointJerk::Fixed => f64::INFINITY,
101                jerk => jerk.si_value(),
102            },
103            None => f64::INFINITY,
104        }
105    }
106
107    /// Checks entry kinds against each other and that every non-fixed joint
108    /// has a positive finite velocity and acceleration bound and, when given,
109    /// a positive (possibly infinite) jerk bound. The joint kind is taken from
110    /// `max_velocities`.
111    pub fn validate(&self) -> CuResult<()> {
112        for j in 0..N {
113            let vel = self.max_velocities[j];
114            let acc = self.max_accelerations[j];
115            let jerk = self.max_jerks.map(|jerks| jerks[j]);
116            let kinds_agree = match vel {
117                CuJointVelocity::Revolute(_) => {
118                    matches!(acc, CuJointAcceleration::Revolute(_))
119                        && jerk.is_none_or(|k| matches!(k, CuJointJerk::Revolute(_)))
120                }
121                CuJointVelocity::Prismatic(_) => {
122                    matches!(acc, CuJointAcceleration::Prismatic(_))
123                        && jerk.is_none_or(|k| matches!(k, CuJointJerk::Prismatic(_)))
124                }
125                CuJointVelocity::Fixed => true,
126            };
127            if !kinds_agree {
128                return Err(CuError::from(
129                    "Trajectory limit kinds disagree across velocity, acceleration and jerk",
130                ));
131            }
132            if matches!(vel, CuJointVelocity::Fixed) {
133                continue;
134            }
135            let (v, a) = (vel.si_value(), acc.si_value());
136            if !v.is_finite() || v <= 0.0 || !a.is_finite() || a <= 0.0 {
137                return Err(CuError::from(
138                    "Trajectory velocity and acceleration limits must be positive and finite",
139                ));
140            }
141            let k = self.jerk(j);
142            if k.is_nan() || k <= 0.0 {
143                return Err(CuError::from("Trajectory jerk limits must be positive"));
144            }
145        }
146        Ok(())
147    }
148}
149
150/// Closed set of time-parameterization profile families.
151///
152/// Deserialized from snake-case RON (`trapezoidal`, `s_curve`,
153/// `minimum_jerk`, `cubic`) with no stringly-typed dispatch.
154#[derive(
155    Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Encode, Decode, Reflect,
156)]
157#[serde(rename_all = "snake_case")]
158pub enum CuTrajectoryProfile {
159    /// Synchronized trapezoidal (or triangular) rest-to-rest profile per
160    /// segment.
161    #[default]
162    Trapezoidal,
163    /// Jerk-limited symmetric 7-segment S-curve rest-to-rest per segment,
164    /// synchronized across joints by time-scaling (velocity scales by
165    /// `s <= 1`, acceleration by `s^2`, jerk by `s^3`, so the scaled profile
166    /// still honors every bound). Infinite jerk entries reproduce the
167    /// trapezoidal shape and timing exactly.
168    SCurve,
169    /// Quintic minimum-jerk polynomial per segment per joint with zero
170    /// endpoint accelerations, honoring the specified waypoint velocities.
171    MinimumJerk,
172    /// Cubic Hermite polynomial per segment per joint honoring the specified
173    /// waypoint velocities exactly.
174    Cubic,
175}
176
177fn default_sample_period() -> Time {
178    Time::new::<second>(0.02)
179}
180
181/// Timing parameters shared by [`parameterize`] and
182/// [`parameterize_cartesian_path`](crate::parameterize_cartesian_path).
183///
184/// `limits` is required (it has no sensible default); `profile` defaults to
185/// trapezoidal and `sample_period` to 0.02 s.
186///
187/// ```ron
188/// {
189///     "limits": {
190///         "max_velocities": [{"revolute": 0.8}],
191///         "max_accelerations": [{"revolute": 2.0}],
192///     },
193///     "profile": "s_curve",
194///     "sample_period": 0.01,
195/// }
196/// ```
197#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Reflect)]
198#[reflect(no_field_bounds, from_reflect = false)]
199// Field-level defaults: serde does not support container `default` on const-generic structs.
200#[serde(deny_unknown_fields)]
201pub struct CuTimingParams<const N: usize> {
202    /// Per-joint velocity, acceleration and optional jerk limits.
203    pub limits: CuJointTrajectoryLimits<N>,
204    /// Profile family of every segment (joint space) or move (Cartesian path).
205    #[serde(default)]
206    pub profile: CuTrajectoryProfile,
207    /// Period at which the timed motion is resampled into the payload.
208    #[serde(default = "default_sample_period")]
209    pub sample_period: Time,
210}
211
212impl<const N: usize> Default for CuTimingParams<N> {
213    fn default() -> Self {
214        Self {
215            limits: CuJointTrajectoryLimits::default(),
216            profile: CuTrajectoryProfile::default(),
217            sample_period: default_sample_period(),
218        }
219    }
220}
221
222impl<const N: usize> CuTimingParams<N> {
223    /// Checks the limits and that `sample_period` is positive and finite.
224    pub fn validate(&self) -> CuResult<()> {
225        self.limits.validate()?;
226        let dt = self.sample_period.get::<second>();
227        if !dt.is_finite() || dt <= 0.0 {
228            return Err(CuError::from(
229                "Trajectory sample period must be positive and finite",
230            ));
231        }
232        Ok(())
233    }
234}
235
236/// Per-call segment timing and waypoint velocities for [`parameterize`].
237///
238/// `segment_durations`, when present, must hold exactly
239/// `waypoints.len() - 1` positive finite durations; the trajectory total is
240/// their exact sum. `waypoint_velocities`, when present, must hold exactly
241/// `waypoints.len()` entries aligned with the waypoints (endpoints
242/// included); `None` means zero velocity at every waypoint. The default is
243/// synchronized rest-to-rest timing.
244#[derive(Default, Debug, Clone, Copy)]
245pub struct CuTimingOverrides<'a, const N: usize> {
246    pub segment_durations: Option<&'a [Time]>,
247    pub waypoint_velocities: Option<&'a [[CuJointVelocity; N]]>,
248}
249
250/// Outcome of [`parameterize`] and
251/// [`parameterize_cartesian_path`](crate::parameterize_cartesian_path).
252///
253/// The Cartesian path leaves its output empty unless `Success`.
254#[derive(
255    Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Encode, Decode, Reflect,
256)]
257#[serde(rename_all = "snake_case")]
258pub enum CuTimingStatus {
259    /// Trajectory written to `out`, ending at the final waypoint.
260    Success,
261    /// No waypoints were provided; `out` is left empty.
262    #[default]
263    EmptyPath,
264    /// A limit, a fixed segment duration, a waypoint velocity, the sample
265    /// period, or the path is non-positive, non-finite, mismatched to the
266    /// joint kinds, or inconsistent with the profile (non-zero waypoint
267    /// velocity under a rest-to-rest profile, fixed duration below the
268    /// synchronized minimum, segment velocity above a limit, wrong override
269    /// slice length, path outside joint limits, or unreachable speeds).
270    InvalidInput,
271    /// `out` filled before the resampled trajectory completed.
272    CapacityExceeded,
273    /// The interpolated Cartesian path collides between knots `sample` and
274    /// `sample + 1`.
275    Collision { sample: usize },
276    /// The interpolated Cartesian path leaves this waypoint's TCP zone.
277    ZoneExceeded { waypoint: usize },
278    /// The Cartesian path cannot keep moving through this waypoint's zone.
279    NoContinuousMotion { waypoint: usize },
280    /// Every joint reverses at this non-stop Cartesian path knot outside any
281    /// zone.
282    UnexpectedStop { sample: usize },
283}
284
285impl CuTimingStatus {
286    /// Returns `true` when the trajectory was fully parameterized.
287    pub const fn is_success(&self) -> bool {
288        matches!(self, Self::Success)
289    }
290}
291
292/// Times a joint-space waypoint path, writing timestamped points into `out`
293/// in place without allocating.
294///
295/// Only fixed-size stack buffers are used; nothing is heap-allocated. `out`
296/// gets [`CuTrajectoryInterpolation::Cubic`]: the stored points carry exact
297/// positions and velocities (accelerations are stored as zero), and the
298/// velocity limits of the polynomial profiles are certified on the cubic
299/// Hermite segments.
300///
301/// # Profiles
302///
303/// - `Trapezoidal`: each segment duration is the maximum over joints of the
304///   trapezoidal (triangular when the displacement is too short to reach full
305///   speed) time under the velocity/acceleration limits, so every joint has
306///   zero velocity at each waypoint. Requires zero waypoint velocities (any
307///   non-zero entry is [`CuTimingStatus::InvalidInput`]). A fixed segment
308///   duration must be at least the synchronized minimum for that segment
309///   (else `InvalidInput`); the profile is stretched onto it exactly, lowering
310///   peak velocity accordingly.
311/// - `SCurve`: jerk-limited symmetric 7-segment rest-to-rest S-curve per
312///   segment, synchronized across joints. Requires zero waypoint velocities.
313///   The segment duration derives from the velocity, acceleration, and jerk
314///   limits (infinite jerk reproduces trapezoidal timing); a fixed duration
315///   at least the minimum is honored exactly (else `InvalidInput`).
316/// - `MinimumJerk`: quintic minimum-jerk polynomial per segment per joint
317///   with zero endpoint accelerations, honoring the specified waypoint
318///   velocities. Duration: fixed when given, otherwise derived so zero-via
319///   peak velocity (`|v| <= 1.875 * distance / duration`) and acceleration
320///   (`|a| <= (10 / sqrt(3)) * distance / duration^2`) satisfy the limits;
321///   with any non-zero waypoint velocity the derived duration falls back to
322///   the trapezoidal minimum. The exact velocity extrema of every segment,
323///   waypoint velocities included, are validated against the limits
324///   (exceeding is `InvalidInput`).
325/// - `Cubic`: cubic Hermite polynomial per segment per joint honoring the
326///   specified waypoint velocities exactly. Duration: fixed when given,
327///   otherwise the trapezoidal minimum; the exact velocity extrema of every
328///   segment, waypoint velocities included, are validated against the limits
329///   (exceeding is `InvalidInput`).
330///
331/// # Guarantees (all profiles)
332///
333/// The first point is at `t = 0` at `waypoints[0]` with its specified
334/// velocity, the last point is exactly at the final waypoint with its
335/// specified velocity, times are strictly increasing, and the total duration
336/// equals the sum of the segment durations. Points are resampled at
337/// `params.sample_period`. An empty path yields `EmptyPath`, a single
338/// waypoint yields one point (`Success`), and a full buffer yields
339/// `CapacityExceeded`.
340///
341/// # Example
342///
343/// ```rust
344/// use repose_kinematics::CuJointAcceleration;
345/// use repose_kinematics::CuJointPosition;
346/// use repose_kinematics::CuJointVelocity;
347/// use repose_trajectory::CuJointTrajectoryLimits;
348/// use repose_trajectory::CuTimingOverrides;
349/// use repose_trajectory::CuTimingParams;
350/// use repose_trajectory::CuTimingStatus;
351/// use repose_trajectory::CuTrajectoryPayload;
352/// use repose_trajectory::parameterize;
353/// use cu29::units::si::angle::radian;
354/// use cu29::units::si::angular_acceleration::radian_per_second_squared;
355/// use cu29::units::si::angular_velocity::radian_per_second;
356/// use cu29::units::si::f64::Angle;
357/// use cu29::units::si::f64::AngularAcceleration;
358/// use cu29::units::si::f64::AngularVelocity;
359/// use cu29::units::si::f64::Time;
360/// use cu29::units::si::time::second;
361///
362/// let waypoints = [
363///     [CuJointPosition::Revolute(Angle::new::<radian>(0.0))],
364///     [CuJointPosition::Revolute(Angle::new::<radian>(1.0))],
365/// ];
366/// let params = CuTimingParams {
367///     limits: CuJointTrajectoryLimits::new(
368///         [CuJointVelocity::Revolute(AngularVelocity::new::<radian_per_second>(0.5))],
369///         [CuJointAcceleration::Revolute(
370///             AngularAcceleration::new::<radian_per_second_squared>(1.0),
371///         )],
372///     ),
373///     sample_period: Time::new::<second>(0.02),
374///     ..Default::default()
375/// };
376/// let mut out = CuTrajectoryPayload::<1, 512>::new();
377/// let status = parameterize(&waypoints, &params, CuTimingOverrides::default(), &mut out).unwrap();
378/// assert_eq!(status, CuTimingStatus::Success);
379/// ```
380pub fn parameterize<const N: usize, const MAX_POINTS: usize>(
381    waypoints: &[[CuJointPosition; N]],
382    params: &CuTimingParams<N>,
383    overrides: CuTimingOverrides<'_, N>,
384    out: &mut CuTrajectoryPayload<N, MAX_POINTS>,
385) -> CuResult<CuTimingStatus> {
386    out.clear();
387    out.interpolation = CuTrajectoryInterpolation::Cubic;
388    let limits = &params.limits;
389    let profile = params.profile;
390    let timing = &overrides;
391    if waypoints.is_empty() {
392        return Ok(CuTimingStatus::EmptyPath);
393    }
394    let dt = params.sample_period.get::<second>();
395    if !dt.is_finite() || dt <= 0.0 {
396        return Ok(CuTimingStatus::InvalidInput);
397    }
398    validate_waypoints(waypoints)?;
399    if !limits_valid(waypoints[0], limits) {
400        return Ok(CuTimingStatus::InvalidInput);
401    }
402    if profile == CuTrajectoryProfile::SCurve && !jerks_valid(waypoints[0], limits) {
403        return Ok(CuTimingStatus::InvalidInput);
404    }
405    let last = waypoints.len() - 1;
406    if let Some(durations) = timing.segment_durations {
407        if durations.len() != last {
408            return Ok(CuTimingStatus::InvalidInput);
409        }
410        let mut s = 0;
411        while s < last {
412            let span = durations[s].get::<second>();
413            if !span.is_finite() || span <= 0.0 {
414                return Ok(CuTimingStatus::InvalidInput);
415            }
416            s += 1;
417        }
418    }
419    if timing.waypoint_velocities.is_some_and(|vias| {
420        vias.len() != waypoints.len()
421            || !vias_valid(waypoints, vias)
422            || !vias_within_limits(waypoints, vias, limits)
423    }) {
424        return Ok(CuTimingStatus::InvalidInput);
425    }
426    let rest_only = matches!(
427        profile,
428        CuTrajectoryProfile::Trapezoidal | CuTrajectoryProfile::SCurve
429    );
430    let all_zero_via = !vias_nonzero(timing);
431    if rest_only && !all_zero_via {
432        return Ok(CuTimingStatus::InvalidInput);
433    }
434    if waypoints.len() == 1 {
435        let solo = waypoint_velocity(timing, 0, waypoints[0]);
436        return push_single_point(out, Time::default(), waypoints[0], solo);
437    }
438    let mut total = 0.0;
439    let mut s = 0;
440    while s < last {
441        let minimum = segment_minimum(
442            waypoints[s],
443            waypoints[s + 1],
444            limits,
445            profile,
446            all_zero_via,
447        );
448        let duration = match timing.segment_durations {
449            Some(fixed) => {
450                let exact = fixed[s].get::<second>();
451                if rest_only && exact < minimum && minimum - exact > 1e-9 * minimum.max(1.0) {
452                    return Ok(CuTimingStatus::InvalidInput);
453                }
454                exact
455            }
456            None => minimum,
457        };
458        total += duration;
459        s += 1;
460    }
461    if total <= 0.0 {
462        let solo = waypoint_velocity(timing, 0, waypoints[0]);
463        return push_single_point(out, Time::default(), waypoints[0], solo);
464    }
465    if matches!(
466        profile,
467        CuTrajectoryProfile::MinimumJerk | CuTrajectoryProfile::Cubic
468    ) && !segments_within_limits(waypoints, limits, profile, timing, all_zero_via)
469    {
470        return Ok(CuTimingStatus::InvalidInput);
471    }
472    if !try_push(
473        out,
474        CuTrajectoryPoint {
475            time_from_start: Time::default(),
476            positions: waypoints[0],
477            velocities: waypoint_velocity(timing, 0, waypoints[0]),
478            accelerations: zero_accelerations(waypoints[0]),
479        },
480    ) {
481        return Ok(CuTimingStatus::CapacityExceeded);
482    }
483    let mut k = 1u64;
484    loop {
485        let t = k as f64 * dt;
486        if t >= total {
487            break;
488        }
489        let at = locate_segment(t, waypoints, limits, profile, timing, all_zero_via);
490        let (positions, velocities) = eval_segment(&at, t, waypoints, limits, profile, timing);
491        if !try_push(
492            out,
493            CuTrajectoryPoint {
494                time_from_start: Time::new::<second>(t),
495                positions,
496                velocities,
497                accelerations: zero_accelerations(waypoints[0]),
498            },
499        ) {
500            return Ok(CuTimingStatus::CapacityExceeded);
501        }
502        k += 1;
503    }
504    if !try_push(
505        out,
506        CuTrajectoryPoint {
507            time_from_start: Time::new::<second>(total),
508            positions: waypoints[last],
509            velocities: waypoint_velocity(timing, last, waypoints[last]),
510            accelerations: zero_accelerations(waypoints[0]),
511        },
512    ) {
513        return Ok(CuTimingStatus::CapacityExceeded);
514    }
515    Ok(CuTimingStatus::Success)
516}
517
518/// Checks that all waypoints share joint kinds and hold finite coordinates.
519fn validate_waypoints<const N: usize>(waypoints: &[[CuJointPosition; N]]) -> CuResult<()> {
520    let mut i = 0;
521    while i < waypoints.len() {
522        let mut j = 0;
523        while j < N {
524            if core::mem::discriminant(&waypoints[i][j])
525                != core::mem::discriminant(&waypoints[0][j])
526                || !waypoints[i][j].si_value().is_finite()
527            {
528                return Err(CuError::from(
529                    "Waypoint joint kinds must match and be finite",
530                ));
531            }
532            j += 1;
533        }
534        i += 1;
535    }
536    Ok(())
537}
538
539/// Checks limit positivity, finiteness, and kind agreement with the joints.
540fn limits_valid<const N: usize>(
541    reference: [CuJointPosition; N],
542    limits: &CuJointTrajectoryLimits<N>,
543) -> bool {
544    let mut j = 0;
545    while j < N {
546        let acc = limits.acc(j);
547        if !acc.is_finite() {
548            return false;
549        }
550        let vel = limits.max_velocities[j];
551        if core::mem::discriminant(&vel)
552            != core::mem::discriminant(&coordinate_velocity_kind(reference[j]))
553            || core::mem::discriminant(&limits.max_accelerations[j])
554                != core::mem::discriminant(&CuJointAcceleration::from_coordinate(reference[j], 0.0))
555        {
556            return false;
557        }
558        match reference[j] {
559            CuJointPosition::Fixed => {
560                if acc < 0.0 {
561                    return false;
562                }
563            }
564            _ => {
565                if !vel.si_value().is_finite() || vel.si_value() <= 0.0 || acc <= 0.0 {
566                    return false;
567                }
568            }
569        }
570        j += 1;
571    }
572    true
573}
574
575/// Checks jerk kind and positivity for joints that move; fixed-joint entries
576/// are ignored and `None` limits are always valid.
577///
578/// Each entry must be strictly positive; `f64::INFINITY` is explicitly
579/// allowed and means "no jerk bound" (the S-curve then coincides with the
580/// trapezoid). NaN, non-positive, and negative-infinite entries fail.
581fn jerks_valid<const N: usize>(
582    reference: [CuJointPosition; N],
583    limits: &CuJointTrajectoryLimits<N>,
584) -> bool {
585    let mut j = 0;
586    while j < N {
587        if matches!(reference[j], CuJointPosition::Fixed) {
588            j += 1;
589            continue;
590        }
591        if let Some(jerks) = limits.max_jerks
592            && core::mem::discriminant(&jerks[j])
593                != core::mem::discriminant(&CuJointJerk::from_coordinate(reference[j], 0.0))
594        {
595            return false;
596        }
597        let jerk = limits.jerk(j);
598        if jerk <= 0.0 || jerk.is_nan() {
599            return false;
600        }
601        j += 1;
602    }
603    true
604}
605
606/// Checks waypoint-velocity kind agreement and finiteness against the joints.
607fn vias_valid<const N: usize>(
608    waypoints: &[[CuJointPosition; N]],
609    vias: &[[CuJointVelocity; N]],
610) -> bool {
611    let mut i = 0;
612    while i < waypoints.len() {
613        let mut j = 0;
614        while j < N {
615            if core::mem::discriminant(&vias[i][j])
616                != core::mem::discriminant(&coordinate_velocity_kind(waypoints[i][j]))
617                || !vias[i][j].si_value().is_finite()
618            {
619                return false;
620            }
621            j += 1;
622        }
623        i += 1;
624    }
625    true
626}
627
628/// Returns `true` when any specified waypoint velocity is non-zero.
629fn vias_nonzero<const N: usize>(timing: &CuTimingOverrides<'_, N>) -> bool {
630    match timing.waypoint_velocities {
631        Some(vias) => {
632            let mut i = 0;
633            while i < vias.len() {
634                let mut j = 0;
635                while j < N {
636                    if vias[i][j].si_value() != 0.0 {
637                        return true;
638                    }
639                    j += 1;
640                }
641                i += 1;
642            }
643            false
644        }
645        None => false,
646    }
647}
648
649/// Resolves the specified velocity at waypoint `index`, defaulting to rest.
650fn waypoint_velocity<const N: usize>(
651    timing: &CuTimingOverrides<'_, N>,
652    index: usize,
653    reference: [CuJointPosition; N],
654) -> [CuJointVelocity; N] {
655    match timing.waypoint_velocities {
656        Some(vias) => vias[index],
657        None => core::array::from_fn(|j| CuJointVelocity::from_coordinate(reference[j], 0.0)),
658    }
659}
660
661/// Zero accelerations of the joint kinds of `reference`.
662fn zero_accelerations<const N: usize>(reference: [CuJointPosition; N]) -> [CuJointAcceleration; N] {
663    reference.map(|q| CuJointAcceleration::from_coordinate(q, 0.0))
664}
665
666/// Maps a joint kind to the matching zero-magnitude velocity variant.
667fn coordinate_velocity_kind(q: CuJointPosition) -> CuJointVelocity {
668    CuJointVelocity::from_coordinate(q, 0.0)
669}
670
671/// Trapezoidal (or triangular) traversal time for one joint displacement.
672fn joint_profile_duration(distance: f64, max_vel: f64, max_acc: f64) -> f64 {
673    if distance <= 0.0 {
674        return 0.0;
675    }
676    let accel_time = max_vel / max_acc;
677    if distance >= max_vel * accel_time {
678        distance / max_vel + accel_time
679    } else {
680        2.0 * libm::sqrt(distance / max_acc)
681    }
682}
683
684/// Jerk-limited S-curve traversal time for one joint displacement.
685///
686/// With infinite jerk this is exactly the trapezoidal time; with finite jerk
687/// it is the symmetric 7-segment duration from [`scurve_shape`].
688fn scurve_joint_duration(distance: f64, max_vel: f64, max_acc: f64, max_jerk: f64) -> f64 {
689    if distance <= 0.0 {
690        return 0.0;
691    }
692    if !max_jerk.is_finite() {
693        return joint_profile_duration(distance, max_vel, max_acc);
694    }
695    let (jerk_time, accel_time, cruise_time) = scurve_shape(distance, max_vel, max_acc, max_jerk);
696    4.0 * jerk_time + 2.0 * accel_time + cruise_time
697}
698
699/// Solves the symmetric rest-to-rest 7-segment S-curve phase durations.
700///
701/// Returns `(jerk_time, accel_time, cruise_time)` for the phase sequence
702/// `+J, 0, -J, cruise, -J, 0, +J`. Assumes `distance > 0` with finite
703/// positive limits. Peak velocity, acceleration, and jerk never exceed the
704/// given bounds: full limits are reached only when the displacement admits
705/// them, otherwise the cruise, the constant-acceleration phase, and finally
706/// the jerk time shrink in that order.
707fn scurve_shape(distance: f64, max_vel: f64, max_acc: f64, max_jerk: f64) -> (f64, f64, f64) {
708    let jerk_full = max_acc / max_jerk;
709    let accel_full = max_vel / max_acc - jerk_full;
710    if accel_full >= 0.0 {
711        let reach = max_vel * (accel_full + 2.0 * jerk_full);
712        if distance >= reach {
713            return (jerk_full, accel_full, (distance - reach) / max_vel);
714        }
715        let discriminant = jerk_full * jerk_full + 4.0 * distance / max_acc;
716        let accel = 0.5 * (libm::sqrt(discriminant) - 3.0 * jerk_full);
717        if accel >= 0.0 {
718            return (jerk_full, accel, 0.0);
719        }
720        return (libm::cbrt(distance / (2.0 * max_jerk)), 0.0, 0.0);
721    }
722    let jerk = libm::sqrt(max_vel / max_jerk);
723    if distance >= 2.0 * max_vel * jerk {
724        return (jerk, 0.0, distance / max_vel - 2.0 * jerk);
725    }
726    (libm::cbrt(distance / (2.0 * max_jerk)), 0.0, 0.0)
727}
728
729/// Evaluates a single-joint trapezoidal profile at `offset` seconds.
730///
731/// Returns `(position_offset, velocity)` relative to the segment start for a
732/// non-negative `distance`; both are zero-rate bounded by `max_vel`/`max_acc`.
733fn trapezoid_eval(offset: f64, distance: f64, max_vel: f64, max_acc: f64) -> (f64, f64) {
734    if distance <= 0.0 || offset <= 0.0 {
735        return (0.0, 0.0);
736    }
737    let accel_time = max_vel / max_acc;
738    if distance >= max_vel * accel_time {
739        let cruise = distance / max_vel - accel_time;
740        let total = distance / max_vel + accel_time;
741        let accel_distance = 0.5 * max_vel * accel_time;
742        if offset < accel_time {
743            (0.5 * max_acc * offset * offset, max_acc * offset)
744        } else if offset < accel_time + cruise {
745            (accel_distance + max_vel * (offset - accel_time), max_vel)
746        } else if offset < total {
747            let remaining = total - offset;
748            (
749                distance - 0.5 * max_acc * remaining * remaining,
750                max_acc * remaining,
751            )
752        } else {
753            (distance, 0.0)
754        }
755    } else {
756        let half = libm::sqrt(distance / max_acc);
757        let total = 2.0 * half;
758        if offset < half {
759            (0.5 * max_acc * offset * offset, max_acc * offset)
760        } else if offset < total {
761            let remaining = total - offset;
762            (
763                distance - 0.5 * max_acc * remaining * remaining,
764                max_acc * remaining,
765            )
766        } else {
767            (distance, 0.0)
768        }
769    }
770}
771
772/// Evaluates a symmetric jerk-limited S-curve at `offset` seconds.
773///
774/// Integrates the `+J, 0, -J, 0, -J, 0, +J` jerk sequence from [`scurve_shape`]
775/// for a non-negative `distance`. Assumes finite positive limits.
776fn scurve_eval(
777    offset: f64,
778    distance: f64,
779    max_vel: f64,
780    max_acc: f64,
781    max_jerk: f64,
782) -> (f64, f64) {
783    if distance <= 0.0 || offset <= 0.0 {
784        return (0.0, 0.0);
785    }
786    let (jerk_time, accel_time, cruise_time) = scurve_shape(distance, max_vel, max_acc, max_jerk);
787    let total = 4.0 * jerk_time + 2.0 * accel_time + cruise_time;
788    if offset >= total {
789        return (distance, 0.0);
790    }
791    let mut remaining = offset;
792    let mut position = 0.0;
793    let mut velocity = 0.0;
794    let mut acceleration = 0.0;
795    let mut phase = 0;
796    while phase < 7 {
797        let (jerk, duration) = match phase {
798            0 => (max_jerk, jerk_time),
799            1 => (0.0, accel_time),
800            2 => (-max_jerk, jerk_time),
801            3 => (0.0, cruise_time),
802            4 => (-max_jerk, jerk_time),
803            5 => (0.0, accel_time),
804            _ => (max_jerk, jerk_time),
805        };
806        let step = if remaining < duration {
807            remaining
808        } else {
809            duration
810        };
811        position +=
812            velocity * step + 0.5 * acceleration * step * step + jerk * step * step * step / 6.0;
813        velocity += acceleration * step + 0.5 * jerk * step * step;
814        acceleration += jerk * step;
815        remaining -= step;
816        if remaining <= 0.0 {
817            break;
818        }
819        phase += 1;
820    }
821    (position, velocity)
822}
823
824/// Single-joint displacement with its SI limit bounds for profile evaluation.
825struct JointPrimitive {
826    distance: f64,
827    max_vel: f64,
828    max_acc: f64,
829    max_jerk: f64,
830}
831
832/// Evaluates a rest-to-rest primitive stretched onto a longer segment.
833///
834/// `own` is the primitive's natural duration and `effective` the synchronized
835/// (or fixed) segment duration. Time-scaling by `scale = own / effective`
836/// lowers peak velocity by `scale`, acceleration by `scale^2`, and jerk by
837/// `scale^3`, so every bound the primitive satisfies still holds.
838fn stretched_eval(
839    tau: f64,
840    effective: f64,
841    own: f64,
842    prim: &JointPrimitive,
843    jerk_limited: bool,
844) -> (f64, f64) {
845    if effective <= 0.0 || own <= 0.0 {
846        return (0.0, 0.0);
847    }
848    let scale = own / effective;
849    let (offset, vel) = if jerk_limited && prim.max_jerk.is_finite() {
850        scurve_eval(
851            tau * scale,
852            prim.distance,
853            prim.max_vel,
854            prim.max_acc,
855            prim.max_jerk,
856        )
857    } else {
858        trapezoid_eval(tau * scale, prim.distance, prim.max_vel, prim.max_acc)
859    };
860    (offset, vel * scale)
861}
862
863/// Evaluates a quintic minimum-jerk segment at normalized time `u`.
864///
865/// Boundary conditions are `q(0) = q0`, `q(1) = q1`, `dq(0) = v0`,
866/// `dq(1) = v1` (SI units per second), with zero endpoint accelerations.
867/// Positions and velocities are absolute SI values, not offsets.
868///
869/// Shared with the Cartesian path timing.
870pub(crate) fn quintic_eval(
871    u: f64,
872    q0: f64,
873    q1: f64,
874    v0: f64,
875    v1: f64,
876    duration: f64,
877) -> (f64, f64) {
878    if duration <= 0.0 {
879        return (q0, v0);
880    }
881    let distance = q1 - q0;
882    let start_term = distance - v0 * duration;
883    let end_term = (v1 - v0) * duration;
884    let a3 = 10.0 * start_term - 4.0 * end_term;
885    let a4 = -15.0 * start_term + 7.0 * end_term;
886    let a5 = 6.0 * start_term - 3.0 * end_term;
887    let u2 = u * u;
888    let u3 = u2 * u;
889    let u4 = u3 * u;
890    let position = q0 + v0 * duration * u + a3 * u3 + a4 * u4 + a5 * u4 * u;
891    let velocity = v0 + (3.0 * a3 * u2 + 4.0 * a4 * u3 + 5.0 * a5 * u4) / duration;
892    (position, velocity)
893}
894
895/// Evaluates a cubic Hermite segment at normalized time `u`.
896///
897/// Interpolates `q(0) = q0`, `q(1) = q1` with endpoint velocities `v0`, `v1`
898/// (SI units per second). Positions and velocities are absolute SI values.
899///
900/// Shared with the Cartesian path timing.
901pub(crate) fn cubic_eval(u: f64, q0: f64, q1: f64, v0: f64, v1: f64, duration: f64) -> (f64, f64) {
902    if duration <= 0.0 {
903        return (q0, v0);
904    }
905    let u2 = u * u;
906    let u3 = u2 * u;
907    let h00 = 2.0 * u3 - 3.0 * u2 + 1.0;
908    let h10 = u3 - 2.0 * u2 + u;
909    let h01 = -2.0 * u3 + 3.0 * u2;
910    let h11 = u3 - u2;
911    let position = h00 * q0 + h10 * duration * v0 + h01 * q1 + h11 * duration * v1;
912    let dh00 = 6.0 * u2 - 6.0 * u;
913    let dh10 = 3.0 * u2 - 4.0 * u + 1.0;
914    let dh01 = -6.0 * u2 + 6.0 * u;
915    let dh11 = 3.0 * u2 - 2.0 * u;
916    let velocity = (dh00 * q0 + dh10 * duration * v0 + dh01 * q1 + dh11 * duration * v1) / duration;
917    (position, velocity)
918}
919
920/// Synchronized minimum duration for one segment under the profile.
921///
922/// Trapezoidal and cubic use the slowest joint's trapezoidal time. S-curve
923/// uses the slowest joint's jerk-limited time. Minimum-jerk uses the slowest
924/// joint's quintic rest-to-rest time when every waypoint velocity is zero,
925/// and the trapezoidal minimum otherwise.
926fn segment_minimum<const N: usize>(
927    from: [CuJointPosition; N],
928    to: [CuJointPosition; N],
929    limits: &CuJointTrajectoryLimits<N>,
930    profile: CuTrajectoryProfile,
931    all_zero_via: bool,
932) -> f64 {
933    match profile {
934        CuTrajectoryProfile::MinimumJerk if all_zero_via => {
935            minjerk_segment_duration(&from, &to, limits)
936        }
937        CuTrajectoryProfile::MinimumJerk | CuTrajectoryProfile::Cubic => {
938            segment_duration(&from, &to, limits)
939        }
940        CuTrajectoryProfile::Trapezoidal => segment_duration(&from, &to, limits),
941        CuTrajectoryProfile::SCurve => scurve_segment_duration(&from, &to, limits),
942    }
943}
944
945/// Segment duration synchronized across joints (slowest joint dominates).
946fn segment_duration<const N: usize>(
947    from: &[CuJointPosition; N],
948    to: &[CuJointPosition; N],
949    limits: &CuJointTrajectoryLimits<N>,
950) -> f64 {
951    let mut duration = 0.0;
952    let mut j = 0;
953    while j < N {
954        let distance = (to[j].si_value() - from[j].si_value()).abs();
955        let leg =
956            joint_profile_duration(distance, limits.max_velocities[j].si_value(), limits.acc(j));
957        if leg > duration {
958            duration = leg;
959        }
960        j += 1;
961    }
962    duration
963}
964
965/// S-curve segment duration synchronized across joints.
966fn scurve_segment_duration<const N: usize>(
967    from: &[CuJointPosition; N],
968    to: &[CuJointPosition; N],
969    limits: &CuJointTrajectoryLimits<N>,
970) -> f64 {
971    let mut duration = 0.0;
972    let mut j = 0;
973    while j < N {
974        let distance = (to[j].si_value() - from[j].si_value()).abs();
975        let leg = scurve_joint_duration(
976            distance,
977            limits.max_velocities[j].si_value(),
978            limits.acc(j),
979            limits.jerk(j),
980        );
981        if leg > duration {
982            duration = leg;
983        }
984        j += 1;
985    }
986    duration
987}
988
989/// Quintic rest-to-rest segment duration synchronized across joints.
990///
991/// A rest-to-rest quintic peaks at `|v| = 1.875 * distance / duration` and
992/// `|a| = (10 / sqrt(3)) * distance / duration^2`, so the smallest duration
993/// honoring the velocity and acceleration limits is their maximum.
994fn minjerk_segment_duration<const N: usize>(
995    from: &[CuJointPosition; N],
996    to: &[CuJointPosition; N],
997    limits: &CuJointTrajectoryLimits<N>,
998) -> f64 {
999    let mut duration = 0.0;
1000    let mut j = 0;
1001    while j < N {
1002        let distance = (to[j].si_value() - from[j].si_value()).abs();
1003        if distance > 0.0 {
1004            let vel = limits.max_velocities[j].si_value();
1005            let acc = limits.acc(j);
1006            let vel_time = 1.875 * distance / vel;
1007            let acc_time = libm::sqrt((10.0 / libm::sqrt(3.0)) * distance / acc);
1008            if vel_time > duration {
1009                duration = vel_time;
1010            }
1011            if acc_time > duration {
1012                duration = acc_time;
1013            }
1014        }
1015        j += 1;
1016    }
1017    duration
1018}
1019
1020/// Effective duration of segment `seg`: fixed timing or synchronized minimum.
1021fn effective_duration<const N: usize>(
1022    seg: usize,
1023    waypoints: &[[CuJointPosition; N]],
1024    limits: &CuJointTrajectoryLimits<N>,
1025    profile: CuTrajectoryProfile,
1026    timing: &CuTimingOverrides<'_, N>,
1027    all_zero_via: bool,
1028) -> f64 {
1029    match timing.segment_durations {
1030        Some(fixed) => fixed[seg].get::<second>(),
1031        None => segment_minimum(
1032            waypoints[seg],
1033            waypoints[seg + 1],
1034            limits,
1035            profile,
1036            all_zero_via,
1037        ),
1038    }
1039}
1040
1041/// Locates the segment containing path time `t` and its start and duration.
1042fn locate_segment<const N: usize>(
1043    t: f64,
1044    waypoints: &[[CuJointPosition; N]],
1045    limits: &CuJointTrajectoryLimits<N>,
1046    profile: CuTrajectoryProfile,
1047    timing: &CuTimingOverrides<'_, N>,
1048    all_zero_via: bool,
1049) -> SegmentAt {
1050    let mut seg = 0;
1051    let mut seg_start = 0.0;
1052    while seg + 2 < waypoints.len() {
1053        let duration = effective_duration(seg, waypoints, limits, profile, timing, all_zero_via);
1054        if t < seg_start + duration {
1055            break;
1056        }
1057        seg_start += duration;
1058        seg += 1;
1059    }
1060    let seg_duration = effective_duration(seg, waypoints, limits, profile, timing, all_zero_via);
1061    SegmentAt {
1062        seg,
1063        start: seg_start,
1064        duration: seg_duration,
1065    }
1066}
1067
1068/// Location of a path time within the trajectory: segment, start, duration.
1069struct SegmentAt {
1070    seg: usize,
1071    start: f64,
1072    duration: f64,
1073}
1074
1075/// Evaluates every joint of segment `seg` at path time `t`.
1076///
1077/// Rest-to-rest profiles follow their own primitive stretched onto the
1078/// segment duration; quintic and Hermite profiles interpolate the specified
1079/// waypoint velocities over the exact segment duration.
1080fn eval_segment<const N: usize>(
1081    at: &SegmentAt,
1082    t: f64,
1083    waypoints: &[[CuJointPosition; N]],
1084    limits: &CuJointTrajectoryLimits<N>,
1085    profile: CuTrajectoryProfile,
1086    timing: &CuTimingOverrides<'_, N>,
1087) -> ([CuJointPosition; N], [CuJointVelocity; N]) {
1088    let from = waypoints[at.seg];
1089    let to = waypoints[at.seg + 1];
1090    let start_vel = waypoint_velocity(timing, at.seg, from);
1091    let end_vel = waypoint_velocity(timing, at.seg + 1, to);
1092    let tau = (t - at.start).clamp(0.0, at.duration);
1093    let mut positions = [CuJointPosition::Fixed; N];
1094    let mut velocities = [CuJointVelocity::Fixed; N];
1095    let mut j = 0;
1096    while j < N {
1097        let start = from[j].si_value();
1098        let displacement = to[j].si_value() - start;
1099        let prim = JointPrimitive {
1100            distance: displacement.abs(),
1101            max_vel: limits.max_velocities[j].si_value(),
1102            max_acc: limits.acc(j),
1103            max_jerk: limits.jerk(j),
1104        };
1105        match profile {
1106            CuTrajectoryProfile::Trapezoidal | CuTrajectoryProfile::SCurve => {
1107                let sign = if displacement >= 0.0 { 1.0 } else { -1.0 };
1108                let jerk_limited = profile == CuTrajectoryProfile::SCurve;
1109                let own = if jerk_limited {
1110                    scurve_joint_duration(prim.distance, prim.max_vel, prim.max_acc, prim.max_jerk)
1111                } else {
1112                    joint_profile_duration(prim.distance, prim.max_vel, prim.max_acc)
1113                };
1114                let (offset, vel) = stretched_eval(tau, at.duration, own, &prim, jerk_limited);
1115                positions[j] = from[j].with_si_value(start + sign * offset);
1116                velocities[j] = CuJointVelocity::from_coordinate(from[j], sign * vel);
1117            }
1118            CuTrajectoryProfile::MinimumJerk | CuTrajectoryProfile::Cubic => {
1119                let u = if at.duration > 0.0 {
1120                    (tau / at.duration).clamp(0.0, 1.0)
1121                } else {
1122                    0.0
1123                };
1124                let (position, vel) = if profile == CuTrajectoryProfile::MinimumJerk {
1125                    quintic_eval(
1126                        u,
1127                        start,
1128                        start + displacement,
1129                        start_vel[j].si_value(),
1130                        end_vel[j].si_value(),
1131                        at.duration,
1132                    )
1133                } else {
1134                    cubic_eval(
1135                        u,
1136                        start,
1137                        start + displacement,
1138                        start_vel[j].si_value(),
1139                        end_vel[j].si_value(),
1140                        at.duration,
1141                    )
1142                };
1143                positions[j] = from[j].with_si_value(position);
1144                velocities[j] = CuJointVelocity::from_coordinate(from[j], vel);
1145            }
1146        }
1147        j += 1;
1148    }
1149    (positions, velocities)
1150}
1151
1152/// Checks waypoint-velocity magnitudes against the per-joint velocity limits.
1153fn vias_within_limits<const N: usize>(
1154    waypoints: &[[CuJointPosition; N]],
1155    vias: &[[CuJointVelocity; N]],
1156    limits: &CuJointTrajectoryLimits<N>,
1157) -> bool {
1158    let mut i = 0;
1159    while i < vias.len() {
1160        let mut j = 0;
1161        while j < N {
1162            if matches!(waypoints[i][j], CuJointPosition::Fixed) {
1163                j += 1;
1164                continue;
1165            }
1166            let bound = limits.max_velocities[j].si_value() * (1.0 + 1e-9) + 1e-12;
1167            if vias[i][j].si_value().abs() > bound {
1168                return false;
1169            }
1170            j += 1;
1171        }
1172        i += 1;
1173    }
1174    true
1175}
1176
1177/// Checks the exact velocity extrema of every Hermite segment — waypoint
1178/// velocities at the ends included — against the velocity limits, with a
1179/// small float tolerance.
1180///
1181/// Sampling alone cannot certify this: a segment shorter than the sample
1182/// period receives no interior samples, and a peak between two samples stays
1183/// unseen, so the analytic extrema are checked instead.
1184fn segments_within_limits<const N: usize>(
1185    waypoints: &[[CuJointPosition; N]],
1186    limits: &CuJointTrajectoryLimits<N>,
1187    profile: CuTrajectoryProfile,
1188    timing: &CuTimingOverrides<'_, N>,
1189    all_zero_via: bool,
1190) -> bool {
1191    let quintic = profile == CuTrajectoryProfile::MinimumJerk;
1192    let mut seg = 0;
1193    while seg + 1 < waypoints.len() {
1194        let duration = effective_duration(seg, waypoints, limits, profile, timing, all_zero_via);
1195        if duration <= 0.0 {
1196            seg += 1;
1197            continue;
1198        }
1199        let start_vel = waypoint_velocity(timing, seg, waypoints[seg]);
1200        let end_vel = waypoint_velocity(timing, seg + 1, waypoints[seg + 1]);
1201        let mut j = 0;
1202        while j < N {
1203            if matches!(waypoints[seg][j], CuJointPosition::Fixed) {
1204                j += 1;
1205                continue;
1206            }
1207            let distance = waypoints[seg + 1][j].si_value() - waypoints[seg][j].si_value();
1208            let (high, low, _) = polynomial_extrema(
1209                quintic,
1210                distance,
1211                start_vel[j].si_value(),
1212                end_vel[j].si_value(),
1213                duration,
1214            );
1215            let bound = limits.max_velocities[j].si_value() * (1.0 + 1e-9) + 1e-12;
1216            if !high.is_finite() || !low.is_finite() || high > bound || low < -bound {
1217                return false;
1218            }
1219            j += 1;
1220        }
1221        seg += 1;
1222    }
1223    true
1224}
1225
1226/// Pushes one fully-specified point; a full buffer maps to `CapacityExceeded`.
1227fn push_single_point<const N: usize, const MAX_POINTS: usize>(
1228    out: &mut CuTrajectoryPayload<N, MAX_POINTS>,
1229    time_from_start: Time,
1230    q: [CuJointPosition; N],
1231    velocities: [CuJointVelocity; N],
1232) -> CuResult<CuTimingStatus> {
1233    match out.push(CuTrajectoryPoint {
1234        time_from_start,
1235        positions: q,
1236        velocities,
1237        accelerations: zero_accelerations(q),
1238    }) {
1239        Ok(()) => Ok(CuTimingStatus::Success),
1240        Err(_) => Ok(CuTimingStatus::CapacityExceeded),
1241    }
1242}
1243
1244/// Pushes a point, returning `false` when `out` is full.
1245fn try_push<const N: usize, const MAX_POINTS: usize>(
1246    out: &mut CuTrajectoryPayload<N, MAX_POINTS>,
1247    point: CuTrajectoryPoint<N>,
1248) -> bool {
1249    out.push(point).is_ok()
1250}
1251
1252#[cfg(test)]
1253mod tests {
1254    use super::*;
1255    use cu29::units::si::angle::radian;
1256    use cu29::units::si::angular_velocity::radian_per_second;
1257    use cu29::units::si::f64::Angle;
1258    use cu29::units::si::f64::AngularVelocity;
1259
1260    fn rev(q: f64) -> CuJointPosition {
1261        CuJointPosition::Revolute(Angle::new::<radian>(q))
1262    }
1263
1264    fn rev_acc(a: f64) -> CuJointAcceleration {
1265        CuJointAcceleration::from_coordinate(rev(0.0), a)
1266    }
1267
1268    fn rev_limits(v: f64, a: f64) -> CuJointTrajectoryLimits<1> {
1269        CuJointTrajectoryLimits::new(
1270            [CuJointVelocity::Revolute(AngularVelocity::new::<
1271                radian_per_second,
1272            >(v))],
1273            [rev_acc(a)],
1274        )
1275    }
1276
1277    fn rev_limits_jerk(v: f64, a: f64, j: f64) -> CuJointTrajectoryLimits<1> {
1278        rev_limits(v, a).with_jerks([CuJointJerk::from_coordinate(rev(0.0), j)])
1279    }
1280
1281    /// Default params and overrides: the synchronized trapezoidal timing.
1282    fn trapezoid<const N: usize, const M: usize>(
1283        waypoints: &[[CuJointPosition; N]],
1284        limits: &CuJointTrajectoryLimits<N>,
1285        sample_period: Time,
1286        out: &mut CuTrajectoryPayload<N, M>,
1287    ) -> CuResult<CuTimingStatus> {
1288        let params = CuTimingParams {
1289            limits: *limits,
1290            sample_period,
1291            ..Default::default()
1292        };
1293        parameterize(waypoints, &params, CuTimingOverrides::default(), out)
1294    }
1295
1296    fn with_profile<const N: usize, const M: usize>(
1297        waypoints: &[[CuJointPosition; N]],
1298        limits: &CuJointTrajectoryLimits<N>,
1299        profile: CuTrajectoryProfile,
1300        overrides: &CuTimingOverrides<'_, N>,
1301        sample_period: Time,
1302        out: &mut CuTrajectoryPayload<N, M>,
1303    ) -> CuResult<CuTimingStatus> {
1304        let params = CuTimingParams {
1305            limits: *limits,
1306            profile,
1307            sample_period,
1308        };
1309        parameterize(waypoints, &params, *overrides, out)
1310    }
1311
1312    fn zero_vel() -> CuJointVelocity {
1313        CuJointVelocity::Revolute(AngularVelocity::new::<radian_per_second>(0.0))
1314    }
1315
1316    fn rev_vel(v: f64) -> CuJointVelocity {
1317        CuJointVelocity::Revolute(AngularVelocity::new::<radian_per_second>(v))
1318    }
1319
1320    fn max_sampled_velocity<const M: usize>(traj: &CuTrajectoryPayload<1, M>) -> (f64, f64) {
1321        let mut lo = f64::INFINITY;
1322        let mut hi = f64::NEG_INFINITY;
1323        let mut i = 0;
1324        while i < traj.len() {
1325            let v = traj.points()[i].velocities[0].si_value();
1326            lo = lo.min(v);
1327            hi = hi.max(v);
1328            i += 1;
1329        }
1330        (lo, hi)
1331    }
1332
1333    fn assert_strictly_increasing<const M: usize>(traj: &CuTrajectoryPayload<1, M>) {
1334        let mut i = 1;
1335        while i < traj.len() {
1336            assert!(
1337                traj.points()[i].time_from_start > traj.points()[i - 1].time_from_start,
1338                "times must be strictly increasing"
1339            );
1340            i += 1;
1341        }
1342    }
1343
1344    #[test]
1345    fn test_empty_path_yields_empty_status() {
1346        let mut out = CuTrajectoryPayload::<1>::new();
1347        let status = trapezoid(
1348            &[],
1349            &rev_limits(1.0, 1.0),
1350            Time::new::<second>(0.01),
1351            &mut out,
1352        )
1353        .unwrap();
1354        assert_eq!(status, CuTimingStatus::EmptyPath);
1355        assert!(out.is_empty());
1356    }
1357
1358    #[test]
1359    fn test_single_waypoint_is_zero_velocity_point() {
1360        let mut out = CuTrajectoryPayload::<1>::new();
1361        let status = trapezoid(
1362            &[[rev(0.5)]],
1363            &rev_limits(1.0, 1.0),
1364            Time::new::<second>(0.01),
1365            &mut out,
1366        )
1367        .unwrap();
1368        assert_eq!(status, CuTimingStatus::Success);
1369        assert_eq!(out.len(), 1);
1370        assert_eq!(out.points()[0].time_from_start.get::<second>(), 0.0);
1371        assert_eq!(out.points()[0].positions[0].si_value(), 0.5);
1372        assert_eq!(out.points()[0].velocities[0].si_value(), 0.0);
1373    }
1374
1375    #[test]
1376    fn test_trapezoidal_duration_and_velocity_limits() {
1377        let mut out = CuTrajectoryPayload::<1, 512>::new();
1378        let status = trapezoid(
1379            &[[rev(0.0)], [rev(1.0)]],
1380            &rev_limits(0.5, 1.0),
1381            Time::new::<second>(0.02),
1382            &mut out,
1383        )
1384        .unwrap();
1385        assert_eq!(status, CuTimingStatus::Success);
1386        // Trapezoidal: T = D/v + v/a = 2.0 + 0.5.
1387        assert!((out.duration().get::<second>() - 2.5).abs() < 1e-9);
1388        let (lo, hi) = max_sampled_velocity(&out);
1389        assert!(hi <= 0.5 + 1e-9, "velocity limit exceeded: {hi}");
1390        assert!(lo >= -1e-9, "unexpected reverse motion: {lo}");
1391        assert_eq!(out.points()[0].velocities[0].si_value(), 0.0);
1392        let last = out.len() - 1;
1393        assert_eq!(out.points()[last].positions[0].si_value(), 1.0);
1394        assert_eq!(out.points()[last].velocities[0].si_value(), 0.0);
1395    }
1396
1397    #[test]
1398    fn test_triangular_duration_matches_formula() {
1399        let mut out = CuTrajectoryPayload::<1, 256>::new();
1400        let status = trapezoid(
1401            &[[rev(0.0)], [rev(0.1)]],
1402            &rev_limits(1.0, 1.0),
1403            Time::new::<second>(0.005),
1404            &mut out,
1405        )
1406        .unwrap();
1407        assert_eq!(status, CuTimingStatus::Success);
1408        // Triangular (D < v^2/a): T = 2*sqrt(D/a).
1409        let expected = 2.0 * (0.1f64).sqrt();
1410        assert!((out.duration().get::<second>() - expected).abs() < 1e-9);
1411        let (_, peak) = max_sampled_velocity(&out);
1412        assert!((peak - (0.1f64).sqrt()).abs() < 0.02);
1413    }
1414
1415    #[test]
1416    fn test_slow_joint_dominates_synchronized_duration() {
1417        let slow = CuJointTrajectoryLimits::new(
1418            [
1419                CuJointVelocity::Revolute(AngularVelocity::new::<radian_per_second>(2.0)),
1420                CuJointVelocity::Revolute(AngularVelocity::new::<radian_per_second>(0.25)),
1421            ],
1422            [rev_acc(2.0), rev_acc(0.5)],
1423        );
1424        let mut out = CuTrajectoryPayload::<2, 512>::new();
1425        let status = trapezoid(
1426            &[[rev(0.0), rev(0.0)], [rev(0.1), rev(1.0)]],
1427            &slow,
1428            Time::new::<second>(0.05),
1429            &mut out,
1430        )
1431        .unwrap();
1432        assert_eq!(status, CuTimingStatus::Success);
1433        // Slow joint trapezoidal: T = 1/0.25 + 0.25/0.5 = 4.5; fast joint ~0.45.
1434        assert!((out.duration().get::<second>() - 4.5).abs() < 1e-9);
1435        let last = out.len() - 1;
1436        assert!((out.points()[last].positions[0].si_value() - 0.1).abs() < 1e-9);
1437        assert!((out.points()[last].positions[1].si_value() - 1.0).abs() < 1e-9);
1438    }
1439
1440    #[test]
1441    fn test_capacity_exceeded_status() {
1442        let mut out = CuTrajectoryPayload::<1, 3>::new();
1443        let status = trapezoid(
1444            &[[rev(0.0)], [rev(1.0)]],
1445            &rev_limits(0.5, 1.0),
1446            Time::new::<second>(0.01),
1447            &mut out,
1448        )
1449        .unwrap();
1450        assert_eq!(status, CuTimingStatus::CapacityExceeded);
1451        assert_eq!(out.len(), 3);
1452    }
1453
1454    #[test]
1455    fn test_invalid_limits_rejected() {
1456        let wps = [[rev(0.0)], [rev(1.0)]];
1457        let mut out = CuTrajectoryPayload::<1>::new();
1458        let zero_vel = CuJointTrajectoryLimits::new(
1459            [CuJointVelocity::Revolute(AngularVelocity::new::<
1460                radian_per_second,
1461            >(0.0))],
1462            [rev_acc(1.0)],
1463        );
1464        let status = trapezoid(&wps, &zero_vel, Time::new::<second>(0.01), &mut out).unwrap();
1465        assert_eq!(status, CuTimingStatus::InvalidInput);
1466        let neg_acc = rev_limits(1.0, -2.0);
1467        let status = trapezoid(&wps, &neg_acc, Time::new::<second>(0.01), &mut out).unwrap();
1468        assert_eq!(status, CuTimingStatus::InvalidInput);
1469        let status = trapezoid(
1470            &wps,
1471            &rev_limits(1.0, 1.0),
1472            Time::new::<second>(0.0),
1473            &mut out,
1474        )
1475        .unwrap();
1476        assert_eq!(status, CuTimingStatus::InvalidInput);
1477    }
1478
1479    #[test]
1480    fn test_profile_default_is_trapezoidal() {
1481        assert_eq!(
1482            CuTrajectoryProfile::default(),
1483            CuTrajectoryProfile::Trapezoidal
1484        );
1485    }
1486
1487    #[test]
1488    fn test_default_profile_matches_legacy_entry_point() {
1489        let wps = [[rev(0.0)], [rev(0.5)], [rev(1.0)]];
1490        let limits = rev_limits(0.5, 1.0);
1491        let dt = Time::new::<second>(0.01);
1492        let mut legacy = CuTrajectoryPayload::<1, 512>::new();
1493        let mut profiled = CuTrajectoryPayload::<1, 512>::new();
1494        let legacy_status = trapezoid(&wps, &limits, dt, &mut legacy).unwrap();
1495        let profiled_status = with_profile(
1496            &wps,
1497            &limits,
1498            CuTrajectoryProfile::Trapezoidal,
1499            &CuTimingOverrides::default(),
1500            dt,
1501            &mut profiled,
1502        )
1503        .unwrap();
1504        assert_eq!(legacy_status, profiled_status);
1505        assert_eq!(legacy.points(), profiled.points());
1506    }
1507
1508    #[test]
1509    fn test_cubic_honors_via_velocity() {
1510        let wps = [[rev(0.0)], [rev(1.0)], [rev(2.0)]];
1511        let limits = rev_limits(2.0, 5.0);
1512        let vias = [[zero_vel()], [rev_vel(0.8)], [zero_vel()]];
1513        let fixed = [Time::new::<second>(1.0), Time::new::<second>(1.0)];
1514        let timing = CuTimingOverrides {
1515            segment_durations: Some(fixed.as_slice()),
1516            waypoint_velocities: Some(vias.as_slice()),
1517        };
1518        let mut out = CuTrajectoryPayload::<1, 512>::new();
1519        let status = with_profile(
1520            &wps,
1521            &limits,
1522            CuTrajectoryProfile::Cubic,
1523            &timing,
1524            Time::new::<second>(0.01),
1525            &mut out,
1526        )
1527        .unwrap();
1528        assert_eq!(status, CuTimingStatus::Success);
1529        assert!((out.duration().get::<second>() - 2.0).abs() < 1e-9);
1530        let mid = out
1531            .points()
1532            .iter()
1533            .find(|p| (p.time_from_start.get::<second>() - 1.0).abs() < 1e-9)
1534            .expect("resampled interior waypoint");
1535        assert!((mid.positions[0].si_value() - 1.0).abs() < 1e-9);
1536        assert!((mid.velocities[0].si_value() - 0.8).abs() < 1e-9);
1537        assert_eq!(out.points()[0].velocities[0].si_value(), 0.0);
1538        let last = out.len() - 1;
1539        assert_eq!(out.points()[last].positions[0].si_value(), 2.0);
1540        assert_eq!(out.points()[last].velocities[0].si_value(), 0.0);
1541        assert_strictly_increasing(&out);
1542    }
1543
1544    #[test]
1545    fn test_minimum_jerk_honors_via_and_zero_end_acceleration() {
1546        let wps = [[rev(0.0)], [rev(1.0)], [rev(2.0)]];
1547        let limits = rev_limits(2.0, 5.0);
1548        let vias = [[zero_vel()], [rev_vel(0.8)], [zero_vel()]];
1549        let fixed = [Time::new::<second>(1.0), Time::new::<second>(1.0)];
1550        let timing = CuTimingOverrides {
1551            segment_durations: Some(fixed.as_slice()),
1552            waypoint_velocities: Some(vias.as_slice()),
1553        };
1554        let mut out = CuTrajectoryPayload::<1, 2048>::new();
1555        let status = with_profile(
1556            &wps,
1557            &limits,
1558            CuTrajectoryProfile::MinimumJerk,
1559            &timing,
1560            Time::new::<second>(0.002),
1561            &mut out,
1562        )
1563        .unwrap();
1564        assert_eq!(status, CuTimingStatus::Success);
1565        assert!((out.duration().get::<second>() - 2.0).abs() < 1e-9);
1566        let mid = out
1567            .points()
1568            .iter()
1569            .find(|p| (p.time_from_start.get::<second>() - 1.0).abs() < 1e-9)
1570            .expect("resampled interior waypoint");
1571        assert!((mid.velocities[0].si_value() - 0.8).abs() < 1e-9);
1572        // Quintic endpoint accelerations are zero; finite differences of the
1573        // 2 ms resampling only see jerk-bounded growth (~0.04 rad/s^2 here).
1574        let dt = 0.002;
1575        let accel_start = (out.points()[1].velocities[0].si_value()
1576            - out.points()[0].velocities[0].si_value())
1577            / dt;
1578        assert!(accel_start.abs() < 0.5, "start accel: {accel_start}");
1579        let last = out.len() - 1;
1580        let accel_end = (out.points()[last].velocities[0].si_value()
1581            - out.points()[last - 1].velocities[0].si_value())
1582            / dt;
1583        assert!(accel_end.abs() < 0.5, "end accel: {accel_end}");
1584        assert_strictly_increasing(&out);
1585    }
1586
1587    #[test]
1588    fn test_minimum_jerk_derived_duration_matches_quintic_factors() {
1589        let wps = [[rev(0.0)], [rev(1.0)]];
1590        let limits = rev_limits(1.0, 1.0);
1591        let mut out = CuTrajectoryPayload::<1, 512>::new();
1592        let status = with_profile(
1593            &wps,
1594            &limits,
1595            CuTrajectoryProfile::MinimumJerk,
1596            &CuTimingOverrides::default(),
1597            Time::new::<second>(0.005),
1598            &mut out,
1599        )
1600        .unwrap();
1601        assert_eq!(status, CuTimingStatus::Success);
1602        // Rest-to-rest quintic peaks: |v| = 1.875 D/T, |a| = (10/sqrt(3)) D/T^2.
1603        let expected = 1.875f64.max((10.0f64 / 3.0f64.sqrt()).sqrt());
1604        assert!((out.duration().get::<second>() - expected).abs() < 1e-9);
1605        let (lo, hi) = max_sampled_velocity(&out);
1606        assert!(hi <= 1.0 + 1e-9, "velocity limit exceeded: {hi}");
1607        assert!(lo >= -1e-9, "unexpected reverse motion: {lo}");
1608        assert_strictly_increasing(&out);
1609    }
1610
1611    #[test]
1612    fn test_scurve_respects_velocity_acceleration_and_rest() {
1613        let wps = [[rev(0.0)], [rev(1.0)]];
1614        let limits = rev_limits_jerk(0.5, 1.0, 2.0);
1615        let mut out = CuTrajectoryPayload::<1, 1024>::new();
1616        let status = with_profile(
1617            &wps,
1618            &limits,
1619            CuTrajectoryProfile::SCurve,
1620            &CuTimingOverrides::default(),
1621            Time::new::<second>(0.005),
1622            &mut out,
1623        )
1624        .unwrap();
1625        assert_eq!(status, CuTimingStatus::Success);
1626        // Jerk time 0.5 s, no constant-accel phase, 1 s cruise: T = 3.0.
1627        assert!((out.duration().get::<second>() - 3.0).abs() < 1e-9);
1628        let (lo, hi) = max_sampled_velocity(&out);
1629        assert!(hi <= 0.5 + 1e-9, "velocity limit exceeded: {hi}");
1630        assert!(lo >= -1e-9, "unexpected reverse motion: {lo}");
1631        assert_eq!(out.points()[0].velocities[0].si_value(), 0.0);
1632        let last = out.len() - 1;
1633        assert_eq!(out.points()[last].positions[0].si_value(), 1.0);
1634        assert_eq!(out.points()[last].velocities[0].si_value(), 0.0);
1635        let mut peak_accel: f64 = 0.0;
1636        let mut i = 1;
1637        while i + 2 < out.len() {
1638            let gap = out.points()[i + 1].time_from_start.get::<second>()
1639                - out.points()[i - 1].time_from_start.get::<second>();
1640            if (gap - 0.01).abs() < 1e-12 {
1641                let accel = (out.points()[i + 1].velocities[0].si_value()
1642                    - out.points()[i - 1].velocities[0].si_value())
1643                    / gap;
1644                peak_accel = peak_accel.max(accel.abs());
1645            }
1646            i += 1;
1647        }
1648        assert!(
1649            peak_accel <= 1.0 + 0.05,
1650            "accel limit exceeded: {peak_accel}"
1651        );
1652        assert_strictly_increasing(&out);
1653    }
1654
1655    #[test]
1656    fn test_scurve_infinite_jerk_matches_trapezoidal_timing() {
1657        let wps = [[rev(0.0)], [rev(1.0)]];
1658        let limits = rev_limits(0.5, 1.0);
1659        assert_eq!(limits.max_jerks, None);
1660        let mut out = CuTrajectoryPayload::<1, 256>::new();
1661        let status = with_profile(
1662            &wps,
1663            &limits,
1664            CuTrajectoryProfile::SCurve,
1665            &CuTimingOverrides::default(),
1666            Time::new::<second>(0.02),
1667            &mut out,
1668        )
1669        .unwrap();
1670        assert_eq!(status, CuTimingStatus::Success);
1671        // Trapezoidal: T = D/v + v/a = 2.0 + 0.5.
1672        assert!((out.duration().get::<second>() - 2.5).abs() < 1e-9);
1673        let (_, peak) = max_sampled_velocity(&out);
1674        assert!(peak <= 0.5 + 1e-9, "velocity limit exceeded: {peak}");
1675    }
1676
1677    #[test]
1678    fn test_scurve_rejects_non_positive_jerk() {
1679        let wps = [[rev(0.0)], [rev(1.0)]];
1680        for bad in [0.0, -1.0, f64::NAN, f64::NEG_INFINITY] {
1681            let limits = rev_limits_jerk(0.5, 1.0, bad);
1682            let mut out = CuTrajectoryPayload::<1>::new();
1683            let status = with_profile(
1684                &wps,
1685                &limits,
1686                CuTrajectoryProfile::SCurve,
1687                &CuTimingOverrides::default(),
1688                Time::new::<second>(0.01),
1689                &mut out,
1690            )
1691            .unwrap();
1692            assert_eq!(status, CuTimingStatus::InvalidInput, "jerk: {bad}");
1693        }
1694    }
1695
1696    #[test]
1697    fn test_rest_only_profiles_reject_nonzero_via() {
1698        let wps = [[rev(0.0)], [rev(1.0)]];
1699        let limits = rev_limits(1.0, 1.0);
1700        let vias = [[zero_vel()], [rev_vel(0.5)]];
1701        let timing = CuTimingOverrides {
1702            segment_durations: None,
1703            waypoint_velocities: Some(vias.as_slice()),
1704        };
1705        for profile in [
1706            CuTrajectoryProfile::Trapezoidal,
1707            CuTrajectoryProfile::SCurve,
1708        ] {
1709            let mut out = CuTrajectoryPayload::<1>::new();
1710            let status = with_profile(
1711                &wps,
1712                &limits,
1713                profile,
1714                &timing,
1715                Time::new::<second>(0.01),
1716                &mut out,
1717            )
1718            .unwrap();
1719            assert_eq!(status, CuTimingStatus::InvalidInput);
1720        }
1721    }
1722
1723    #[test]
1724    fn test_timing_shape_violations_rejected() {
1725        let wps = [[rev(0.0)], [rev(1.0)], [rev(2.0)]];
1726        let limits = rev_limits(1.0, 1.0);
1727        let dt = Time::new::<second>(0.01);
1728        // One duration for two segments.
1729        let one = [Time::new::<second>(1.0)];
1730        let short_timing = CuTimingOverrides {
1731            segment_durations: Some(one.as_slice()),
1732            waypoint_velocities: None,
1733        };
1734        // Via count must match waypoint count.
1735        let vias = [[zero_vel()], [zero_vel()]];
1736        let via_timing = CuTimingOverrides {
1737            segment_durations: None,
1738            waypoint_velocities: Some(vias.as_slice()),
1739        };
1740        // Fixed durations below the trapezoidal minimum (2.0 s here) ...
1741        let short = [Time::new::<second>(0.5), Time::new::<second>(0.5)];
1742        let fast_timing = CuTimingOverrides {
1743            segment_durations: Some(short.as_slice()),
1744            waypoint_velocities: None,
1745        };
1746        // ... and non-positive fixed durations.
1747        let zero = [Time::new::<second>(0.0), Time::new::<second>(1.0)];
1748        let zero_timing = CuTimingOverrides {
1749            segment_durations: Some(zero.as_slice()),
1750            waypoint_velocities: None,
1751        };
1752        for timing in [short_timing, via_timing, fast_timing, zero_timing] {
1753            let mut out = CuTrajectoryPayload::<1>::new();
1754            let status = with_profile(
1755                &wps,
1756                &limits,
1757                CuTrajectoryProfile::Trapezoidal,
1758                &timing,
1759                dt,
1760                &mut out,
1761            )
1762            .unwrap();
1763            assert_eq!(status, CuTimingStatus::InvalidInput);
1764        }
1765    }
1766
1767    #[test]
1768    fn test_fixed_durations_produce_exact_total() {
1769        let wps = [[rev(0.0)], [rev(1.0)], [rev(1.5)]];
1770        let limits = rev_limits(3.0, 5.0);
1771        let fixed = [Time::new::<second>(0.7), Time::new::<second>(1.3)];
1772        let timing = CuTimingOverrides {
1773            segment_durations: Some(fixed.as_slice()),
1774            waypoint_velocities: None,
1775        };
1776        for profile in [CuTrajectoryProfile::Cubic, CuTrajectoryProfile::MinimumJerk] {
1777            let mut out = CuTrajectoryPayload::<1, 512>::new();
1778            let status = with_profile(
1779                &wps,
1780                &limits,
1781                profile,
1782                &timing,
1783                Time::new::<second>(0.01),
1784                &mut out,
1785            )
1786            .unwrap();
1787            assert_eq!(status, CuTimingStatus::Success);
1788            assert!((out.duration().get::<second>() - 2.0).abs() < 1e-9);
1789            let last = out.len() - 1;
1790            assert_eq!(out.points()[last].positions[0].si_value(), 1.5);
1791            assert_eq!(out.points()[last].velocities[0].si_value(), 0.0);
1792            assert_strictly_increasing(&out);
1793        }
1794    }
1795
1796    #[test]
1797    fn test_cubic_velocity_violation_rejected() {
1798        let wps = [[rev(0.0)], [rev(1.0)]];
1799        let limits = rev_limits(0.5, 5.0);
1800        let mut out = CuTrajectoryPayload::<1, 512>::new();
1801        // Trapezoidal minimum is 2.1 s; cubic rest-to-rest peaks at
1802        // 1.5 / 2.1 = 0.71 rad/s, above the 0.5 rad/s limit.
1803        let status = with_profile(
1804            &wps,
1805            &limits,
1806            CuTrajectoryProfile::Cubic,
1807            &CuTimingOverrides::default(),
1808            Time::new::<second>(0.01),
1809            &mut out,
1810        )
1811        .unwrap();
1812        assert_eq!(status, CuTimingStatus::InvalidInput);
1813        assert!(out.is_empty());
1814    }
1815
1816    #[test]
1817    fn test_parameterize_tags_cubic_interpolation() {
1818        let mut out = CuTrajectoryPayload::<1, 512>::new();
1819        trapezoid(
1820            &[[rev(0.0)], [rev(1.0)]],
1821            &rev_limits(0.5, 1.0),
1822            Time::new::<second>(0.02),
1823            &mut out,
1824        )
1825        .unwrap();
1826        assert_eq!(out.interpolation, CuTrajectoryInterpolation::Cubic);
1827    }
1828
1829    #[test]
1830    fn test_params_validate() {
1831        assert!(CuTimingParams::<1>::default().validate().is_err());
1832        let mut params = CuTimingParams {
1833            limits: rev_limits(0.5, 1.0),
1834            ..Default::default()
1835        };
1836        assert!(params.validate().is_ok());
1837        assert_eq!(params.sample_period.get::<second>(), 0.02);
1838        params.sample_period = Time::new::<second>(0.0);
1839        assert!(params.validate().is_err());
1840        params.sample_period = Time::new::<second>(0.01);
1841        params.limits = rev_limits_jerk(0.5, 1.0, -1.0);
1842        assert!(params.validate().is_err());
1843        params.limits = rev_limits(0.5, 1.0);
1844        params.limits.max_accelerations = [CuJointAcceleration::Fixed];
1845        assert!(params.validate().is_err());
1846    }
1847
1848    #[cfg(feature = "std")]
1849    #[test]
1850    fn test_params_from_ron() {
1851        let parse = |text: &str| {
1852            let cfg: ComponentConfig =
1853                ron::from_str(&format!("#![enable(unwrap_newtypes)] {text}")).unwrap();
1854            cfg.deserialize_into::<CuTimingParams<1>>()
1855        };
1856        let params = parse(
1857            r#"{"limits": {"max_velocities": [{"revolute": 0.5}], "max_accelerations": [{"revolute": 1.0}]}}"#,
1858        )
1859        .unwrap();
1860        assert_eq!(params.limits, rev_limits(0.5, 1.0));
1861        assert_eq!(params.profile, CuTrajectoryProfile::Trapezoidal);
1862        assert_eq!(params.sample_period.get::<second>(), 0.02);
1863        let params = parse(
1864            r#"{"limits": {"max_velocities": [{"revolute": 0.5}], "max_accelerations": [{"revolute": 1.0}],
1865                "max_jerks": [{"revolute": 2.0}]}, "profile": "s_curve", "sample_period": 0.01}"#,
1866        )
1867        .unwrap();
1868        assert_eq!(params.limits, rev_limits_jerk(0.5, 1.0, 2.0));
1869        assert_eq!(params.profile, CuTrajectoryProfile::SCurve);
1870        assert_eq!(params.sample_period.get::<second>(), 0.01);
1871        assert!(
1872            parse(
1873                r#"{"limits": {"max_velocities": [{"revolute": 0.5}], "max_accelerations": [{"revolute": 1.0}]},
1874                "sample_period_s": 0.01}"#
1875            )
1876            .is_err()
1877        );
1878        assert!(parse(r#"{"profile": "cubic"}"#).is_err());
1879    }
1880}