1use 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
52pub const STANDARD_TABLE_LEN: usize = 36;
54
55pub 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#[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 #[default]
79 Linear,
80 Cubic,
85 Parametric,
93 ShapePreserving,
104}
105
106#[derive(Debug, Clone, Copy, Default)]
150pub struct Interpolation<'a> {
151 pub method: InterpolationMethod,
153 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#[derive(Debug, Clone, Copy, PartialEq)]
214#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
215pub struct SwingObservation {
216 pub compass_heading: Direction<Compass>,
218 pub observed_bearing: Direction<Compass>,
220 pub reference_bearing: Direction<True>,
222}
223
224impl SwingObservation {
225 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#[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 #[must_use]
255 pub const fn course(&self) -> i32 {
256 self.course
257 }
258
259 #[must_use]
261 pub fn deviation(&self) -> Deviation {
262 Deviation::new(self.deviation).unwrap_or(Deviation::ZERO)
264 }
265
266 #[must_use]
268 pub const fn deviation_degrees(&self) -> f64 {
269 self.deviation
270 }
271}
272
273#[derive(Debug, Clone, Copy, PartialEq, Default)]
300#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
301pub struct DeviationCoefficients {
302 pub a: Option<f64>,
304 pub b: Option<f64>,
306 pub c: Option<f64>,
308 pub d: Option<f64>,
310 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#[derive(Debug, Clone, Copy, PartialEq, Default)]
337#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
338pub struct SmithCoefficients {
339 pub a: f64,
341 pub b: f64,
343 pub c: f64,
345 pub d: f64,
347 pub e: f64,
349}
350
351impl SmithCoefficients {
352 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq)]
388#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
389pub struct DeviationAnalysis {
390 pub coefficients: SmithCoefficients,
392 pub rms_residual: f64,
397 pub max_residual: f64,
399 pub max_abs_deviation: f64,
401 pub max_gap: f64,
403 pub max_slope: f64,
408 pub nodes: usize,
410}
411
412#[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 fn default() -> Self {
430 Self {
431 nodes: (0..STANDARD_TABLE_LEN)
432 .map(|index| DeviationNode {
433 course: i32::try_from(index).unwrap_or(0) * 10,
435 deviation: 0.0,
436 })
437 .collect(),
438 }
439 }
440}
441
442impl DeviationTable {
443 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 #[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 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 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 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 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 #[must_use]
592 pub fn nodes(&self) -> &[DeviationNode] {
593 &self.nodes
594 }
595
596 #[must_use]
598 pub fn len(&self) -> usize {
599 self.nodes.len()
600 }
601
602 #[must_use]
604 pub fn is_empty(&self) -> bool {
605 self.nodes.is_empty()
606 }
607
608 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 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 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 #[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 #[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 #[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 #[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 #[must_use]
749 pub fn is_invertible(&self) -> bool {
750 self.max_slope() < 1.0
751 }
752
753 #[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 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 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 pub fn smith_coefficients(&self) -> Result<SmithCoefficients> {
812 self.fit_parametric(&DeviationCoefficients::default())
813 }
814
815 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 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 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 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 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 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 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 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 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 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 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 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 fn second_derivatives(&self) -> Option<Vec<f64>> {
1066 let count = self.nodes.len();
1067 if count < 3 {
1068 return None;
1069 }
1070
1071 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 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 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 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 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 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#[derive(Debug, Clone)]
1240pub(crate) enum Interpolator {
1241 Linear,
1242 Cubic(Vec<f64>),
1243 Parametric(SmithCoefficients),
1244 Hermite(Vec<f64>),
1245}
1246
1247struct Segment {
1249 index: usize,
1251 span: f64,
1253 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
1273fn 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 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 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 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 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 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 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 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 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 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 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}