Skip to main content

copp\diag/
error.rs

1use clarabel::solver::{SolverError as ClarabelSolverError, SolverStatus as ClarabelSolverStatus};
2use thiserror::Error;
3
4/// The error type for COPP.
5#[derive(Error)]
6pub enum CoppError {
7    /// Some error occurred in filesystem I/O operations, such as opening/creating log files.
8    #[error("I/O error: {0}")]
9    IoError(#[from] std::io::Error),
10    /// Some error occurred in the Constraint struct, such as invalid input or computation failure.
11    #[error("Some error occurred in the Constraint struct: {0}")]
12    ConstraintError(#[from] ConstraintError),
13    /// Some error occurred in the Path struct, such as invalid input or computation failure.
14    #[error("Some error occurred in the Path struct: {0}")]
15    PathError(#[from] PathError),
16    /// Some error occurred while evaluating user-provided robot inverse dynamics.
17    #[error("Robot dynamics error: {0}")]
18    RobotDynamicsError(#[from] RobotDynamicsError),
19    /// The specified optimization problem is reported infeasible by the backend solver.
20    #[error("{0} reported an infeasibility: {1}")]
21    Infeasible(String, String),
22    /// The specified optimization problem is reported unbounded by the backend solver.
23    #[error("{0} reported an unboundedness: {1}")]
24    Unbounded(String, String),
25    /// The solver/backend rejects the given model or data as invalid input.
26    #[error("{0} reported an invalid input: {1}")]
27    InvalidInput(String, String),
28    /// The solver/backend rejects the provided configuration options.
29    #[error("{0} reported an invalid options: {1}")]
30    InvalidOptions(String, String),
31    /// The Clarabel solver returned a concrete internal solver error.
32    #[error("{0} reported an error in Clarabel solver: {1}")]
33    ClarabelSolverError(String, #[source] ClarabelSolverError),
34    /// The Clarabel solver terminated with a non-success status.
35    #[error("{0} reported a failure in Clarabel solver with status {1}")]
36    ClarabelSolverStatus(String, ClarabelSolverStatus),
37    /// A backend-specific or uncategorized runtime error is reported.
38    #[error("{0} reported an error: {1}")]
39    Other(String, String),
40}
41
42/// Error type for user-provided robot inverse-dynamics evaluation.
43///
44/// This type intentionally stores a free-form message so robot integrations can
45/// report errors from external dynamics libraries without fitting them into a
46/// fixed COPP-specific taxonomy.
47#[derive(Error, Debug, Clone, PartialEq, Eq)]
48#[error("{message}")]
49pub struct RobotDynamicsError {
50    message: String,
51}
52
53impl RobotDynamicsError {
54    /// Construct a robot dynamics error from a display-ready message.
55    #[inline]
56    pub fn new(message: impl Into<String>) -> Self {
57        Self {
58            message: message.into(),
59        }
60    }
61
62    /// Borrow the underlying error message.
63    #[inline]
64    pub fn message(&self) -> &str {
65        &self.message
66    }
67
68    /// Consume the error and return its message.
69    #[inline]
70    pub fn into_message(self) -> String {
71        self.message
72    }
73}
74
75impl From<String> for RobotDynamicsError {
76    #[inline]
77    fn from(message: String) -> Self {
78        Self::new(message)
79    }
80}
81
82impl From<&str> for RobotDynamicsError {
83    #[inline]
84    fn from(message: &str) -> Self {
85        Self::new(message)
86    }
87}
88
89/// Error type for constraint storage/query operations.
90///
91/// # Usage recommendation
92/// For public-facing application code, prefer using [`CoppError`](crate::diag::CoppError)
93/// as the unified error type.
94///
95/// [`ConstraintError`](crate::diag::ConstraintError) is automatically converted into [`CoppError`](crate::diag::CoppError) via
96/// `From<ConstraintError> for CoppError`, so `?` can be used directly when
97/// your function returns `Result<_, CoppError>`.
98#[derive(Error, Debug)]
99pub enum ConstraintError {
100    /// Input station sequence is not strictly increasing.
101    #[error("`s` must be strictly increasing; first violation at local index {index}.")]
102    NonIncreasingS {
103        /// Local index of the first non-increasing station.
104        index: usize,
105    },
106
107    /// Input matrix/vector dimensions are incompatible with expected shape.
108    #[error("Input dimensions do not match the expected shape.")]
109    NoMatchDimensions,
110
111    /// Input ordering contract is violated.
112    #[error("Input order does not satisfy the expected contract.")]
113    NoMatchOrder,
114
115    /// Signed upper/lower bounds violate strict feasibility contract.
116    #[error(
117        "`{bound_name}` requires strict signed limits at every station: upper bound > 0 and lower bound < 0."
118    )]
119    InvalidSignedBounds {
120        /// Name of the bound array that violated the signed-bound contract.
121        bound_name: &'static str,
122    },
123
124    /// Requested station interval is outside currently stored constraints range.
125    #[error("Requested station interval is out of bounds: idx_s={idx_s}, len={len}.")]
126    OutOfSBounds {
127        /// Requested starting station index.
128        idx_s: usize,
129        /// Number of stations currently stored.
130        len: usize,
131    },
132
133    /// `a` violates positivity / non-negativity preconditions.
134    #[error(
135        "Input `a` violates positivity requirements (must be nonnegative, and strictly positive where required)."
136    )]
137    NonPositiveA,
138
139    /// Linearization floor must be strictly positive.
140    #[error("Linearization floor must be strictly positive.")]
141    NonPositiveLinearizationFloor,
142
143    /// Required path-derivative data is missing in the requested interval.
144    #[error(
145        "Required derivative data (`q`, `dq`, `ddq`, `dddq`) is not fully available in the requested interval."
146    )]
147    NoGivenQInfo,
148
149    /// Linearized jerk constraints are unavailable at the requested station.
150    #[error(
151        "Linearized jerk constraints are unavailable at idx_s={idx_s}; valid range is [{}, {}).",
152        valid_range.0,
153        valid_range.1
154    )]
155    LinearJerkNotAvailable {
156        /// Requested station index.
157        idx_s: usize,
158        /// Half-open station range for which linearized jerk data is available.
159        valid_range: (usize, usize),
160    },
161
162    /// Dynamic-model data has not been provided.
163    #[error("Required dynamic-model information is not available.")]
164    NoDynamic,
165
166    /// Reference profile is infeasible under current constraints.
167    #[error("Reference profile is infeasible under current constraints.")]
168    InfeasibleReference,
169
170    /// Requested interval is empty.
171    #[error("Requested interval is empty: {start} <= idx_s < {end}.")]
172    EmptyInterval {
173        /// Start index of the requested interval.
174        start: usize,
175        /// End index of the requested interval.
176        end: usize,
177    },
178}
179
180/// Error type for path construction and path evaluation APIs.
181///
182/// This error is returned by path-related modules such as [`Path`](crate::path::Path),
183/// strict spline interpolation, and tolerance-bounded waypoint fitting when
184/// input data, parameter ranges, construction budgets, or numerical systems are
185/// invalid.
186#[derive(Error, Debug)]
187pub enum PathError {
188    /// Tolerance-bounded waypoint fitting could not produce an audited path.
189    ///
190    /// This covers invalid smoothing configuration/input, exhausted knot or
191    /// refinement budgets, numerical factorization failures, parameter
192    /// resolution loss, and failure of the final physical-unit interval audit.
193    /// The variant means construction returned no unchecked fallback curve.
194    ///
195    /// The contained text is intended for human diagnostics and may become more
196    /// specific over time; programmatic handling should match this enum variant
197    /// rather than depend on the exact message.
198    #[error("path smoothing failed: {message}")]
199    Smoothing {
200        /// Human-readable construction-stage diagnostic.
201        message: String,
202    },
203    /// Path dimension is invalid (typically zero).
204    #[error("invalid dimension: {dim}")]
205    InvalidDimension {
206        /// Requested path dimension.
207        dim: usize,
208    },
209    /// Path parameter range is invalid (must satisfy finite `s_min < s_max`).
210    #[error("invalid s range: [{s_min}, {s_max}]")]
211    InvalidRange {
212        /// Lower endpoint of the invalid parameter range.
213        s_min: f64,
214        /// Upper endpoint of the invalid parameter range.
215        s_max: f64,
216    },
217    /// Spline order is invalid (must satisfy required minimum/order constraints).
218    #[error("invalid spline order: {order}, expected >= 3")]
219    InvalidOrder {
220        /// Requested spline order.
221        order: usize,
222    },
223    /// Matrix/tensor shapes are incompatible for the requested operation.
224    #[error("dimension mismatch")]
225    DimensionMismatch,
226    /// The path representation cannot provide the requested derivative order.
227    #[error("unsupported derivative order: requested {requested}, available {available}")]
228    UnsupportedDerivativeOrder {
229        /// Requested derivative order.
230        requested: usize,
231        /// Highest derivative order available from the path representation.
232        available: usize,
233    },
234    /// A user-provided path evaluator returned or threw an error.
235    #[error("path evaluator error: {message}")]
236    EvaluatorError {
237        /// Display-ready evaluator error message.
238        message: String,
239    },
240    /// Waypoint sequence is too short to build a valid path.
241    #[error("not enough waypoints: {n}, expected >= 2")]
242    NotEnoughWaypoints {
243        /// Number of supplied waypoints.
244        n: usize,
245    },
246    /// Query parameter `s` is outside the configured valid interval.
247    #[error("s out of range [{s_min}, {s_max}] at index {index}: {value}")]
248    OutOfRangeS {
249        /// Lower endpoint of the valid parameter range.
250        s_min: f64,
251        /// Upper endpoint of the valid parameter range.
252        s_max: f64,
253        /// Index of the out-of-range query value.
254        index: usize,
255        /// Out-of-range query value.
256        value: f64,
257    },
258    /// Boundary conditions are not supported for the requested spline order.
259    #[error("unsupported boundary for order={order}")]
260    UnsupportedBoundary {
261        /// Requested spline order.
262        order: usize,
263    },
264    /// Internal linear system is singular and cannot be solved robustly.
265    #[error("singular linear system")]
266    SingularSystem,
267}
268
269/// Force the debug format of ToppError to be the same as the display format, which is more concise and user-friendly.
270impl std::fmt::Debug for CoppError {
271    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
272        write!(f, "{self}")
273    }
274}
275
276/// Check whether a given s_interval is valid.
277#[inline(always)]
278pub(crate) fn check_s_interval_valid(
279    function_name: &str,
280    idx_s_start: usize,
281    idx_s_final: usize,
282) -> Result<(), CoppError> {
283    if idx_s_final < idx_s_start + 2 {
284        Err(CoppError::InvalidInput(
285            function_name.into(),
286            format!(
287                "The final index {idx_s_final} must be at least two positions after the start index {idx_s_start} in Topp2Problem."
288            ),
289        ))
290    } else {
291        Ok(())
292    }
293}
294
295/// Check the positivity of a given tolerance.
296#[inline(always)]
297pub(crate) fn check_abs_rel_tol(
298    function_name: &str,
299    abs_tol_name: &str,
300    abs_tol: f64,
301    rel_tol_name: &str,
302    rel_tol: f64,
303) -> Result<(), CoppError> {
304    check_options_not_nan_infinite(function_name, abs_tol_name, abs_tol)?;
305    check_options_not_nan_infinite(function_name, rel_tol_name, rel_tol)?;
306    if abs_tol >= 0.0 && rel_tol >= 0.0 && (abs_tol > f64::EPSILON || rel_tol > f64::EPSILON) {
307        Ok(())
308    } else {
309        Err(CoppError::InvalidOptions(
310            function_name.into(),
311            format!(
312                "At least one of {abs_tol_name} = {abs_tol} and {rel_tol_name} = {rel_tol} must be strictly positive."
313            ),
314        ))
315    }
316}
317
318#[inline(always)]
319pub(crate) fn check_options_not_nan_infinite(
320    function_name: &str,
321    var_name: &str,
322    var_value: f64,
323) -> Result<(), CoppError> {
324    if var_value.is_nan() {
325        Err(CoppError::InvalidOptions(
326            function_name.into(),
327            format!("{var_name} = {var_value} must not be NaN",),
328        ))
329    } else if var_value.is_infinite() {
330        Err(CoppError::InvalidOptions(
331            function_name.into(),
332            format!("{var_name} = {var_value} must not be infinite",),
333        ))
334    } else {
335        Ok(())
336    }
337}
338
339/// Check that a scalar input value is neither NaN nor infinite.
340///
341/// This is the input-data counterpart of [`check_not_nan_infinite`], which is
342/// reserved for option validation and therefore returns [`InvalidOptions`](CoppError::InvalidOptions).
343#[inline(always)]
344pub(crate) fn check_input_not_nan_infinite(
345    function_name: &str,
346    var_name: &str,
347    var_value: f64,
348) -> Result<(), CoppError> {
349    if var_value.is_nan() {
350        Err(CoppError::InvalidInput(
351            function_name.into(),
352            format!("{var_name} = {var_value} must not be NaN"),
353        ))
354    } else if var_value.is_infinite() {
355        Err(CoppError::InvalidInput(
356            function_name.into(),
357            format!("{var_name} = {var_value} must not be infinite"),
358        ))
359    } else {
360        Ok(())
361    }
362}
363
364/// Check that every value in an input slice is neither NaN nor infinite.
365///
366/// The reported variable name includes the first offending local index, which
367/// keeps interpolation and solver diagnostics precise without duplicating this
368/// scan logic in each module.
369#[inline(always)]
370pub(crate) fn check_input_slice_not_nan_infinite(
371    function_name: &str,
372    slice_name: &str,
373    values: &[f64],
374) -> Result<(), CoppError> {
375    if let Some((index, value)) = values.iter().enumerate().find(|(_, value)| value.is_nan()) {
376        Err(CoppError::InvalidInput(
377            function_name.into(),
378            format!("`{slice_name}[{index}]` = {value} must not be NaN"),
379        ))
380    } else if let Some((index, value)) = values
381        .iter()
382        .enumerate()
383        .find(|(_, value)| value.is_infinite())
384    {
385        Err(CoppError::InvalidInput(
386            function_name.into(),
387            format!("`{slice_name}[{index}]` = {value} must not be infinite"),
388        ))
389    } else {
390        Ok(())
391    }
392}
393
394/// Check that an input slice is strictly increasing.
395///
396/// This check assumes finiteness has already been verified when NaN-specific
397/// diagnostics are needed; otherwise comparisons involving NaN simply fail the
398/// ordering contract at the first affected pair.
399#[inline(always)]
400pub(crate) fn check_input_strictly_increasing(
401    function_name: &str,
402    slice_name: &str,
403    values: &[f64],
404) -> Result<(), CoppError> {
405    if let Some(index) = values.windows(2).position(|pair| pair[0] >= pair[1]) {
406        Err(CoppError::InvalidInput(
407            function_name.into(),
408            format!(
409                "`{slice_name}` must be strictly increasing; first violation at local index {index}."
410            ),
411        ))
412    } else {
413        Ok(())
414    }
415}
416
417/// Check that an input scalar is nonnegative.
418///
419/// This helper first rejects NaN and infinity so downstream numerical code can
420/// safely use ordinary comparisons and square roots.
421#[inline(always)]
422pub(crate) fn check_input_non_negative(
423    function_name: &str,
424    var_name: &str,
425    var_value: f64,
426) -> Result<(), CoppError> {
427    check_input_not_nan_infinite(function_name, var_name, var_value)?;
428    if var_value < 0.0 {
429        Err(CoppError::InvalidInput(
430            function_name.into(),
431            format!("{var_name} = {var_value} must be nonnegative"),
432        ))
433    } else {
434        Ok(())
435    }
436}
437
438/// Check that every value in an input slice is nonnegative.
439///
440/// The function reports the first offending entry and is intended for data
441/// profiles such as sampled `a(s)` that must be valid before interpolation.
442#[inline(always)]
443pub(crate) fn check_input_slice_non_negative(
444    function_name: &str,
445    slice_name: &str,
446    values: &[f64],
447) -> Result<(), CoppError> {
448    check_input_slice_not_nan_infinite(function_name, slice_name, values)?;
449    if let Some((index, value)) = values.iter().enumerate().find(|(_, value)| **value < 0.0) {
450        Err(CoppError::InvalidInput(
451            function_name.into(),
452            format!("`{slice_name}[{index}]` = {value} must be nonnegative"),
453        ))
454    } else {
455        Ok(())
456    }
457}
458
459/// Check that an input length is at least the required minimum.
460///
461/// Callers pass display-ready length names such as `` `s.len()` `` so error
462/// messages can mirror the notation used in each API contract.
463#[inline(always)]
464pub(crate) fn check_input_len_at_least(
465    function_name: &str,
466    len_name: &str,
467    len: usize,
468    min_len: usize,
469) -> Result<(), CoppError> {
470    if len < min_len {
471        Err(CoppError::InvalidInput(
472            function_name.into(),
473            format!("{len_name} = {len} must be at least {min_len}"),
474        ))
475    } else {
476        Ok(())
477    }
478}
479
480/// Check that two input lengths are equal.
481///
482/// This is used for shape contracts where both the actual and reference lengths
483/// are useful to report to the caller.
484#[inline(always)]
485pub(crate) fn check_input_len_equal(
486    function_name: &str,
487    lhs_name: &str,
488    lhs_len: usize,
489    rhs_name: &str,
490    rhs_len: usize,
491) -> Result<(), CoppError> {
492    if lhs_len != rhs_len {
493        Err(CoppError::InvalidInput(
494            function_name.into(),
495            format!("{lhs_name} = {lhs_len} must equal {rhs_name} = {rhs_len}"),
496        ))
497    } else {
498        Ok(())
499    }
500}
501
502/// Check that an input slice is not empty.
503///
504/// This helper is for APIs where an empty user-provided sample grid is
505/// ambiguous and should be rejected before interpolation starts.
506#[inline(always)]
507pub(crate) fn check_input_not_empty(
508    function_name: &str,
509    slice_name: &str,
510    len: usize,
511) -> Result<(), CoppError> {
512    if len == 0 {
513        Err(CoppError::InvalidInput(
514            function_name.into(),
515            format!("{slice_name} must not be empty"),
516        ))
517    } else {
518        Ok(())
519    }
520}
521
522/// Check the non-negativity.
523#[inline(always)]
524pub(crate) fn check_non_negative(
525    function_name: &str,
526    var_name: &str,
527    var_value: f64,
528) -> Result<(), CoppError> {
529    check_options_not_nan_infinite(function_name, var_name, var_value)?;
530    if var_value < 0.0 {
531        Err(CoppError::InvalidOptions(
532            function_name.into(),
533            format!("{var_name} = {var_value} must be strictly non-negative"),
534        ))
535    } else {
536        Ok(())
537    }
538}
539
540/// Check the positivity of a given tolerance.
541#[inline(always)]
542pub(crate) fn check_strictly_positive(
543    function_name: &str,
544    var_name: &str,
545    var_value: f64,
546) -> Result<(), CoppError> {
547    check_options_not_nan_infinite(function_name, var_name, var_value)?;
548    if var_value < f64::EPSILON {
549        Err(CoppError::InvalidOptions(
550            function_name.into(),
551            format!("{var_name} = {var_value} must be strictly positive"),
552        ))
553    } else {
554        Ok(())
555    }
556}
557
558#[inline(always)]
559/// Validate the two third-order boundary states `(a, b)`.
560///
561/// Exact zero selects the root-order branch; it is deliberately not a
562/// floating-point closeness test. A strictly positive
563/// `a = dot(s)^2`, however small, is a nonzero-speed state whose adjacent
564/// interval is validated separately.
565///
566/// For an exact zero at the initial station, the one-sided expansion
567/// `a(s_start + x) = 2 b_start x + O(x^2)` requires `b_start >= 0`.
568/// At the terminal station,
569/// `a(s_final - x) = -2 b_final x + O(x^2)` requires `b_final <= 0`.
570/// A zero `b` is a stationary endpoint; a strict compatible sign is a
571/// finite-time simple zero.
572pub(crate) fn check_boundary_state_copp3_valid(
573    a_boundary: (f64, f64),
574    b_boundary: (f64, f64),
575) -> Result<(), CoppError> {
576    for (name, value) in [
577        ("initial a", a_boundary.0),
578        ("terminal a", a_boundary.1),
579        ("initial b", b_boundary.0),
580        ("terminal b", b_boundary.1),
581    ] {
582        check_input_not_nan_infinite("copp3", name, value)?;
583    }
584    if a_boundary.0 < 0.0 {
585        return Err(CoppError::InvalidInput(
586            "copp3_socp".into(),
587            format!("The initial a = {} must be non-negative.", a_boundary.0),
588        ));
589    }
590    if a_boundary.1 < 0.0 {
591        return Err(CoppError::InvalidInput(
592            "copp3_socp".into(),
593            format!("The terminal a = {} must be non-negative.", a_boundary.1),
594        ));
595    }
596    // These are exact root-order branches, not floating-point closeness tests.
597    // A strictly positive `a = dot(s)^2`, however small, may have nonzero
598    // `b = ddot(s)` while the adjacent continuous interval remains positive.
599    // Its interval certificate is responsible for deciding that geometry.
600    // Treating every `a < EPSILON` as zero rejects valid scale-dependent
601    // boundary states and can break an otherwise bitwise-exact window seam.
602    if a_boundary.0 == 0.0 && b_boundary.0 < 0.0 {
603        return Err(CoppError::InvalidInput(
604            "copp3_socp".into(),
605            format!(
606                "The initial a = {} is zero, so the initial b = {} must be non-negative.",
607                a_boundary.0, b_boundary.0
608            ),
609        ));
610    }
611    if a_boundary.1 == 0.0 && b_boundary.1 > 0.0 {
612        return Err(CoppError::InvalidInput(
613            "copp3_socp".into(),
614            format!(
615                "The terminal a = {} is zero, so the terminal b = {} must be non-positive.",
616                a_boundary.1, b_boundary.1
617            ),
618        ));
619    }
620    Ok(())
621}