Skip to main content

navcore_math/
rhumb.rs

1//! Rhumb line (loxodrome) sailing -- the path of constant bearing.
2//!
3//! Longer than the great circle, but the constant-bearing path actually
4//! steered. A rhumb line is also the path a leg drawn on a Mercator
5//! chart represents: a straight line on that projection is a constant
6//! bearing, the property Mercator was built for. Measuring a leg drawn
7//! on a Mercator map as a great circle gives a distance that disagrees
8//! with the line under it, so route legs should use this module
9//! throughout.
10
11use crate::{EARTH_RADIUS_NM, Position, angle};
12
13/// Guards the `Δφ/Δψ` quotient near the equator, where both go to zero
14/// together and the ratio becomes 0/0. Below this the limit `cos φ` is used
15/// instead, which is what the quotient converges to.
16const MERIDIONAL_EPS: f64 = 1e-12;
17
18/// The inverse Gudermannian-ish term: the Mercator projected latitude,
19/// which is what makes a constant bearing a straight line.
20fn projected_lat(lat_rad: f64) -> f64 {
21    (std::f64::consts::FRAC_PI_4 + lat_rad / 2.0).tan().ln()
22}
23
24/// Distance along the rhumb line between two positions, in nautical miles.
25#[must_use]
26pub fn distance_nm(from: Position, to: Position) -> f64 {
27    let (lat1, lat2) = (from.lat_rad(), to.lat_rad());
28    let d_lat = lat2 - lat1;
29    let d_lon = shortest_d_lon(from.lon_rad(), to.lon_rad());
30    let d_proj = projected_lat(lat2) - projected_lat(lat1);
31
32    // The ratio is the local scale between projected and real latitude; at
33    // the equator it degenerates and the limit cos(lat) takes over.
34    let q = if d_proj.abs() > MERIDIONAL_EPS {
35        d_lat / d_proj
36    } else {
37        lat1.cos()
38    };
39
40    (d_lat * d_lat + q * q * d_lon * d_lon).sqrt() * EARTH_RADIUS_NM
41}
42
43/// The constant bearing that runs from `from` to `to`, in degrees true.
44///
45/// Unlike [`crate::great_circle::initial_bearing_deg`] this bearing holds
46/// for the whole leg -- steer it and you arrive.
47#[must_use]
48pub fn bearing_deg(from: Position, to: Position) -> f64 {
49    let d_lon = shortest_d_lon(from.lon_rad(), to.lon_rad());
50    let d_proj = projected_lat(to.lat_rad()) - projected_lat(from.lat_rad());
51    angle::norm_360(d_lon.atan2(d_proj).to_degrees())
52}
53
54/// The position reached by holding `bearing_deg` for `distance_nm`.
55#[must_use]
56pub fn destination(from: Position, bearing_deg: f64, distance_nm: f64) -> Position {
57    let angular = distance_nm / EARTH_RADIUS_NM;
58    let brg = bearing_deg.to_radians();
59    let (lat1, lon1) = (from.lat_rad(), from.lon_rad());
60
61    let d_lat = angular * brg.cos();
62    let mut lat2 = lat1 + d_lat;
63
64    // Sailing over a pole on a constant bearing is possible only due north
65    // or due south; anything else spirals in without reaching it. Reflecting
66    // keeps a nonsensical input from producing a position off the sphere.
67    if lat2.abs() > std::f64::consts::FRAC_PI_2 {
68        lat2 = if lat2 > 0.0 {
69            std::f64::consts::PI - lat2
70        } else {
71            -std::f64::consts::PI - lat2
72        };
73    }
74
75    let d_proj = projected_lat(lat2) - projected_lat(lat1);
76    let q = if d_proj.abs() > MERIDIONAL_EPS {
77        d_lat / d_proj
78    } else {
79        lat1.cos()
80    };
81    let lon2 = lon1 + angular * brg.sin() / q;
82
83    Position::new(lat2.to_degrees(), angle::norm_180(lon2.to_degrees()))
84}
85
86/// Longitude difference taken the short way round, in radians.
87fn shortest_d_lon(lon1_rad: f64, lon2_rad: f64) -> f64 {
88    let d = lon2_rad - lon1_rad;
89    if d.abs() > std::f64::consts::PI {
90        if d > 0.0 {
91            d - std::f64::consts::TAU
92        } else {
93            d + std::f64::consts::TAU
94        }
95    } else {
96        d
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use crate::great_circle;
104
105    fn close(a: f64, b: f64, tol: f64) -> bool {
106        (a - b).abs() < tol
107    }
108
109    #[test]
110    fn along_a_meridian_it_agrees_with_the_great_circle() {
111        // A meridian *is* a great circle, so the two sailings must agree
112        // exactly -- a good check that the projected-latitude term is right.
113        let a = Position::new(45.0, 13.0);
114        let b = Position::new(46.0, 13.0);
115        assert!(close(distance_nm(a, b), great_circle::distance_nm(a, b), 1e-6));
116        assert!(close(bearing_deg(a, b), 0.0, 1e-9));
117    }
118
119    #[test]
120    fn along_the_equator_it_agrees_too() {
121        let a = Position::new(0.0, 10.0);
122        let b = Position::new(0.0, 11.0);
123        assert!(close(distance_nm(a, b), great_circle::distance_nm(a, b), 1e-6));
124        assert!(close(bearing_deg(a, b), 90.0, 1e-9));
125    }
126
127    #[test]
128    fn along_a_parallel_it_is_longer_than_the_great_circle() {
129        // The point of the whole module: holding 090 at 60N costs distance
130        // against the great circle that bends north of it.
131        let a = Position::new(60.0, -10.0);
132        let b = Position::new(60.0, 10.0);
133        assert!(distance_nm(a, b) > great_circle::distance_nm(a, b));
134        assert!(close(bearing_deg(a, b), 90.0, 1e-9));
135    }
136
137    #[test]
138    fn destination_inverts_bearing_and_distance() {
139        let start = Position::new(45.55, 13.73);
140        for bearing in [0.0, 37.5, 90.0, 180.0, 271.3, 359.0] {
141            for dist in [0.5, 12.0, 300.0] {
142                let end = destination(start, bearing, dist);
143                assert!(
144                    close(distance_nm(start, end), dist, 1e-6),
145                    "distance differs for {bearing}/{dist}"
146                );
147                assert!(
148                    close(bearing_deg(start, end), bearing, 1e-6),
149                    "bearing differs for {bearing}/{dist}"
150                );
151            }
152        }
153    }
154
155    #[test]
156    fn bearing_holds_all_the_way_along_the_leg() {
157        // The defining property: sample the leg and every remaining stretch
158        // has the same bearing to the end.
159        let start = Position::new(43.0, 9.0);
160        let end = destination(start, 62.0, 400.0);
161        for frac in [0.1, 0.25, 0.5, 0.9] {
162            let mid = destination(start, 62.0, 400.0 * frac);
163            assert!(
164                close(bearing_deg(mid, end), 62.0, 1e-6),
165                "bearing drifted at {frac}"
166            );
167        }
168    }
169
170    #[test]
171    fn crossing_the_antimeridian_takes_the_short_way() {
172        let a = Position::new(10.0, 179.0);
173        let b = Position::new(10.0, -179.0);
174        let d = distance_nm(a, b);
175        assert!(d < 130.0, "took the long way round: {d}");
176        assert!(close(bearing_deg(a, b), 90.0, 1e-6));
177    }
178}