1use crate::{EARTH_RADIUS_NM, Position, angle};
9
10#[must_use]
17pub fn distance_nm(from: Position, to: Position) -> f64 {
18 central_angle_rad(from, to) * EARTH_RADIUS_NM
19}
20
21pub(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#[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#[must_use]
54pub fn final_bearing_deg(from: Position, to: Position) -> f64 {
55 angle::reciprocal(initial_bearing_deg(to, from))
56}
57
58#[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 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 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 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 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}