Skip to main content

kinavis_kernel/
error.rs

1//! Errors of the value types and numeric kernels.
2//!
3//! [`KernelError`] uses only the kernel's vocabulary: non-finite or
4//! out-of-range numbers, undersized buffers or stores, unsolvable systems,
5//! undefined quantities, missing inputs, reversed clocks. Algorithm-specific
6//! errors (deviation tables, sailings) belong to the crate that owns the
7//! algorithm, which wraps this type via `From`.
8//!
9//! `#[non_exhaustive]`: variants may be added in a minor release; match with a
10//! wildcard arm.
11//!
12//! [`ensure_finite`] and [`ensure_range`] are public so adapters validate
13//! sensor input the same way the value types do.
14
15use crate::inline::InlineStr;
16use core::fmt;
17
18/// Result alias.
19pub type Result<T> = core::result::Result<T, KernelError>;
20
21/// Maximum bytes of offending input carried by an error.
22///
23/// Enough to identify the input; bounded so errors need no allocator and cannot
24/// be inflated by input.
25pub const EXCERPT_BYTES: usize = 32;
26
27/// Excerpt of offending input.
28pub type Excerpt = InlineStr<EXCERPT_BYTES>;
29
30/// Kernel operation failure.
31///
32/// No operation panics on caller data; every failure is reported through this
33/// type.
34#[derive(Debug, Clone, PartialEq)]
35#[non_exhaustive]
36pub enum KernelError {
37    /// Parameter is `NaN` or infinite.
38    NotFinite {
39        /// Parameter name.
40        parameter: &'static str,
41        /// Value supplied.
42        value: f64,
43    },
44
45    /// Parameter is finite but out of range.
46    OutOfRange {
47        /// Parameter name.
48        parameter: &'static str,
49        /// Value supplied.
50        value: f64,
51        /// Minimum, inclusive.
52        min: f64,
53        /// Maximum, inclusive.
54        max: f64,
55    },
56
57    /// Fewer items than required: interpolation nodes, route waypoints, fit
58    /// samples.
59    InsufficientData {
60        /// Items present.
61        found: usize,
62        /// Items required.
63        required: usize,
64        /// Operation that required them.
65        context: &'static str,
66    },
67
68    /// Not one of the eight cardinal/intercardinal abbreviations.
69    UnknownCardinalDirection {
70        /// Input, truncated.
71        direction: Excerpt,
72    },
73
74    /// Caller-supplied output buffer too small.
75    ///
76    /// Returned by slice-writing calls so a short buffer is an error, not a
77    /// silent truncation.
78    BufferTooSmall {
79        /// Values produced.
80        needed: usize,
81        /// Buffer length.
82        found: usize,
83    },
84
85    /// Inline store capacity exceeded.
86    ///
87    /// Collections are fixed-capacity inline stores; their bounds are public
88    /// constants, so callers can check beforehand.
89    CapacityExceeded {
90        /// What was being built.
91        context: &'static str,
92        /// Items required.
93        needed: usize,
94        /// Capacity.
95        capacity: usize,
96    },
97
98    /// Linear system has no unique solution.
99    ///
100    /// For a parametric fit: the sample courses do not constrain the requested
101    /// coefficients (e.g. five coefficients from nodes on one semicircle).
102    SingularSystem {
103        /// System being solved.
104        context: &'static str,
105    },
106
107    /// Matrix is not a valid covariance (symmetric positive definite): e.g.
108    /// zero observation variance, collapsed innovation covariance.
109    NotCovariance {
110        /// Intended role of the matrix.
111        context: &'static str,
112    },
113
114    /// Iterative solver did not converge.
115    ///
116    /// The deviation inverse fails this way when deviation changes faster than
117    /// 1° per degree of heading, so several compass courses map to one magnetic
118    /// course; the compass needs re-swinging.
119    NotConverged {
120        /// Iterations run.
121        iterations: u32,
122        /// Final residual.
123        residual: f64,
124    },
125
126    /// String could not be parsed.
127    Parse {
128        /// Expected value type.
129        what: &'static str,
130        /// Input, truncated.
131        input: Excerpt,
132    },
133
134    /// Mathematically undefined for the inputs: rhumb line through a pole,
135    /// direction of a zero vector, great circle between antipodes.
136    Indeterminate {
137        /// Undefined quantity.
138        quantity: &'static str,
139    },
140
141    /// Required input unavailable: snapshot without position, environment
142    /// sample without tide, empty estimator history.
143    Missing {
144        /// Missing input.
145        what: &'static str,
146    },
147
148    /// Result exists but is not representable: instant beyond range, counter
149    /// overflow.
150    Unrepresentable {
151        /// Unrepresentable quantity.
152        what: &'static str,
153    },
154
155    /// Instants in the wrong order for the elapsed time requested (clock
156    /// stepped back, or arguments swapped).
157    TimeReversed {
158        /// Amount by which the later instant precedes the earlier.
159        by: core::time::Duration,
160    },
161
162    /// Height referred to a different vertical datum than required.
163    VerticalDatumMismatch {
164        /// Required datum.
165        required: crate::geodesy::VerticalDatum,
166        /// Datum of the height.
167        found: crate::geodesy::VerticalDatum,
168    },
169
170    /// Data requested outside its validity interval (leap-second table past
171    /// expiry, magnetic model past epoch).
172    OutsideValidity {
173        /// Requested data.
174        data: &'static str,
175    },
176}
177
178/// Checks that a value is finite.
179///
180/// # Errors
181///
182/// [`KernelError::NotFinite`] for `NaN` or infinity.
183pub fn ensure_finite(parameter: &'static str, value: f64) -> Result<()> {
184    if value.is_finite() {
185        Ok(())
186    } else {
187        Err(KernelError::NotFinite { parameter, value })
188    }
189}
190
191/// Checks that a value is finite and within `[min, max]`.
192///
193/// # Errors
194///
195/// [`KernelError::NotFinite`] for `NaN` or infinity;
196/// [`KernelError::OutOfRange`] outside the interval.
197pub fn ensure_range(parameter: &'static str, value: f64, min: f64, max: f64) -> Result<()> {
198    ensure_finite(parameter, value)?;
199    if value < min || value > max {
200        return Err(KernelError::OutOfRange {
201            parameter,
202            value,
203            min,
204            max,
205        });
206    }
207    Ok(())
208}
209
210/// Allowed interval in words. `f64::MIN_POSITIVE` as the minimum reads as
211/// "greater than 0", and `f64::MIN` or `f64::MAX` as unbounded, so no bound is
212/// printed as hundreds of digits.
213fn write_interval(f: &mut fmt::Formatter<'_>, min: f64, max: f64) -> fmt::Result {
214    let positive = min > 0.0 && min <= f64::MIN_POSITIVE;
215    let below = min > f64::MIN;
216    let above = max < f64::MAX;
217    match (positive, below, above) {
218        (true, _, true) => write!(f, "greater than 0 and at most {max}"),
219        (true, _, false) => f.write_str("greater than 0"),
220        (false, true, true) => write!(f, "between {min} and {max}"),
221        (false, true, false) => write!(f, "at least {min}"),
222        (false, false, true) => write!(f, "at most {max}"),
223        (false, false, false) => f.write_str("finite"),
224    }
225}
226
227impl fmt::Display for KernelError {
228    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
229        match self {
230            Self::NotFinite { parameter, value } => {
231                write!(f, "{parameter} must be a finite number, got {value}")
232            }
233            Self::OutOfRange {
234                parameter,
235                value,
236                min,
237                max,
238            } => {
239                write!(f, "{parameter} out of range: {value}. Must be ")?;
240                write_interval(f, *min, *max)
241            }
242            Self::InsufficientData {
243                found,
244                required,
245                context,
246            } => write!(f, "{context} needs at least {required}, and has {found}"),
247            Self::UnknownCardinalDirection { direction } => write!(
248                f,
249                "unknown cardinal direction: {direction}. Expected one of N, NE, E, SE, S, SW, W, NW"
250            ),
251            Self::CapacityExceeded {
252                context,
253                needed,
254                capacity,
255            } => write!(
256                f,
257                "{context} needs room for {needed}, and the limit is {capacity}"
258            ),
259            Self::BufferTooSmall { needed, found } => write!(
260                f,
261                "output buffer holds {found} values, {needed} are needed"
262            ),
263            Self::SingularSystem { context } => {
264                write!(f, "singular system while solving {context}")
265            }
266            Self::NotCovariance { context } => {
267                write!(f, "{context} is not a covariance matrix")
268            }
269            Self::NotConverged {
270                iterations,
271                residual,
272            } => write!(
273                f,
274                "solver did not converge after {iterations} iterations, residual {residual}"
275            ),
276            Self::Parse { what, input } => {
277                write!(f, "could not read {input:?} as a {what}")
278            }
279            Self::Indeterminate { quantity } => {
280                write!(f, "{quantity} is indeterminate for these inputs")
281            }
282            Self::Missing { what } => write!(f, "{what} is not available"),
283            Self::Unrepresentable { what } => write!(f, "{what} cannot be represented"),
284            Self::TimeReversed { by } => {
285                write!(f, "time ran backwards by {} s", by.as_secs_f64())
286            }
287            Self::VerticalDatumMismatch { required, found } => write!(
288                f,
289                "a height above {found:?} was given where one above {required:?} is required"
290            ),
291            Self::OutsideValidity { data } => {
292                write!(f, "the {data} is not valid for the requested moment")
293            }
294        }
295    }
296}
297
298// `std::error::Error` re-exports `core::error::Error` since Rust 1.81; one impl
299// covers `std` and `no_std`.
300impl core::error::Error for KernelError {}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use alloc::string::ToString;
306
307    #[test]
308    fn every_variant_has_a_message() {
309        let errors = [
310            KernelError::NotFinite {
311                parameter: "course",
312                value: f64::NAN,
313            },
314            KernelError::OutOfRange {
315                parameter: "course",
316                value: 400.0,
317                min: 0.0,
318                max: 360.0,
319            },
320            KernelError::InsufficientData {
321                found: 1,
322                required: 2,
323                context: "interpolation",
324            },
325            KernelError::UnknownCardinalDirection {
326                direction: Excerpt::new("XYZ"),
327            },
328            KernelError::BufferTooSmall {
329                needed: 36,
330                found: 8,
331            },
332            KernelError::CapacityExceeded {
333                context: "a deviation table",
334                needed: 90,
335                capacity: 72,
336            },
337            KernelError::SingularSystem {
338                context: "parametric fit",
339            },
340            KernelError::NotCovariance {
341                context: "the observation noise",
342            },
343            KernelError::TimeReversed {
344                by: core::time::Duration::from_secs(18),
345            },
346            KernelError::OutsideValidity {
347                data: "leap second table",
348            },
349            KernelError::VerticalDatumMismatch {
350                required: crate::geodesy::VerticalDatum::Ellipsoid,
351                found: crate::geodesy::VerticalDatum::MeanSeaLevel,
352            },
353            KernelError::NotConverged {
354                iterations: 64,
355                residual: 1.0,
356            },
357            KernelError::Parse {
358                what: "latitude",
359                input: Excerpt::new("north-ish"),
360            },
361            KernelError::Indeterminate {
362                quantity: "a rhumb line through a pole",
363            },
364            KernelError::Missing {
365                what: "the vessel's position",
366            },
367            KernelError::Unrepresentable {
368                what: "a moment beyond the end of time",
369            },
370        ];
371        for error in errors {
372            assert!(!error.to_string().is_empty(), "{error:?}");
373        }
374    }
375
376    #[test]
377    fn errors_compare_by_value() {
378        let a = KernelError::OutOfRange {
379            parameter: "x",
380            value: 1.0,
381            min: 0.0,
382            max: 0.5,
383        };
384        assert_eq!(a, a.clone());
385        assert_ne!(
386            a,
387            KernelError::Missing {
388                what: "the vessel's position"
389            }
390        );
391    }
392
393    #[test]
394    fn an_unbounded_side_is_not_printed_as_a_number() {
395        let message = |min, max| {
396            KernelError::OutOfRange {
397                parameter: "x",
398                value: 0.0,
399                min,
400                max,
401            }
402            .to_string()
403        };
404        assert_eq!(
405            message(0.0, 1.0),
406            "x out of range: 0. Must be between 0 and 1"
407        );
408        assert_eq!(
409            message(f64::MIN_POSITIVE, f64::MAX),
410            "x out of range: 0. Must be greater than 0"
411        );
412        assert_eq!(
413            message(f64::MIN_POSITIVE, 1e6),
414            "x out of range: 0. Must be greater than 0 and at most 1000000"
415        );
416        assert_eq!(
417            message(1.0, f64::MAX),
418            "x out of range: 0. Must be at least 1"
419        );
420        assert_eq!(
421            message(f64::MIN, -1.0),
422            "x out of range: 0. Must be at most -1"
423        );
424    }
425
426    #[test]
427    fn the_checks_report_what_they_reject() {
428        assert!(ensure_finite("x", 1.0).is_ok());
429        assert!(matches!(
430            ensure_finite("x", f64::NAN),
431            Err(KernelError::NotFinite { parameter: "x", .. })
432        ));
433        assert!(matches!(
434            ensure_range("x", 2.0, 0.0, 1.0),
435            Err(KernelError::OutOfRange { parameter: "x", .. })
436        ));
437        assert!(matches!(
438            ensure_range("x", f64::INFINITY, 0.0, 1.0),
439            Err(KernelError::NotFinite { .. })
440        ));
441    }
442}