Skip to main content

bearingpro/
deviation.rs

1//! Deviation tables and the interpolation that reads them.
2//!
3//! A deviation table records, for a set of compass headings, how far the ship's
4//! compass card is displaced from magnetic north by the vessel's own magnetism:
5//!
6//! ```text
7//! magnetic course = compass course + deviation(compass course)
8//! ```
9//!
10//! Deviation is therefore a *periodic* function of the **compass** course, and
11//! everything in this module treats it as such.
12//!
13//! # Interpolation methods
14//!
15//! | Method | Continuity | Nodes needed | Use when |
16//! |---|---|---|---|
17//! | [`InterpolationMethod::Linear`] | C⁰ | 2 | you want a result that can never overshoot the tabulated values |
18//! | [`InterpolationMethod::Cubic`] | C² | 3 | the swing is dense and you want a smooth curve |
19//! | [`InterpolationMethod::Parametric`] | analytic | 5 | you want the classical A–E coefficient model, or want to smooth a noisy swing |
20//!
21//! All three are periodic: the arc from the last node through `360°/0°` back to
22//! the first node is a real interval, not a flat extrapolation.
23//!
24//! # Example
25//!
26//! ```rust
27//! use bearingpro::{DeviationTable, InterpolationMethod};
28//!
29//! let mut table = DeviationTable::from_step(90)?;
30//! table.set_deviation(0, 10.0)?;
31//! table.set_deviation(180, -10.0)?;
32//!
33//! // Halfway between the 270° node (0.0) and the 0° node (10.0), the long way
34//! // round through north — a segment the pre-1.0 implementation could not see.
35//! let deviation = table.deviation_at(315.0, InterpolationMethod::Linear, None)?;
36//! assert!((deviation.degrees() - 5.0).abs() < 1e-12);
37//! # Ok::<(), bearingpro::NavigationError>(())
38//! ```
39
40use alloc::string::ToString;
41use alloc::vec;
42use alloc::vec::Vec;
43
44use crate::angle::{
45    ensure_range, wrap180, wrap360, Compass, Deviation, Direction, True, Variation,
46    MAX_DEVIATION_DEG,
47};
48use crate::error::{NavigationError, Result};
49use crate::linalg::{solve_cyclic_tridiagonal, solve_dense};
50use crate::math;
51
52/// Number of values [`DeviationTable::from_deviation_vec`] expects: 0° to 350° in 10° steps.
53pub const STANDARD_TABLE_LEN: usize = 36;
54
55/// The eight cardinal and intercardinal directions, with their compass courses.
56pub const CARDINAL_DIRECTIONS: [(&str, i32); 8] = [
57    ("N", 0),
58    ("NE", 45),
59    ("E", 90),
60    ("SE", 135),
61    ("S", 180),
62    ("SW", 225),
63    ("W", 270),
64    ("NW", 315),
65];
66
67/// How to read deviation for a heading that is not a table node.
68///
69/// This enum is `#[non_exhaustive]`; match with a wildcard arm.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
71#[non_exhaustive]
72#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
73pub enum InterpolationMethod {
74    /// Periodic linear interpolation between neighbouring nodes.
75    ///
76    /// The default: it is exact at the nodes, never overshoots them, and needs
77    /// only two nodes.
78    #[default]
79    Linear,
80    /// Periodic cubic spline with continuous first and second derivatives.
81    ///
82    /// Exact at the nodes and smooth across `360°/0°`. Falls back to
83    /// [`InterpolationMethod::Linear`] for tables with fewer than three nodes.
84    Cubic,
85    /// The classical five-coefficient deviation model, fitted by least squares.
86    ///
87    /// `δ = A + B·sin(y) + C·cos(y) + D·sin(2y) + E·cos(2y)`
88    ///
89    /// Unlike the others this is a *fit*, not an interpolation: it does not
90    /// reproduce the nodes exactly, which is exactly what you want when the
91    /// swing contains observation noise.
92    Parametric,
93    /// Periodic shape-preserving cubic, by the Fritsch–Carlson method.
94    ///
95    /// Smooth like [`InterpolationMethod::Cubic`], but it cannot overshoot:
96    /// between two nodes the curve stays between their values, and it never
97    /// invents a wiggle the data does not show. A cubic spline buys its second
98    /// derivative by allowing both, which on a swing with an abrupt step can put
99    /// the interpolated deviation outside anything that was ever observed.
100    ///
101    /// Slightly less smooth — continuous first derivative but not second — and
102    /// the better default when the numbers matter more than the curve.
103    ShapePreserving,
104}
105
106/// How to read the table, and with which coefficients.
107///
108/// Every conversion in [`crate::navigation_solutions`] takes
109/// `impl Into<Interpolation>`, so passing a bare [`InterpolationMethod`] is
110/// enough for the common case, and this struct is there when you need to pin
111/// coefficients as well.
112///
113/// # Example
114///
115/// ```rust
116/// use bearingpro::{
117///     navigation_solutions::convert_compass_course_to_true_course, CompassCourse,
118///     DeviationCoefficients, DeviationTable, Interpolation, InterpolationMethod, Variation,
119/// };
120///
121/// let table = DeviationTable::from_deviation_vec(vec![0.0; 36])?;
122/// let coefficients = DeviationCoefficients {
123///     a: Some(1.0),
124///     ..DeviationCoefficients::default()
125/// };
126///
127/// // A bare method...
128/// let plain = convert_compass_course_to_true_course(
129///     CompassCourse::new(10.0)?,
130///     Variation::ZERO,
131///     &table,
132///     InterpolationMethod::Linear,
133/// )?;
134/// assert_eq!(plain.deviation.degrees(), 0.0);
135///
136/// // ...or a method with coefficients held fixed.
137/// let pinned = convert_compass_course_to_true_course(
138///     CompassCourse::new(10.0)?,
139///     Variation::ZERO,
140///     &table,
141///     Interpolation {
142///         method: InterpolationMethod::Parametric,
143///         coefficients: Some(&coefficients),
144///     },
145/// )?;
146/// assert!((pinned.deviation.degrees() - 1.0).abs() < 1e-9);
147/// # Ok::<(), bearingpro::NavigationError>(())
148/// ```
149#[derive(Debug, Clone, Copy, Default)]
150pub struct Interpolation<'a> {
151    /// Which method to use.
152    pub method: InterpolationMethod,
153    /// Coefficients to hold fixed, for [`InterpolationMethod::Parametric`].
154    pub coefficients: Option<&'a DeviationCoefficients>,
155}
156
157impl From<InterpolationMethod> for Interpolation<'_> {
158    fn from(method: InterpolationMethod) -> Self {
159        Self {
160            method,
161            coefficients: None,
162        }
163    }
164}
165
166/// One heading of a swing, as it is actually observed.
167///
168/// Deviation is not measured directly. What is measured is a bearing of
169/// something whose true direction is known — a transit, a distant object, the
170/// azimuth of a heavenly body — taken by the compass on each heading in turn.
171/// The deviation follows from the three:
172///
173/// ```text
174/// deviation = reference bearing − variation − observed bearing
175/// ```
176///
177/// # Example
178///
179/// ```rust
180/// use bearingpro::{
181///     CompassBearing, CompassCourse, DeviationTable, NavigationError, SwingObservation,
182///     TrueBearing, Variation,
183/// };
184///
185/// fn main() -> Result<(), NavigationError> {
186///     let variation = Variation::new(-2.0)?;
187///     // A transit whose charted direction is 045°T, observed from four headings.
188///     let transit = TrueBearing::new(45.0)?;
189///     let observations = [
190///         (0.0, 48.5),
191///         (90.0, 46.0),
192///         (180.0, 45.5),
193///         (270.0, 48.0),
194///     ]
195///     .into_iter()
196///     .map(|(heading, observed)| {
197///         Ok(SwingObservation {
198///             compass_heading: CompassCourse::new(heading)?,
199///             observed_bearing: CompassBearing::new(observed)?,
200///             reference_bearing: transit,
201///         })
202///     })
203///     .collect::<Result<Vec<_>, NavigationError>>()?;
204///
205///     let table = DeviationTable::from_swing(&observations, variation)?;
206///
207///     // On north the compass read 048.5 for something that is really 045.0,
208///     // with 2°W variation: deviation is 045.0 − (−2.0) − 048.5 = −1.5°.
209///     assert_eq!(table.deviation_at_node(0).unwrap().degrees(), -1.5);
210///     Ok(())
211/// }
212/// ```
213#[derive(Debug, Clone, Copy, PartialEq)]
214#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
215pub struct SwingObservation {
216    /// Heading the ship was steadied on, by compass.
217    pub compass_heading: Direction<Compass>,
218    /// Bearing of the reference object, as read from the same compass.
219    pub observed_bearing: Direction<Compass>,
220    /// True bearing the reference object is known to lie on.
221    pub reference_bearing: Direction<True>,
222}
223
224impl SwingObservation {
225    /// The deviation this observation implies, given the variation in force.
226    ///
227    /// # Errors
228    ///
229    /// Returns [`NavigationError::OutOfRange`] if the three bearings imply a
230    /// deviation beyond half a turn, which means one of them is wrong.
231    pub fn deviation(&self, variation: Variation) -> Result<Deviation> {
232        Deviation::new(wrap180(
233            self.reference_bearing.degrees()
234                - variation.degrees()
235                - self.observed_bearing.degrees(),
236        ))
237    }
238}
239
240/// One row of a deviation table.
241#[derive(Debug, Clone, Copy, PartialEq)]
242#[cfg_attr(
243    feature = "serde",
244    derive(serde::Serialize, serde::Deserialize),
245    serde(try_from = "(i32, f64)", into = "(i32, f64)")
246)]
247pub struct DeviationNode {
248    course: i32,
249    deviation: f64,
250}
251
252impl DeviationNode {
253    /// The compass course of this node, in `0..360` degrees.
254    #[must_use]
255    pub const fn course(&self) -> i32 {
256        self.course
257    }
258
259    /// The tabulated deviation at this node.
260    #[must_use]
261    pub fn deviation(&self) -> Deviation {
262        // The value was validated when it entered the table.
263        Deviation::new(self.deviation).unwrap_or(Deviation::ZERO)
264    }
265
266    /// The tabulated deviation in degrees.
267    #[must_use]
268    pub const fn deviation_degrees(&self) -> f64 {
269        self.deviation
270    }
271}
272
273/// Coefficients for the parametric deviation model.
274///
275/// Any field left `None` is fitted from the table by least squares; any field
276/// set to `Some` is held fixed and the remaining ones are fitted around it.
277///
278/// # Example
279///
280/// ```rust
281/// use bearingpro::{DeviationCoefficients, DeviationTable, InterpolationMethod};
282///
283/// let table = DeviationTable::from_deviation_vec(vec![0.0; 36])?;
284///
285/// // Force a constant 1° index error, fit the rest.
286/// let coefficients = DeviationCoefficients {
287///     a: Some(1.0),
288///     ..DeviationCoefficients::default()
289/// };
290///
291/// let deviation = table.deviation_at(
292///     250.0,
293///     InterpolationMethod::Parametric,
294///     Some(&coefficients),
295/// )?;
296/// assert!((deviation.degrees() - 1.0).abs() < 1e-9);
297/// # Ok::<(), bearingpro::NavigationError>(())
298/// ```
299#[derive(Debug, Clone, Copy, PartialEq, Default)]
300#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
301pub struct DeviationCoefficients {
302    /// Constant deviation, usually a compass index or alignment error.
303    pub a: Option<f64>,
304    /// Semicircular deviation in phase with `sin(course)`.
305    pub b: Option<f64>,
306    /// Semicircular deviation in phase with `cos(course)`.
307    pub c: Option<f64>,
308    /// Quadrantal deviation in phase with `sin(2·course)`.
309    pub d: Option<f64>,
310    /// Quadrantal deviation in phase with `cos(2·course)`.
311    pub e: Option<f64>,
312}
313
314impl DeviationCoefficients {
315    fn as_array(self) -> [Option<f64>; 5] {
316        [self.a, self.b, self.c, self.d, self.e]
317    }
318
319    fn validate(self) -> Result<()> {
320        for (name, value) in [
321            ("coefficient A", self.a),
322            ("coefficient B", self.b),
323            ("coefficient C", self.c),
324            ("coefficient D", self.d),
325            ("coefficient E", self.e),
326        ] {
327            if let Some(value) = value {
328                ensure_range(name, value, -MAX_DEVIATION_DEG, MAX_DEVIATION_DEG)?;
329            }
330        }
331        Ok(())
332    }
333}
334
335/// A fully determined set of deviation coefficients.
336#[derive(Debug, Clone, Copy, PartialEq, Default)]
337#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
338pub struct SmithCoefficients {
339    /// Constant deviation.
340    pub a: f64,
341    /// Semicircular deviation in phase with `sin(course)`.
342    pub b: f64,
343    /// Semicircular deviation in phase with `cos(course)`.
344    pub c: f64,
345    /// Quadrantal deviation in phase with `sin(2·course)`.
346    pub d: f64,
347    /// Quadrantal deviation in phase with `cos(2·course)`.
348    pub e: f64,
349}
350
351impl SmithCoefficients {
352    /// Evaluates the model at a compass course, in degrees.
353    #[must_use]
354    pub fn deviation_at(&self, course_degrees: f64) -> f64 {
355        let basis = parametric_basis(course_degrees);
356        self.a * basis[0]
357            + self.b * basis[1]
358            + self.c * basis[2]
359            + self.d * basis[3]
360            + self.e * basis[4]
361    }
362
363    /// Converts to the partially-specified form accepted by the interpolator.
364    #[must_use]
365    pub const fn as_input(&self) -> DeviationCoefficients {
366        DeviationCoefficients {
367            a: Some(self.a),
368            b: Some(self.b),
369            c: Some(self.c),
370            d: Some(self.d),
371            e: Some(self.e),
372        }
373    }
374
375    fn from_array(values: [f64; 5]) -> Self {
376        Self {
377            a: values[0],
378            b: values[1],
379            c: values[2],
380            d: values[3],
381            e: values[4],
382        }
383    }
384}
385
386/// Summary of a swing, produced by [`DeviationTable::analyze`].
387#[derive(Debug, Clone, Copy, PartialEq)]
388#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
389pub struct DeviationAnalysis {
390    /// Least-squares fit of the five-coefficient model.
391    pub coefficients: SmithCoefficients,
392    /// Root-mean-square distance between the tabulated values and the fit, in degrees.
393    ///
394    /// Large values mean the compass has deviation the classical model does not
395    /// describe — or that the swing contains a bad observation.
396    pub rms_residual: f64,
397    /// Largest single residual, in degrees.
398    pub max_residual: f64,
399    /// Largest tabulated deviation magnitude, in degrees.
400    pub max_abs_deviation: f64,
401    /// Largest angular gap between adjacent nodes, in degrees, measured periodically.
402    pub max_gap: f64,
403    /// Steepest node-to-node slope, in degrees of deviation per degree of heading.
404    ///
405    /// See [`DeviationTable::max_slope`]; at or above `1.0` the table cannot be
406    /// inverted uniquely.
407    pub max_slope: f64,
408    /// Number of nodes in the table.
409    pub nodes: usize,
410}
411
412/// Deviation as a function of compass course.
413///
414/// The table keeps its nodes sorted and unique, so lookups are a binary search
415/// and iteration order is deterministic. Every constructor validates its input:
416/// a table that exists is always usable.
417#[derive(Debug, Clone, PartialEq)]
418#[cfg_attr(
419    feature = "serde",
420    derive(serde::Serialize, serde::Deserialize),
421    serde(try_from = "Vec<(i32, f64)>", into = "Vec<(i32, f64)>")
422)]
423pub struct DeviationTable {
424    nodes: Vec<DeviationNode>,
425}
426
427impl Default for DeviationTable {
428    /// A table of zero deviations from 0° to 350° in 10° steps.
429    fn default() -> Self {
430        Self {
431            nodes: (0..STANDARD_TABLE_LEN)
432                .map(|index| DeviationNode {
433                    // `index < 36`, so this cannot overflow.
434                    course: i32::try_from(index).unwrap_or(0) * 10,
435                    deviation: 0.0,
436                })
437                .collect(),
438        }
439    }
440}
441
442impl DeviationTable {
443    /// Builds a table of zero deviations with a fixed step between headings.
444    ///
445    /// # Errors
446    ///
447    /// Returns [`NavigationError::InvalidStep`] unless `step` is in `1..=180`.
448    /// A step of `0` used to abort the process; a negative step used to produce a
449    /// silent one-node table.
450    pub fn from_step(step: i32) -> Result<Self> {
451        if !(1..=180).contains(&step) {
452            return Err(NavigationError::InvalidStep { step });
453        }
454        let stride = usize::try_from(step).unwrap_or(1);
455        let nodes = (0..360)
456            .step_by(stride)
457            .map(|course| DeviationNode {
458                course,
459                deviation: 0.0,
460            })
461            .collect();
462        Ok(Self { nodes })
463    }
464
465    /// Builds a table of zero deviations on the eight cardinal and intercardinal points.
466    #[must_use]
467    pub fn from_cardinal_directions() -> Self {
468        let mut nodes: Vec<DeviationNode> = CARDINAL_DIRECTIONS
469            .iter()
470            .map(|&(_, course)| DeviationNode {
471                course,
472                deviation: 0.0,
473            })
474            .collect();
475        nodes.sort_unstable_by_key(DeviationNode::course);
476        Self { nodes }
477    }
478
479    /// Builds a table from explicit `(compass course, deviation)` pairs.
480    ///
481    /// Courses are normalised into `0..360` with Euclidean remainder, so `-350`
482    /// becomes `10`. The pre-1.0 implementation used `%`, which left `-350` as a
483    /// negative key that no lookup could ever match.
484    ///
485    /// # Errors
486    ///
487    /// - [`NavigationError::InsufficientNodes`] if fewer than two pairs remain.
488    /// - [`NavigationError::DuplicateCourse`] if two pairs normalise to the same course.
489    /// - [`NavigationError::NotFinite`] or [`NavigationError::OutOfRange`] for a bad deviation.
490    pub fn from_vec(deviations: Vec<(i32, f64)>) -> Result<Self> {
491        let mut nodes = Vec::with_capacity(deviations.len());
492        for (course, deviation) in deviations {
493            ensure_range(
494                "deviation",
495                deviation,
496                -MAX_DEVIATION_DEG,
497                MAX_DEVIATION_DEG,
498            )?;
499            nodes.push(DeviationNode {
500                course: course.rem_euclid(360),
501                deviation,
502            });
503        }
504        Self::from_nodes(nodes)
505    }
506
507    /// Builds a table from 36 deviations for the headings 0°, 10°, … 350°.
508    ///
509    /// # Errors
510    ///
511    /// Returns [`NavigationError::UnexpectedTableLength`] unless exactly
512    /// [`STANDARD_TABLE_LEN`] values are supplied. The pre-1.0 implementation
513    /// silently zero-filled a short slice and silently dropped a long one.
514    pub fn from_deviation_vec(deviations: Vec<f64>) -> Result<Self> {
515        if deviations.len() != STANDARD_TABLE_LEN {
516            return Err(NavigationError::UnexpectedTableLength {
517                found: deviations.len(),
518                expected: STANDARD_TABLE_LEN,
519            });
520        }
521        let mut nodes = Vec::with_capacity(STANDARD_TABLE_LEN);
522        for (index, deviation) in deviations.into_iter().enumerate() {
523            ensure_range(
524                "deviation",
525                deviation,
526                -MAX_DEVIATION_DEG,
527                MAX_DEVIATION_DEG,
528            )?;
529            nodes.push(DeviationNode {
530                course: i32::try_from(index).unwrap_or(0) * 10,
531                deviation,
532            });
533        }
534        Self::from_nodes(nodes)
535    }
536
537    /// Builds a table from the raw observations of a swing.
538    ///
539    /// This is the step that used to be the caller's problem: the library took a
540    /// column of deviations, but a swing produces bearings, and turning one into
541    /// the other by hand is where arithmetic slips get in.
542    ///
543    /// Headings are rounded to the nearest whole degree, so a swing steadied on
544    /// 089.6° by compass becomes the 090° node.
545    ///
546    /// # Errors
547    ///
548    /// - [`NavigationError::InsufficientNodes`] for fewer than two observations.
549    /// - [`NavigationError::DuplicateCourse`] if two observations round to the
550    ///   same heading.
551    /// - [`NavigationError::OutOfRange`] if an observation implies an impossible
552    ///   deviation.
553    pub fn from_swing(observations: &[SwingObservation], variation: Variation) -> Result<Self> {
554        let mut nodes = Vec::with_capacity(observations.len());
555        for observation in observations {
556            let deviation = observation.deviation(variation)?;
557            // A validated direction is in `[0, 360)`, so this cannot overflow.
558            let heading = math::round_to_i32(observation.compass_heading.degrees());
559            nodes.push(DeviationNode {
560                course: heading.rem_euclid(360),
561                deviation: deviation.degrees(),
562            });
563        }
564        Self::from_nodes(nodes)
565    }
566
567    fn from_nodes(mut nodes: Vec<DeviationNode>) -> Result<Self> {
568        nodes.sort_unstable_by_key(DeviationNode::course);
569        if let Some(duplicate) = nodes
570            .windows(2)
571            .find(|pair| {
572                pair.first().map(DeviationNode::course) == pair.last().map(DeviationNode::course)
573            })
574            .and_then(|pair| pair.first())
575        {
576            return Err(NavigationError::DuplicateCourse {
577                course: duplicate.course,
578            });
579        }
580        if nodes.len() < 2 {
581            return Err(NavigationError::InsufficientNodes {
582                found: nodes.len(),
583                required: 2,
584                context: "a deviation table",
585            });
586        }
587        Ok(Self { nodes })
588    }
589
590    /// The table's nodes, sorted by compass course.
591    #[must_use]
592    pub fn nodes(&self) -> &[DeviationNode] {
593        &self.nodes
594    }
595
596    /// Number of nodes in the table, always at least two.
597    #[must_use]
598    pub fn len(&self) -> usize {
599        self.nodes.len()
600    }
601
602    /// Always `false`: a table cannot be constructed empty.
603    #[must_use]
604    pub fn is_empty(&self) -> bool {
605        self.nodes.is_empty()
606    }
607
608    /// Replaces the deviation at an existing node.
609    ///
610    /// The course is normalised into `0..360` first.
611    ///
612    /// # Errors
613    ///
614    /// - [`NavigationError::CourseNotInTable`] if the course is not a node; use
615    ///   [`DeviationTable::insert_deviation`] to add one.
616    /// - [`NavigationError::NotFinite`] or [`NavigationError::OutOfRange`] for a bad value.
617    pub fn set_deviation(&mut self, course: i32, deviation: f64) -> Result<()> {
618        ensure_range(
619            "deviation",
620            deviation,
621            -MAX_DEVIATION_DEG,
622            MAX_DEVIATION_DEG,
623        )?;
624        let course = course.rem_euclid(360);
625        match self
626            .nodes
627            .binary_search_by_key(&course, DeviationNode::course)
628        {
629            Ok(index) => {
630                if let Some(node) = self.nodes.get_mut(index) {
631                    node.deviation = deviation;
632                }
633                Ok(())
634            }
635            Err(_) => Err(NavigationError::CourseNotInTable { course }),
636        }
637    }
638
639    /// Sets the deviation at a course, adding the node if it does not exist yet.
640    ///
641    /// # Errors
642    ///
643    /// Returns [`NavigationError::NotFinite`] or [`NavigationError::OutOfRange`]
644    /// for a deviation that is not a usable angle.
645    pub fn insert_deviation(&mut self, course: i32, deviation: f64) -> Result<()> {
646        ensure_range(
647            "deviation",
648            deviation,
649            -MAX_DEVIATION_DEG,
650            MAX_DEVIATION_DEG,
651        )?;
652        let course = course.rem_euclid(360);
653        match self
654            .nodes
655            .binary_search_by_key(&course, DeviationNode::course)
656        {
657            Ok(index) => {
658                if let Some(node) = self.nodes.get_mut(index) {
659                    node.deviation = deviation;
660                }
661            }
662            Err(index) => self
663                .nodes
664                .insert(index, DeviationNode { course, deviation }),
665        }
666        Ok(())
667    }
668
669    /// Sets the deviation on one of the eight cardinal points.
670    ///
671    /// # Errors
672    ///
673    /// - [`NavigationError::UnknownCardinalDirection`] for an unrecognised name.
674    /// - [`NavigationError::CourseNotInTable`] if that point is not a node of this table.
675    /// - [`NavigationError::NotFinite`] or [`NavigationError::OutOfRange`] for a bad value.
676    pub fn set_deviation_by_direction(&mut self, direction: &str, deviation: f64) -> Result<()> {
677        let course = cardinal_course(direction)?;
678        self.set_deviation(course, deviation)
679    }
680
681    /// Reads the tabulated deviation on one of the eight cardinal points.
682    ///
683    /// Returns `None` if the name is unknown or that point is not a node.
684    #[must_use]
685    pub fn get_deviation_by_direction(&self, direction: &str) -> Option<Deviation> {
686        let course = cardinal_course(direction).ok()?;
687        self.deviation_at_node(course)
688    }
689
690    /// Reads the tabulated deviation at an exact node, without interpolating.
691    #[must_use]
692    pub fn deviation_at_node(&self, course: i32) -> Option<Deviation> {
693        let course = course.rem_euclid(360);
694        self.nodes
695            .binary_search_by_key(&course, DeviationNode::course)
696            .ok()
697            .and_then(|index| self.nodes.get(index))
698            .map(DeviationNode::deviation)
699    }
700
701    /// Largest gap between adjacent nodes, in degrees, measured around the full circle.
702    #[must_use]
703    pub fn max_gap(&self) -> f64 {
704        let mut max_gap: f64 = 0.0;
705        for pair in self.nodes.windows(2) {
706            if let (Some(low), Some(high)) = (pair.first(), pair.last()) {
707                max_gap = max_gap.max(f64::from(high.course - low.course));
708            }
709        }
710        if let (Some(first), Some(last)) = (self.nodes.first(), self.nodes.last()) {
711            max_gap = max_gap.max(360.0 - f64::from(last.course - first.course));
712        }
713        max_gap
714    }
715
716    /// Steepest node-to-node rate of change of deviation, in degrees per degree.
717    ///
718    /// This is what decides whether the table can be inverted. Once deviation
719    /// changes by a full degree for each degree of heading, two different compass
720    /// courses produce the same magnetic course, and asking "what compass course
721    /// gives this true course" stops having a single answer. See
722    /// [`DeviationTable::is_invertible`].
723    #[must_use]
724    pub fn max_slope(&self) -> f64 {
725        let count = self.nodes.len();
726        let mut steepest: f64 = 0.0;
727        for index in 0..count {
728            let span = if index + 1 < count {
729                self.node_course(index + 1) - self.node_course(index)
730            } else {
731                360.0 - self.node_course(index) + self.node_course(0)
732            };
733            if span > 0.0 {
734                let rise = self.node_value(index + 1) - self.node_value(index);
735                steepest = steepest.max(math::abs(rise) / span);
736            }
737        }
738        steepest
739    }
740
741    /// Whether a magnetic course maps back to exactly one compass course.
742    ///
743    /// False means the swing describes a compass that cannot be steered by over
744    /// part of the circle, and should be re-swung or the compass re-adjusted.
745    /// Conversions still work — [`crate::navigation_solutions::convert_true_course_to_compass_course`]
746    /// returns *a* compass course that produces the requested true course — but it
747    /// is no longer necessarily the one you started from.
748    #[must_use]
749    pub fn is_invertible(&self) -> bool {
750        self.max_slope() < 1.0
751    }
752
753    /// Largest tabulated deviation magnitude, in degrees.
754    #[must_use]
755    pub fn max_abs_deviation(&self) -> f64 {
756        self.nodes
757            .iter()
758            .fold(0.0_f64, |acc, node| acc.max(math::abs(node.deviation)))
759    }
760
761    /// Interpolates the deviation for one compass course.
762    ///
763    /// # Errors
764    ///
765    /// - [`NavigationError::NotFinite`] or [`NavigationError::OutOfRange`] if
766    ///   `course_degrees` is not in `[0.0, 360.0]`.
767    /// - [`NavigationError::InsufficientNodes`] or
768    ///   [`NavigationError::SingularSystem`] if a parametric fit is impossible.
769    pub fn deviation_at(
770        &self,
771        course_degrees: f64,
772        method: InterpolationMethod,
773        coefficients: Option<&DeviationCoefficients>,
774    ) -> Result<Deviation> {
775        ensure_range("course", course_degrees, 0.0, 360.0)?;
776        let interpolator = self.prepare(method, coefficients)?;
777        Deviation::new(self.evaluate(&interpolator, wrap360(course_degrees)))
778    }
779
780    /// Interpolates the deviation for several compass courses at once.
781    ///
782    /// Cheaper than repeated [`DeviationTable::deviation_at`] calls: the spline
783    /// or the least-squares fit is built once for the whole batch.
784    ///
785    /// # Errors
786    ///
787    /// As [`DeviationTable::deviation_at`], for the first offending angle.
788    pub fn interpolate_deviation(
789        &self,
790        courses_degrees: &[f64],
791        method: InterpolationMethod,
792        coefficients: Option<&DeviationCoefficients>,
793    ) -> Result<Vec<f64>> {
794        for &course in courses_degrees {
795            ensure_range("course", course, 0.0, 360.0)?;
796        }
797        let interpolator = self.prepare(method, coefficients)?;
798        Ok(courses_degrees
799            .iter()
800            .map(|&course| self.evaluate(&interpolator, wrap360(course)))
801            .collect())
802    }
803
804    /// Fits the five-coefficient deviation model to the whole table by least squares.
805    ///
806    /// # Errors
807    ///
808    /// - [`NavigationError::InsufficientNodes`] if the table has fewer than five nodes.
809    /// - [`NavigationError::SingularSystem`] if the nodes do not constrain the model,
810    ///   for example when they all lie on one semicircle.
811    pub fn smith_coefficients(&self) -> Result<SmithCoefficients> {
812        self.fit_parametric(&DeviationCoefficients::default())
813    }
814
815    /// Summarises the table: fitted coefficients, residuals, extremes and node spacing.
816    ///
817    /// # Errors
818    ///
819    /// As [`DeviationTable::smith_coefficients`].
820    pub fn analyze(&self) -> Result<DeviationAnalysis> {
821        let coefficients = self.smith_coefficients()?;
822        let mut sum_squares = 0.0;
823        let mut max_residual: f64 = 0.0;
824        for node in &self.nodes {
825            let residual = node.deviation - coefficients.deviation_at(f64::from(node.course));
826            sum_squares += residual * residual;
827            max_residual = max_residual.max(math::abs(residual));
828        }
829        let count = self.nodes.len();
830        // `from_nodes` guarantees at least two nodes, so this division is safe.
831        let rms_residual = math::sqrt(sum_squares / math::count_to_f64(count.max(1)));
832        Ok(DeviationAnalysis {
833            coefficients,
834            rms_residual,
835            max_residual,
836            max_abs_deviation: self.max_abs_deviation(),
837            max_gap: self.max_gap(),
838            max_slope: self.max_slope(),
839            nodes: count,
840        })
841    }
842
843    /// Builds whatever the chosen method needs before it can be evaluated.
844    pub(crate) fn prepare(
845        &self,
846        method: InterpolationMethod,
847        coefficients: Option<&DeviationCoefficients>,
848    ) -> Result<Interpolator> {
849        match method {
850            InterpolationMethod::Linear => Ok(Interpolator::Linear),
851            InterpolationMethod::Cubic => {
852                // A cyclic spline system is only well posed from three nodes up.
853                if self.nodes.len() < 3 {
854                    return Ok(Interpolator::Linear);
855                }
856                match self.second_derivatives() {
857                    Some(moments) => Ok(Interpolator::Cubic(moments)),
858                    None => Err(NavigationError::SingularSystem {
859                        context: "the periodic cubic spline",
860                    }),
861                }
862            }
863            InterpolationMethod::Parametric => {
864                let requested = coefficients.copied().unwrap_or_default();
865                Ok(Interpolator::Parametric(self.fit_parametric(&requested)?))
866            }
867            InterpolationMethod::ShapePreserving => {
868                Ok(Interpolator::Hermite(self.shape_preserving_slopes()))
869            }
870        }
871    }
872
873    /// Evaluates a prepared interpolator. `course` must already be in `[0.0, 360.0)`.
874    pub(crate) fn evaluate(&self, interpolator: &Interpolator, course: f64) -> f64 {
875        match interpolator {
876            Interpolator::Linear => self.evaluate_linear(course),
877            Interpolator::Cubic(moments) => self.evaluate_cubic(moments, course),
878            Interpolator::Parametric(coefficients) => coefficients.deviation_at(course),
879            Interpolator::Hermite(slopes) => self.evaluate_hermite(slopes, course),
880        }
881    }
882
883    /// Estimated uncertainty of an interpolated value at `course`, in degrees.
884    ///
885    /// For the two interpolating methods this is the classical `h²·|f''|/8` bound
886    /// on linear interpolation error, approximated by the local second difference
887    /// of the tabulated values. For the parametric fit it is the RMS residual,
888    /// since that method does not pass through the nodes at all.
889    pub(crate) fn uncertainty(&self, interpolator: &Interpolator, course: f64) -> f64 {
890        match interpolator {
891            Interpolator::Parametric(coefficients) => {
892                let mut sum_squares = 0.0;
893                for node in &self.nodes {
894                    let residual =
895                        node.deviation - coefficients.deviation_at(f64::from(node.course));
896                    sum_squares += residual * residual;
897                }
898                math::sqrt(sum_squares / math::count_to_f64(self.nodes.len().max(1)))
899            }
900            Interpolator::Linear | Interpolator::Cubic(_) | Interpolator::Hermite(_) => {
901                let segment = self.locate(course);
902                let count = self.nodes.len();
903                let second_difference = |centre: usize| {
904                    let previous = self.node_value(centre + count - 1);
905                    let current = self.node_value(centre);
906                    let next = self.node_value(centre + 1);
907                    math::abs(previous - 2.0 * current + next)
908                };
909                let left = second_difference(segment.index);
910                let right = second_difference((segment.index + 1) % count);
911                left.max(right) / 8.0
912            }
913        }
914    }
915
916    fn node_value(&self, index: usize) -> f64 {
917        let count = self.nodes.len().max(1);
918        self.nodes
919            .get(index % count)
920            .map_or(0.0, DeviationNode::deviation_degrees)
921    }
922
923    fn node_course(&self, index: usize) -> f64 {
924        let count = self.nodes.len().max(1);
925        self.nodes
926            .get(index % count)
927            .map_or(0.0, |node| f64::from(node.course))
928    }
929
930    /// Finds the segment containing `course`, treating the table as a closed circle.
931    fn locate(&self, course: f64) -> Segment {
932        let count = self.nodes.len();
933        let first = self.node_course(0);
934        let last = self.node_course(count.saturating_sub(1));
935        let wrap_span = 360.0 - last + first;
936
937        if course < first {
938            // Between the last node and the first, having already passed 360°/0°.
939            return Segment {
940                index: count.saturating_sub(1),
941                span: wrap_span,
942                offset: course + 360.0 - last,
943            };
944        }
945
946        let index = self
947            .nodes
948            .partition_point(|node| f64::from(node.course) <= course)
949            .saturating_sub(1);
950
951        if index >= count.saturating_sub(1) {
952            Segment {
953                index: count.saturating_sub(1),
954                span: wrap_span,
955                offset: course - last,
956            }
957        } else {
958            let start = self.node_course(index);
959            Segment {
960                index,
961                span: self.node_course(index + 1) - start,
962                offset: course - start,
963            }
964        }
965    }
966
967    fn evaluate_linear(&self, course: f64) -> f64 {
968        let segment = self.locate(course);
969        let start_value = self.node_value(segment.index);
970        let end_value = self.node_value(segment.index + 1);
971        start_value + (end_value - start_value) * segment.fraction()
972    }
973
974    fn evaluate_cubic(&self, moments: &[f64], course: f64) -> f64 {
975        let segment = self.locate(course);
976        let count = self.nodes.len().max(1);
977        let start_value = self.node_value(segment.index);
978        let end_value = self.node_value(segment.index + 1);
979        let start_moment = moments.get(segment.index % count).copied().unwrap_or(0.0);
980        let end_moment = moments
981            .get((segment.index + 1) % count)
982            .copied()
983            .unwrap_or(0.0);
984
985        let span = segment.span;
986        let slope =
987            (end_value - start_value) / span - span * (2.0 * start_moment + end_moment) / 6.0;
988        let offset = segment.offset;
989
990        start_value
991            + slope * offset
992            + start_moment / 2.0 * offset * offset
993            + (end_moment - start_moment) / (6.0 * span) * offset * offset * offset
994    }
995
996    /// Evaluates the shape-preserving cubic on the segment containing `course`.
997    fn evaluate_hermite(&self, slopes: &[f64], course: f64) -> f64 {
998        let segment = self.locate(course);
999        let count = self.nodes.len().max(1);
1000        let start_value = self.node_value(segment.index);
1001        let end_value = self.node_value(segment.index + 1);
1002        let start_slope = slopes.get(segment.index % count).copied().unwrap_or(0.0);
1003        let end_slope = slopes
1004            .get((segment.index + 1) % count)
1005            .copied()
1006            .unwrap_or(0.0);
1007
1008        // The cubic Hermite basis on the unit interval.
1009        let span = segment.span;
1010        let t = segment.fraction();
1011        let complement = 1.0 - t;
1012        let start_weight = (1.0 + 2.0 * t) * complement * complement;
1013        let start_tangent = t * complement * complement;
1014        let end_weight = t * t * (3.0 - 2.0 * t);
1015        let end_tangent = t * t * (t - 1.0);
1016
1017        start_value * start_weight
1018            + span * start_slope * start_tangent
1019            + end_value * end_weight
1020            + span * end_slope * end_tangent
1021    }
1022
1023    /// Node slopes for the shape-preserving cubic, by Fritsch–Carlson.
1024    ///
1025    /// Where the data turns, the slope is set to zero; elsewhere it is the
1026    /// weighted harmonic mean of the two neighbouring secants, which is what
1027    /// keeps the curve from overshooting.
1028    fn shape_preserving_slopes(&self) -> Vec<f64> {
1029        let count = self.nodes.len();
1030        let gap = |index: usize| {
1031            if index + 1 < count {
1032                self.node_course(index + 1) - self.node_course(index)
1033            } else {
1034                360.0 - self.node_course(index) + self.node_course(0)
1035            }
1036        };
1037
1038        (0..count)
1039            .map(|index| {
1040                let previous = (index + count - 1) % count;
1041                let (before, after) = (gap(previous), gap(index));
1042                if before <= 0.0 || after <= 0.0 {
1043                    return 0.0;
1044                }
1045                let secant_before = (self.node_value(index) - self.node_value(previous)) / before;
1046                let secant_after = (self.node_value(index + 1) - self.node_value(index)) / after;
1047
1048                // A turning point, or a flat spot: level the tangent so the
1049                // curve cannot bulge past the nodes on either side.
1050                if secant_before * secant_after <= 0.0 {
1051                    return 0.0;
1052                }
1053                let weight_before = 2.0 * after + before;
1054                let weight_after = after + 2.0 * before;
1055                (weight_before + weight_after)
1056                    / (weight_before / secant_before + weight_after / secant_after)
1057            })
1058            .collect()
1059    }
1060
1061    /// Second derivatives of the periodic cubic spline, one per node.
1062    ///
1063    /// Solves the cyclic tridiagonal moment system; returns `None` if it is
1064    /// numerically singular.
1065    fn second_derivatives(&self) -> Option<Vec<f64>> {
1066        let count = self.nodes.len();
1067        if count < 3 {
1068            return None;
1069        }
1070
1071        // Gap from node i to node i+1, the last one closing the circle.
1072        let gaps: Vec<f64> = (0..count)
1073            .map(|index| {
1074                if index + 1 < count {
1075                    self.node_course(index + 1) - self.node_course(index)
1076                } else {
1077                    360.0 - self.node_course(index) + self.node_course(0)
1078                }
1079            })
1080            .collect();
1081
1082        let mut sub = vec![0.0; count];
1083        let mut diag = vec![0.0; count];
1084        let mut sup = vec![0.0; count];
1085        let mut rhs = vec![0.0; count];
1086
1087        for index in 0..count {
1088            let previous = (index + count - 1) % count;
1089            let gap_before = *gaps.get(previous)?;
1090            let gap_after = *gaps.get(index)?;
1091
1092            let slope_before = (self.node_value(index) - self.node_value(previous)) / gap_before;
1093            let slope_after = (self.node_value(index + 1) - self.node_value(index)) / gap_after;
1094
1095            *sub.get_mut(index)? = gap_before;
1096            *diag.get_mut(index)? = 2.0 * (gap_before + gap_after);
1097            *sup.get_mut(index)? = gap_after;
1098            *rhs.get_mut(index)? = 6.0 * (slope_after - slope_before);
1099        }
1100
1101        // Row 0 reaches back to node n-1 and row n-1 reaches forward to node 0;
1102        // those two entries live in the matrix corners, not on the diagonals.
1103        let corner_top_right = *sub.first()?;
1104        let corner_bottom_left = *sup.last()?;
1105        *sub.first_mut()? = 0.0;
1106        *sup.last_mut()? = 0.0;
1107
1108        solve_cyclic_tridiagonal(
1109            &sub,
1110            &diag,
1111            &sup,
1112            corner_top_right,
1113            corner_bottom_left,
1114            &rhs,
1115        )
1116    }
1117
1118    /// Least-squares fit of the parametric model, holding any supplied coefficient fixed.
1119    fn fit_parametric(&self, requested: &DeviationCoefficients) -> Result<SmithCoefficients> {
1120        requested.validate()?;
1121        let fixed = requested.as_array();
1122        let free: Vec<usize> = (0..5)
1123            .filter(|&index| fixed.get(index).copied().flatten().is_none())
1124            .collect();
1125
1126        let mut resolved = [0.0_f64; 5];
1127        for (index, value) in fixed.iter().enumerate() {
1128            if let (Some(slot), Some(value)) = (resolved.get_mut(index), *value) {
1129                *slot = value;
1130            }
1131        }
1132
1133        if free.is_empty() {
1134            return Ok(SmithCoefficients::from_array(resolved));
1135        }
1136
1137        if self.nodes.len() < free.len() {
1138            return Err(NavigationError::InsufficientNodes {
1139                found: self.nodes.len(),
1140                required: free.len(),
1141                context: "a parametric deviation fit",
1142            });
1143        }
1144
1145        // Normal equations over the free basis functions only, with the fixed
1146        // contribution subtracted from the observations first.
1147        let size = free.len();
1148        let mut normal = vec![0.0; size * size];
1149        let mut target = vec![0.0; size];
1150
1151        for node in &self.nodes {
1152            let basis = parametric_basis(f64::from(node.course));
1153            let mut residual = node.deviation;
1154            for (index, value) in fixed.iter().enumerate() {
1155                if let Some(value) = *value {
1156                    residual -= value * basis.get(index).copied().unwrap_or(0.0);
1157                }
1158            }
1159            for (row, &row_index) in free.iter().enumerate() {
1160                let row_basis = basis.get(row_index).copied().unwrap_or(0.0);
1161                for (column, &column_index) in free.iter().enumerate() {
1162                    let column_basis = basis.get(column_index).copied().unwrap_or(0.0);
1163                    if let Some(cell) = normal.get_mut(row * size + column) {
1164                        *cell += row_basis * column_basis;
1165                    }
1166                }
1167                if let Some(cell) = target.get_mut(row) {
1168                    *cell += row_basis * residual;
1169                }
1170            }
1171        }
1172
1173        let solution =
1174            solve_dense(&mut normal, &mut target, size).ok_or(NavigationError::SingularSystem {
1175                context: "a parametric deviation fit",
1176            })?;
1177
1178        for (position, &index) in free.iter().enumerate() {
1179            if let (Some(slot), Some(value)) = (resolved.get_mut(index), solution.get(position)) {
1180                *slot = *value;
1181            }
1182        }
1183
1184        Ok(SmithCoefficients::from_array(resolved))
1185    }
1186}
1187
1188#[cfg(feature = "serde")]
1189impl TryFrom<(i32, f64)> for DeviationNode {
1190    type Error = NavigationError;
1191
1192    /// Validates on the way in: a stored node cannot carry an impossible
1193    /// deviation or a course outside `0..360`.
1194    fn try_from((course, deviation): (i32, f64)) -> Result<Self> {
1195        ensure_range(
1196            "deviation",
1197            deviation,
1198            -MAX_DEVIATION_DEG,
1199            MAX_DEVIATION_DEG,
1200        )?;
1201        Ok(Self {
1202            course: course.rem_euclid(360),
1203            deviation,
1204        })
1205    }
1206}
1207
1208#[cfg(feature = "serde")]
1209impl From<DeviationNode> for (i32, f64) {
1210    fn from(node: DeviationNode) -> Self {
1211        (node.course, node.deviation)
1212    }
1213}
1214
1215#[cfg(feature = "serde")]
1216impl TryFrom<Vec<(i32, f64)>> for DeviationTable {
1217    type Error = NavigationError;
1218
1219    /// Read back through [`DeviationTable::from_vec`], so a stored table is
1220    /// checked for duplicates, non-finite values and having enough nodes just as
1221    /// a freshly built one is.
1222    fn try_from(nodes: Vec<(i32, f64)>) -> Result<Self> {
1223        Self::from_vec(nodes)
1224    }
1225}
1226
1227#[cfg(feature = "serde")]
1228impl From<DeviationTable> for Vec<(i32, f64)> {
1229    fn from(table: DeviationTable) -> Self {
1230        table
1231            .nodes
1232            .into_iter()
1233            .map(|node| (node.course, node.deviation))
1234            .collect()
1235    }
1236}
1237
1238/// A prepared interpolator, built once and evaluated many times.
1239#[derive(Debug, Clone)]
1240pub(crate) enum Interpolator {
1241    Linear,
1242    Cubic(Vec<f64>),
1243    Parametric(SmithCoefficients),
1244    Hermite(Vec<f64>),
1245}
1246
1247/// Where a course falls inside the table's ring of segments.
1248struct Segment {
1249    /// Index of the node the segment starts at.
1250    index: usize,
1251    /// Angular width of the segment, in degrees. Always greater than zero.
1252    span: f64,
1253    /// Distance from the segment start to the query point, in degrees.
1254    offset: f64,
1255}
1256
1257impl Segment {
1258    fn fraction(&self) -> f64 {
1259        self.offset / self.span
1260    }
1261}
1262
1263fn cardinal_course(direction: &str) -> Result<i32> {
1264    CARDINAL_DIRECTIONS
1265        .iter()
1266        .find(|&&(name, _)| name.eq_ignore_ascii_case(direction))
1267        .map(|&(_, course)| course)
1268        .ok_or_else(|| NavigationError::UnknownCardinalDirection {
1269            direction: direction.to_string(),
1270        })
1271}
1272
1273/// The five basis functions of the parametric model at a course, in degrees.
1274fn parametric_basis(course_degrees: f64) -> [f64; 5] {
1275    let radians = math::to_radians(course_degrees);
1276    [
1277        1.0,
1278        math::sin(radians),
1279        math::cos(radians),
1280        math::sin(2.0 * radians),
1281        math::cos(2.0 * radians),
1282    ]
1283}
1284
1285#[cfg(test)]
1286#[allow(clippy::unwrap_used, clippy::float_cmp, clippy::indexing_slicing)]
1287mod tests {
1288    use super::*;
1289    use alloc::vec;
1290
1291    fn readme_table() -> DeviationTable {
1292        DeviationTable::from_deviation_vec(vec![
1293            -2.5, -0.5, 1.6, 4.4, -1.7, 0.0, 1.0, 0.3, -0.9, 0.5, -1.2, 0.8, -0.3, 1.7, -2.1, 0.4,
1294            -0.6, 1.2, -1.3, 0.0, 0.9, -1.1, 1.5, -0.7, -13.2, -15.7, -17.9, -19.2, -18.1, 1.8,
1295            -0.4, 0.7, -0.2, 1.4, -4.4, -2.9,
1296        ])
1297        .unwrap()
1298    }
1299
1300    #[test]
1301    fn default_table_has_thirty_six_nodes() {
1302        let table = DeviationTable::default();
1303        assert_eq!(table.len(), STANDARD_TABLE_LEN);
1304        assert_eq!(table.deviation_at_node(0).unwrap().degrees(), 0.0);
1305        assert_eq!(table.deviation_at_node(350).unwrap().degrees(), 0.0);
1306        assert!(table.deviation_at_node(5).is_none());
1307    }
1308
1309    #[test]
1310    fn from_step_rejects_zero_and_negative() {
1311        // Both of these used to abort the process or silently build a one-node table.
1312        assert_eq!(
1313            DeviationTable::from_step(0).unwrap_err(),
1314            NavigationError::InvalidStep { step: 0 }
1315        );
1316        assert_eq!(
1317            DeviationTable::from_step(-10).unwrap_err(),
1318            NavigationError::InvalidStep { step: -10 }
1319        );
1320        assert!(DeviationTable::from_step(181).is_err());
1321        assert_eq!(DeviationTable::from_step(180).unwrap().len(), 2);
1322        assert_eq!(DeviationTable::from_step(1).unwrap().len(), 360);
1323    }
1324
1325    #[test]
1326    fn from_step_never_duplicates_north() {
1327        let table = DeviationTable::from_step(45).unwrap();
1328        assert_eq!(table.len(), 8);
1329        assert_eq!(table.nodes().last().unwrap().course(), 315);
1330    }
1331
1332    #[test]
1333    fn empty_and_tiny_tables_are_rejected_not_paniced() {
1334        // The pre-1.0 code panicked with a subtract overflow on an empty table.
1335        assert!(matches!(
1336            DeviationTable::from_vec(vec![]).unwrap_err(),
1337            NavigationError::InsufficientNodes { found: 0, .. }
1338        ));
1339        assert!(matches!(
1340            DeviationTable::from_vec(vec![(0, 1.0)]).unwrap_err(),
1341            NavigationError::InsufficientNodes { found: 1, .. }
1342        ));
1343    }
1344
1345    #[test]
1346    fn negative_courses_normalise_the_euclidean_way() {
1347        // `%` used to leave this as the unreachable key -350.
1348        let table = DeviationTable::from_vec(vec![(-350, 1.0), (180, 2.0)]).unwrap();
1349        assert_eq!(table.nodes().first().unwrap().course(), 10);
1350        assert_eq!(table.deviation_at_node(10).unwrap().degrees(), 1.0);
1351    }
1352
1353    #[test]
1354    fn duplicate_courses_are_rejected() {
1355        assert_eq!(
1356            DeviationTable::from_vec(vec![(10, 1.0), (370, 2.0), (180, 0.0)]).unwrap_err(),
1357            NavigationError::DuplicateCourse { course: 10 }
1358        );
1359    }
1360
1361    #[test]
1362    fn non_finite_deviations_are_rejected() {
1363        assert!(DeviationTable::from_vec(vec![(0, f64::NAN), (10, 0.0)]).is_err());
1364        assert!(DeviationTable::from_vec(vec![(0, f64::INFINITY), (10, 0.0)]).is_err());
1365        let mut table = DeviationTable::default();
1366        assert!(table.set_deviation(0, f64::NAN).is_err());
1367        assert!(table.set_deviation(0, 1e9).is_err());
1368    }
1369
1370    #[test]
1371    fn from_deviation_vec_demands_the_full_swing() {
1372        // Short slices used to be silently zero-filled, long ones silently truncated.
1373        assert_eq!(
1374            DeviationTable::from_deviation_vec(vec![-2.5, -0.5]).unwrap_err(),
1375            NavigationError::UnexpectedTableLength {
1376                found: 2,
1377                expected: 36
1378            }
1379        );
1380        assert!(DeviationTable::from_deviation_vec(vec![0.0; 37]).is_err());
1381        assert!(DeviationTable::from_deviation_vec(vec![0.0; 36]).is_ok());
1382    }
1383
1384    #[test]
1385    fn set_deviation_reports_unknown_nodes() {
1386        let mut table = DeviationTable::from_cardinal_directions();
1387        assert_eq!(
1388            table.set_deviation(50, -1.0).unwrap_err(),
1389            NavigationError::CourseNotInTable { course: 50 }
1390        );
1391        table.set_deviation(90, -1.0).unwrap();
1392        assert_eq!(table.deviation_at_node(90).unwrap().degrees(), -1.0);
1393
1394        // ...but `insert_deviation` adds it, keeping the table sorted.
1395        table.insert_deviation(50, -1.0).unwrap();
1396        assert_eq!(table.deviation_at_node(50).unwrap().degrees(), -1.0);
1397        assert!(table
1398            .nodes()
1399            .windows(2)
1400            .all(|pair| pair[0].course() < pair[1].course()));
1401    }
1402
1403    #[test]
1404    fn cardinal_directions_round_trip() {
1405        let mut table = DeviationTable::from_cardinal_directions();
1406        table.set_deviation_by_direction("N", -2.5).unwrap();
1407        table.set_deviation_by_direction("e", 1.0).unwrap();
1408        assert_eq!(
1409            table.get_deviation_by_direction("N").unwrap().degrees(),
1410            -2.5
1411        );
1412        assert_eq!(
1413            table.get_deviation_by_direction("E").unwrap().degrees(),
1414            1.0
1415        );
1416        assert_eq!(
1417            table.get_deviation_by_direction("SW").unwrap().degrees(),
1418            0.0
1419        );
1420        assert!(table.get_deviation_by_direction("XYZ").is_none());
1421        assert!(table.set_deviation_by_direction("XYZ", 1.0).is_err());
1422    }
1423
1424    #[test]
1425    fn interpolation_rejects_bad_angles() {
1426        let table = DeviationTable::default();
1427        assert!(table
1428            .interpolate_deviation(&[400.0], InterpolationMethod::Linear, None)
1429            .is_err());
1430        assert!(table
1431            .interpolate_deviation(&[f64::NAN], InterpolationMethod::Linear, None)
1432            .is_err());
1433        assert!(table
1434            .interpolate_deviation(&[-1.0], InterpolationMethod::Cubic, None)
1435            .is_err());
1436        assert!(table
1437            .interpolate_deviation(&[0.0, 360.0], InterpolationMethod::Linear, None)
1438            .is_ok());
1439    }
1440
1441    #[test]
1442    fn linear_interpolation_is_exact_at_nodes() {
1443        let table = readme_table();
1444        for node in table.nodes() {
1445            let value = table
1446                .deviation_at(f64::from(node.course()), InterpolationMethod::Linear, None)
1447                .unwrap();
1448            assert!((value.degrees() - node.deviation_degrees()).abs() < 1e-12);
1449        }
1450    }
1451
1452    #[test]
1453    fn cubic_interpolation_is_exact_at_nodes() {
1454        let table = readme_table();
1455        for node in table.nodes() {
1456            let value = table
1457                .deviation_at(f64::from(node.course()), InterpolationMethod::Cubic, None)
1458                .unwrap();
1459            assert!(
1460                (value.degrees() - node.deviation_degrees()).abs() < 1e-9,
1461                "node {}: {} vs {}",
1462                node.course(),
1463                value.degrees(),
1464                node.deviation_degrees()
1465            );
1466        }
1467    }
1468
1469    #[test]
1470    fn cubic_no_longer_flattens_the_first_segment() {
1471        // The old implementation returned -2.5 across the whole 0°..10° segment.
1472        let mut table = DeviationTable::from_step(10).unwrap();
1473        table.set_deviation(0, -2.5).unwrap();
1474        table.set_deviation(10, -1.5).unwrap();
1475
1476        let midpoint = table
1477            .deviation_at(5.0, InterpolationMethod::Cubic, None)
1478            .unwrap()
1479            .degrees();
1480        assert!(
1481            midpoint > -2.5 && midpoint < -1.5,
1482            "midpoint should lie between the nodes, got {midpoint}"
1483        );
1484    }
1485
1486    #[test]
1487    fn linear_interpolation_wraps_through_north() {
1488        // The old implementation clamped everything past the last node.
1489        let mut table = DeviationTable::from_step(10).unwrap();
1490        table.set_deviation(350, 10.0).unwrap();
1491        table.set_deviation(0, -10.0).unwrap();
1492
1493        let midpoint = table
1494            .deviation_at(355.0, InterpolationMethod::Linear, None)
1495            .unwrap();
1496        assert!((midpoint.degrees() - 0.0).abs() < 1e-12);
1497
1498        let quarter = table
1499            .deviation_at(352.5, InterpolationMethod::Linear, None)
1500            .unwrap();
1501        assert!((quarter.degrees() - 5.0).abs() < 1e-12);
1502    }
1503
1504    #[test]
1505    fn cubic_spline_is_smooth_across_north() {
1506        let table = readme_table();
1507        let before = table
1508            .deviation_at(359.9, InterpolationMethod::Cubic, None)
1509            .unwrap()
1510            .degrees();
1511        let after = table
1512            .deviation_at(0.1, InterpolationMethod::Cubic, None)
1513            .unwrap()
1514            .degrees();
1515        assert!(
1516            (before - after).abs() < 0.05,
1517            "spline jumps across north: {before} vs {after}"
1518        );
1519    }
1520
1521    #[test]
1522    fn cubic_spline_reproduces_a_sinusoid() {
1523        let values: Vec<f64> = (0..36)
1524            .map(|index| 5.0 * math::sin(math::to_radians(f64::from(index) * 10.0)))
1525            .collect();
1526        let table = DeviationTable::from_deviation_vec(values).unwrap();
1527
1528        for course in [5.0, 17.5, 123.4, 250.0, 355.0] {
1529            let expected = 5.0 * math::sin(math::to_radians(course));
1530            let actual = table
1531                .deviation_at(course, InterpolationMethod::Cubic, None)
1532                .unwrap()
1533                .degrees();
1534            assert!(
1535                (actual - expected).abs() < 1e-3,
1536                "at {course}: {actual} vs {expected}"
1537            );
1538        }
1539    }
1540
1541    #[test]
1542    fn linear_never_overshoots_its_nodes() {
1543        let table = readme_table();
1544        let low = table
1545            .nodes()
1546            .iter()
1547            .fold(f64::MAX, |acc, node| acc.min(node.deviation_degrees()));
1548        let high = table
1549            .nodes()
1550            .iter()
1551            .fold(f64::MIN, |acc, node| acc.max(node.deviation_degrees()));
1552
1553        let mut course = 0.0;
1554        while course < 360.0 {
1555            let value = table
1556                .deviation_at(course, InterpolationMethod::Linear, None)
1557                .unwrap()
1558                .degrees();
1559            assert!(value >= low - 1e-12 && value <= high + 1e-12);
1560            course += 0.25;
1561        }
1562    }
1563
1564    #[test]
1565    fn parametric_fit_recovers_known_coefficients() {
1566        // The old implementation ignored the deviation values entirely and
1567        // returned the table mean for every course.
1568        let truth = SmithCoefficients {
1569            a: 1.0,
1570            b: -2.0,
1571            c: 3.0,
1572            d: 0.5,
1573            e: -1.5,
1574        };
1575        let values: Vec<f64> = (0..36)
1576            .map(|index| truth.deviation_at(f64::from(index) * 10.0))
1577            .collect();
1578        let table = DeviationTable::from_deviation_vec(values).unwrap();
1579
1580        let fitted = table.smith_coefficients().unwrap();
1581        assert!((fitted.a - truth.a).abs() < 1e-9);
1582        assert!((fitted.b - truth.b).abs() < 1e-9);
1583        assert!((fitted.c - truth.c).abs() < 1e-9);
1584        assert!((fitted.d - truth.d).abs() < 1e-9);
1585        assert!((fitted.e - truth.e).abs() < 1e-9);
1586
1587        let analysis = table.analyze().unwrap();
1588        assert!(analysis.rms_residual < 1e-9);
1589        assert_eq!(analysis.nodes, 36);
1590        assert_eq!(analysis.max_gap, 10.0);
1591    }
1592
1593    #[test]
1594    fn parametric_depends_on_the_deviation_values() {
1595        let flat = DeviationTable::from_deviation_vec(vec![0.0; 36]).unwrap();
1596        let values: Vec<f64> = (0..36)
1597            .map(|index| 5.0 * math::sin(math::to_radians(f64::from(index) * 10.0)))
1598            .collect();
1599        let sinusoid = DeviationTable::from_deviation_vec(values).unwrap();
1600
1601        let flat_value = flat
1602            .deviation_at(90.0, InterpolationMethod::Parametric, None)
1603            .unwrap()
1604            .degrees();
1605        let sinusoid_value = sinusoid
1606            .deviation_at(90.0, InterpolationMethod::Parametric, None)
1607            .unwrap()
1608            .degrees();
1609
1610        assert!(flat_value.abs() < 1e-9);
1611        assert!(
1612            (sinusoid_value - 5.0).abs() < 1e-9,
1613            "expected 5.0 at 090°, got {sinusoid_value}"
1614        );
1615    }
1616
1617    #[test]
1618    fn parametric_is_not_constant_across_the_compass() {
1619        let table = readme_table();
1620        let north = table
1621            .deviation_at(0.0, InterpolationMethod::Parametric, None)
1622            .unwrap()
1623            .degrees();
1624        let west = table
1625            .deviation_at(270.0, InterpolationMethod::Parametric, None)
1626            .unwrap()
1627            .degrees();
1628        assert!((north - west).abs() > 1.0, "{north} vs {west}");
1629    }
1630
1631    #[test]
1632    fn parametric_honours_fixed_coefficients() {
1633        let table = readme_table();
1634        let requested = DeviationCoefficients {
1635            a: Some(0.0),
1636            b: Some(0.0),
1637            c: Some(0.0),
1638            d: Some(0.0),
1639            e: Some(0.0),
1640        };
1641        let value = table
1642            .deviation_at(123.0, InterpolationMethod::Parametric, Some(&requested))
1643            .unwrap();
1644        assert_eq!(value.degrees(), 0.0);
1645
1646        let partial = DeviationCoefficients {
1647            a: Some(2.0),
1648            ..DeviationCoefficients::default()
1649        };
1650        let fitted = table.fit_parametric(&partial).unwrap();
1651        assert_eq!(fitted.a, 2.0);
1652        assert!(fitted.b.abs() > 0.0 || fitted.c.abs() > 0.0);
1653    }
1654
1655    #[test]
1656    fn parametric_needs_enough_nodes() {
1657        let table = DeviationTable::from_vec(vec![(0, 1.0), (180, -1.0)]).unwrap();
1658        assert!(matches!(
1659            table.smith_coefficients().unwrap_err(),
1660            NavigationError::InsufficientNodes { required: 5, .. }
1661        ));
1662    }
1663
1664    #[test]
1665    fn parametric_rejects_absurd_fixed_coefficients() {
1666        let table = readme_table();
1667        let requested = DeviationCoefficients {
1668            a: Some(1e6),
1669            ..DeviationCoefficients::default()
1670        };
1671        assert!(table
1672            .deviation_at(0.0, InterpolationMethod::Parametric, Some(&requested))
1673            .is_err());
1674    }
1675
1676    #[test]
1677    fn two_node_table_falls_back_from_cubic_to_linear() {
1678        let table = DeviationTable::from_vec(vec![(0, 0.0), (180, 4.0)]).unwrap();
1679        let value = table
1680            .deviation_at(90.0, InterpolationMethod::Cubic, None)
1681            .unwrap();
1682        assert!((value.degrees() - 2.0).abs() < 1e-12);
1683    }
1684
1685    #[test]
1686    fn uneven_node_spacing_still_interpolates() {
1687        let table = DeviationTable::from_vec(vec![
1688            (0, 1.0),
1689            (7, -2.0),
1690            (93, 0.5),
1691            (200, -3.0),
1692            (201, -3.1),
1693            (355, 2.0),
1694        ])
1695        .unwrap();
1696
1697        for method in [
1698            InterpolationMethod::Linear,
1699            InterpolationMethod::Cubic,
1700            InterpolationMethod::Parametric,
1701            InterpolationMethod::ShapePreserving,
1702        ] {
1703            let mut course = 0.0;
1704            while course < 360.0 {
1705                let value = table.deviation_at(course, method, None).unwrap();
1706                assert!(value.degrees().is_finite(), "{method:?} at {course}");
1707                course += 0.5;
1708            }
1709        }
1710    }
1711
1712    #[test]
1713    fn shape_preserving_is_exact_at_nodes() {
1714        let table = readme_table();
1715        for node in table.nodes() {
1716            let value = table
1717                .deviation_at(
1718                    f64::from(node.course()),
1719                    InterpolationMethod::ShapePreserving,
1720                    None,
1721                )
1722                .unwrap();
1723            assert!((value.degrees() - node.deviation_degrees()).abs() < 1e-12);
1724        }
1725    }
1726
1727    #[test]
1728    fn shape_preserving_never_overshoots_where_the_spline_does() {
1729        // This swing has a 12.5° step in it, which is exactly the situation a
1730        // natural cubic spline handles by bulging past the data.
1731        let table = readme_table();
1732        let low = table
1733            .nodes()
1734            .iter()
1735            .fold(f64::MAX, |acc, node| acc.min(node.deviation_degrees()));
1736        let high = table
1737            .nodes()
1738            .iter()
1739            .fold(f64::MIN, |acc, node| acc.max(node.deviation_degrees()));
1740
1741        let mut spline_overshot = false;
1742        let mut course = 0.0;
1743        while course < 360.0 {
1744            let shaped = table
1745                .deviation_at(course, InterpolationMethod::ShapePreserving, None)
1746                .unwrap()
1747                .degrees();
1748            assert!(
1749                shaped >= low - 1e-12 && shaped <= high + 1e-12,
1750                "shape-preserving bulged to {shaped} at {course}"
1751            );
1752
1753            let spline = table
1754                .deviation_at(course, InterpolationMethod::Cubic, None)
1755                .unwrap()
1756                .degrees();
1757            if spline < low - 1e-9 || spline > high + 1e-9 {
1758                spline_overshot = true;
1759            }
1760            course += 0.25;
1761        }
1762
1763        assert!(
1764            spline_overshot,
1765            "the cubic spline was expected to overshoot on this swing"
1766        );
1767    }
1768
1769    #[test]
1770    fn shape_preserving_stays_between_neighbouring_nodes() {
1771        // The stronger property: within any one segment the curve stays between
1772        // that segment's own two values.
1773        let table = readme_table();
1774        let nodes = table.nodes();
1775        for pair in nodes.windows(2) {
1776            let (start, end) = (pair[0], pair[1]);
1777            let (low, high) = if start.deviation_degrees() <= end.deviation_degrees() {
1778                (start.deviation_degrees(), end.deviation_degrees())
1779            } else {
1780                (end.deviation_degrees(), start.deviation_degrees())
1781            };
1782
1783            let mut course = f64::from(start.course());
1784            while course <= f64::from(end.course()) {
1785                let value = table
1786                    .deviation_at(course, InterpolationMethod::ShapePreserving, None)
1787                    .unwrap()
1788                    .degrees();
1789                assert!(
1790                    value >= low - 1e-12 && value <= high + 1e-12,
1791                    "between {}° and {}° the curve reached {value}, outside [{low}, {high}]",
1792                    start.course(),
1793                    end.course()
1794                );
1795                course += 0.1;
1796            }
1797        }
1798    }
1799
1800    #[test]
1801    fn shape_preserving_is_smooth_across_north() {
1802        let table = readme_table();
1803        let before = table
1804            .deviation_at(359.9, InterpolationMethod::ShapePreserving, None)
1805            .unwrap()
1806            .degrees();
1807        let after = table
1808            .deviation_at(0.1, InterpolationMethod::ShapePreserving, None)
1809            .unwrap()
1810            .degrees();
1811        assert!((before - after).abs() < 0.05, "{before} vs {after}");
1812    }
1813
1814    #[test]
1815    fn shape_preserving_reproduces_a_gentle_curve() {
1816        let values: Vec<f64> = (0..36)
1817            .map(|index| 5.0 * math::sin(math::to_radians(f64::from(index) * 10.0)))
1818            .collect();
1819        let table = DeviationTable::from_deviation_vec(values).unwrap();
1820
1821        for course in [5.0, 17.5, 123.4, 250.0, 355.0] {
1822            let expected = 5.0 * math::sin(math::to_radians(course));
1823            let actual = table
1824                .deviation_at(course, InterpolationMethod::ShapePreserving, None)
1825                .unwrap()
1826                .degrees();
1827            assert!(
1828                (actual - expected).abs() < 0.02,
1829                "at {course}: {actual} vs {expected}"
1830            );
1831        }
1832    }
1833
1834    #[test]
1835    fn interpolate_batch_matches_single_lookups() {
1836        let table = readme_table();
1837        let courses = [0.0, 3.0, 45.5, 180.0, 259.9, 360.0];
1838        for method in [
1839            InterpolationMethod::Linear,
1840            InterpolationMethod::Cubic,
1841            InterpolationMethod::Parametric,
1842            InterpolationMethod::ShapePreserving,
1843        ] {
1844            let batch = table.interpolate_deviation(&courses, method, None).unwrap();
1845            for (index, &course) in courses.iter().enumerate() {
1846                let single = table.deviation_at(course, method, None).unwrap().degrees();
1847                assert!((batch[index] - single).abs() < 1e-12);
1848            }
1849        }
1850    }
1851}