Skip to main content

navcore_math/
great_circle.rs

1//! Great circle sailing -- the shortest path between two positions.
2//!
3//! This is the right sailing for "how far is it" and for legs long enough
4//! that the saving matters. It is the wrong one for steering: the bearing
5//! changes continuously along the track, which is why [`crate::rhumb`]
6//! exists alongside it.
7
8use crate::{EARTH_RADIUS_NM, Position, angle};
9
10/// Distance along the great circle between two positions, in nautical
11/// miles.
12///
13/// Haversine rather than the spherical law of cosines: the latter loses
14/// precision badly for short distances, which are exactly the distances
15/// computed most often (metres to the next waypoint in a harbour).
16#[must_use]
17pub fn distance_nm(from: Position, to: Position) -> f64 {
18    central_angle_rad(from, to) * EARTH_RADIUS_NM
19}
20
21/// The angle subtended at the centre of the earth, in radians. Shared by
22/// distance and cross-track, which both need it.
23pub(crate) fn central_angle_rad(from: Position, to: Position) -> f64 {
24    let (lat1, lat2) = (from.lat_rad(), to.lat_rad());
25    let d_lat = lat2 - lat1;
26    let d_lon = to.lon_rad() - from.lon_rad();
27
28    let a = (d_lat / 2.0).sin().powi(2)
29        + lat1.cos() * lat2.cos() * (d_lon / 2.0).sin().powi(2);
30    2.0 * a.sqrt().atan2((1.0 - a).sqrt())
31}
32
33/// The bearing to leave `from` on to reach `to` by the great circle, in
34/// degrees true.
35///
36/// Only the *initial* bearing: on any leg that is not along a meridian or
37/// the equator, holding this bearing does not arrive at `to`. See
38/// [`final_bearing_deg`] for the other end, and [`crate::rhumb`] for the
39/// constant-bearing alternative.
40#[must_use]
41pub fn initial_bearing_deg(from: Position, to: Position) -> f64 {
42    let (lat1, lat2) = (from.lat_rad(), to.lat_rad());
43    let d_lon = to.lon_rad() - from.lon_rad();
44
45    let y = d_lon.sin() * lat2.cos();
46    let x = lat1.cos() * lat2.sin() - lat1.sin() * lat2.cos() * d_lon.cos();
47    angle::norm_360(y.atan2(x).to_degrees())
48}
49
50/// The bearing the great circle arrives at `to` on, in degrees true.
51///
52/// The initial bearing of the reverse leg, turned around.
53#[must_use]
54pub fn final_bearing_deg(from: Position, to: Position) -> f64 {
55    angle::reciprocal(initial_bearing_deg(to, from))
56}
57
58/// The position reached by sailing `distance_nm` from `from` along the
59/// great circle that leaves on `bearing_deg`.
60#[must_use]
61pub fn destination(from: Position, bearing_deg: f64, distance_nm: f64) -> Position {
62    let angular = distance_nm / EARTH_RADIUS_NM;
63    let brg = bearing_deg.to_radians();
64    let (lat1, lon1) = (from.lat_rad(), from.lon_rad());
65
66    let lat2 =
67        (lat1.sin() * angular.cos() + lat1.cos() * angular.sin() * brg.cos()).asin();
68    let lon2 = lon1
69        + (brg.sin() * angular.sin() * lat1.cos())
70            .atan2(angular.cos() - lat1.sin() * lat2.sin());
71
72    Position::new(lat2.to_degrees(), angle::norm_180(lon2.to_degrees()))
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    const EPS: f64 = 1e-6;
80
81    fn close(a: f64, b: f64, tol: f64) -> bool {
82        (a - b).abs() < tol
83    }
84
85    #[test]
86    fn one_degree_of_latitude_is_sixty_and_a_bit() {
87        // 60.04, not 60 -- see the crate docs on the earth model. If this
88        // ever reads exactly 60.0 someone has swapped the radius for the
89        // "rounded earth" 3437.75 NM, which is a defensible choice but a
90        // different one, and every computed distance shifts by 0.07%.
91        let d = distance_nm(Position::new(45.0, 13.0), Position::new(46.0, 13.0));
92        assert!(close(d, 60.0405, 1e-3), "got {d}");
93    }
94
95    #[test]
96    fn a_degree_of_longitude_shrinks_with_latitude() {
97        let at_equator = distance_nm(Position::new(0.0, 0.0), Position::new(0.0, 1.0));
98        let at_sixty = distance_nm(Position::new(60.0, 0.0), Position::new(60.0, 1.0));
99        assert!(close(at_equator, 60.0405, 1e-3), "got {at_equator}");
100        // cos(60) = 0.5 exactly, so this one is analytically checkable.
101        assert!(close(at_sixty, at_equator * 0.5, 1e-3), "got {at_sixty}");
102    }
103
104    #[test]
105    fn distance_is_symmetric_and_zero_for_a_point() {
106        let a = Position::new(45.55, 13.73);
107        let b = Position::new(45.65, 13.60);
108        assert!(close(distance_nm(a, b), distance_nm(b, a), EPS));
109        assert!(close(distance_nm(a, a), 0.0, EPS));
110    }
111
112    #[test]
113    fn cardinal_bearings_come_out_cardinal() {
114        let origin = Position::new(45.0, 13.0);
115        assert!(close(
116            initial_bearing_deg(origin, Position::new(46.0, 13.0)),
117            0.0,
118            EPS
119        ));
120        assert!(close(
121            initial_bearing_deg(origin, Position::new(44.0, 13.0)),
122            180.0,
123            EPS
124        ));
125        // Due east along a parallel is 090 only at the moment of leaving;
126        // the great circle then bends poleward, so check just the start.
127        assert!(close(
128            initial_bearing_deg(origin, Position::new(45.0, 14.0)),
129            90.0,
130            0.5
131        ));
132    }
133
134    #[test]
135    fn destination_inverts_distance_and_bearing() {
136        let start = Position::new(45.55, 13.73);
137        for bearing in [0.0, 37.5, 90.0, 180.0, 271.3, 359.0] {
138            for dist in [0.5, 12.0, 300.0] {
139                let end = destination(start, bearing, dist);
140                assert!(
141                    close(distance_nm(start, end), dist, 1e-6),
142                    "distance back differs for {bearing}/{dist}"
143                );
144                assert!(
145                    close(initial_bearing_deg(start, end), bearing, 1e-6),
146                    "bearing back differs for {bearing}/{dist}"
147                );
148            }
149        }
150    }
151
152    #[test]
153    fn final_bearing_differs_from_initial_on_a_long_leg() {
154        // The whole reason rhumb lines exist. A long east-west leg at high
155        // latitude arrives on a noticeably different bearing than it left.
156        let a = Position::new(60.0, -10.0);
157        let b = Position::new(60.0, 10.0);
158        let start = initial_bearing_deg(a, b);
159        let end = final_bearing_deg(a, b);
160        assert!(start < 90.0, "great circle leaves poleward of east: {start}");
161        assert!(end > 90.0, "and arrives equatorward of east: {end}");
162    }
163
164    #[test]
165    fn crossing_the_antimeridian_is_the_short_way() {
166        let a = Position::new(0.0, 179.0);
167        let b = Position::new(0.0, -179.0);
168        assert!(close(distance_nm(a, b), 2.0 * 60.0405, 1e-3));
169    }
170
171    #[test]
172    fn destination_normalises_longitude_across_the_antimeridian() {
173        let p = destination(Position::new(0.0, 179.5), 90.0, 120.0);
174        assert!(p.lon_deg < 0.0, "should have wrapped to negative: {p:?}");
175        assert!(p.is_valid());
176    }
177}