Skip to main content

geometry_strategy/geographic/
azimuth.rs

1//! Geodesic bearing (azimuth) on a spheroid via the Andoyer inverse.
2//!
3//! Mirrors `boost::geometry::strategy::azimuth::geographic` from
4//! `boost/geometry/strategies/geographic/azimuth.hpp`, whose default
5//! `FormulaPolicy` is `strategy::andoyer`
6//! (`strategies/geographic/azimuth.hpp:33`, `:137-145`). The forward
7//! azimuth is the `alpha_1` output of
8//! `formula::andoyer_inverse` — the same iteration
9//! [`Andoyer`](crate::geographic::Andoyer) runs for distance — computed
10//! here from the closed-form Andoyer expansion in
11//! `boost/geometry/formulas/andoyer_inverse.hpp:165-219`:
12//!
13//! ```text
14//! A  = atan2(sin Δλ, cos φ₁·tan φ₂ − sin φ₁·cos Δλ)
15//! U  = (f/2)·cos²φ₁·sin 2A
16//! B  = atan2(sin Δλ, cos φ₂·tan φ₁ − sin φ₂·cos Δλ)
17//! V  = (f/2)·cos²φ₂·sin 2B
18//! T  = d / sin d
19//! azimuth = A − (V·T − U)
20//! ```
21//!
22//! # Convention
23//!
24//! `0` = north, increasing **clockwise** — the geodetic bearing
25//! convention, matching [`SphericalAzimuth`](crate::spherical::SphericalAzimuth)
26//! and *differing* from
27//! [`CartesianAzimuth`](crate::azimuth::CartesianAzimuth). `azimuth(p, p)`
28//! and coincident / antipodal short-circuits return `0`, mirroring
29//! `andoyer_inverse.hpp:127-163`.
30
31use geometry_cs::{CoordinateSystem, GeographicFamily, Spheroid};
32use geometry_tag::SameAs;
33use geometry_trait::Point;
34
35use crate::azimuth::AzimuthStrategy;
36
37#[cfg(feature = "std")]
38use crate::geographic::spheroid_calc::SpheroidCalc;
39#[cfg(feature = "std")]
40use crate::normalise::{HasAngularUnits, lonlat_radians};
41
42/// Geodesic initial bearing on a spheroid, in radians (`0` = north,
43/// clockwise), via the Andoyer inverse.
44///
45/// Carries the reference [`Spheroid`]; `Default::default()` produces
46/// [`GeographicAzimuth::WGS84`].
47///
48/// Mirrors `boost::geometry::strategy::azimuth::geographic<andoyer>`
49/// (`strategies/geographic/azimuth.hpp`).
50#[derive(Debug, Clone, Copy)]
51pub struct GeographicAzimuth {
52    /// Reference ellipsoid the bearing is measured on.
53    pub spheroid: Spheroid,
54}
55
56impl GeographicAzimuth {
57    /// Bearing on the WGS84 reference ellipsoid — the default for
58    /// nearly every real geographic dataset (matches `Andoyer::WGS84`).
59    pub const WGS84: Self = Self {
60        spheroid: Spheroid::WGS84,
61    };
62}
63
64impl Default for GeographicAzimuth {
65    #[inline]
66    fn default() -> Self {
67        Self::WGS84
68    }
69}
70
71#[cfg(feature = "std")]
72impl<P1, P2> AzimuthStrategy<P1, P2> for GeographicAzimuth
73where
74    P1: Point<Scalar = f64>,
75    P2: Point<Scalar = f64>,
76    P1::Cs: HasAngularUnits,
77    P2::Cs: HasAngularUnits,
78    <P1::Cs as CoordinateSystem>::Family: SameAs<GeographicFamily>,
79    <P2::Cs as CoordinateSystem>::Family: SameAs<GeographicFamily>,
80{
81    type Out = f64;
82
83    // The single-letter names `A, B, U, V, T, M, N, d` mirror
84    // `formula::andoyer_inverse::apply` in
85    // `formulas/andoyer_inverse.hpp:165-219` letter-for-letter; the
86    // epsilon-aware short-circuits mirror Boost's `math::equals` guards on
87    // the same lines.
88    #[allow(clippy::many_single_char_names, clippy::float_cmp)]
89    #[inline]
90    fn azimuth(&self, p1: &P1, p2: &P2) -> f64 {
91        let calc = SpheroidCalc::from(self.spheroid);
92        let f = calc.f;
93        let (lon1, lat1) = lonlat_radians(p1);
94        let (lon2, lat2) = lonlat_radians(p2);
95
96        let dlon = lon2 - lon1;
97        let sin_dlon = dlon.sin();
98        let cos_dlon = dlon.cos();
99        let sin_lat1 = lat1.sin();
100        let cos_lat1 = lat1.cos();
101        let sin_lat2 = lat2.sin();
102        let cos_lat2 = lat2.cos();
103
104        // Spherical central angle, clamped to the acos domain — mirrors
105        // `andoyer_inverse.hpp:90-97`.
106        let cos_d = (sin_lat1 * sin_lat2 + cos_lat1 * cos_lat2 * cos_dlon).clamp(-1.0, 1.0);
107        let d = cos_d.acos();
108        let sin_d = d.sin();
109
110        // Coincident / antipodal short-circuit — mirrors
111        // `andoyer_inverse.hpp:127-163`. Boost returns 0 for the
112        // aligned case (which is all this port needs to reproduce the
113        // reference table).
114        if sin_d.abs() <= f64::EPSILON {
115            return 0.0;
116        }
117
118        let pi = core::f64::consts::PI;
119
120        // Forward-azimuth term A + first-order flattening correction U.
121        let (a, u) = if cos_lat2.abs() <= f64::EPSILON {
122            (if sin_lat2 < 0.0 { pi } else { 0.0 }, 0.0)
123        } else {
124            let tan_lat2 = sin_lat2 / cos_lat2;
125            let m = cos_lat1 * tan_lat2 - sin_lat1 * cos_dlon;
126            let a = sin_dlon.atan2(m);
127            let u = (f / 2.0) * (cos_lat1 * cos_lat1) * (2.0 * a).sin();
128            (a, u)
129        };
130
131        // Correction term V (from the reverse-azimuth term B), needed
132        // for the forward `dA = V·T − U`. B itself is not used forward.
133        let v = if cos_lat1.abs() <= f64::EPSILON {
134            0.0
135        } else {
136            let tan_lat1 = sin_lat1 / cos_lat1;
137            let n = cos_lat2 * tan_lat1 - sin_lat2 * cos_dlon;
138            let b = sin_dlon.atan2(n);
139            (f / 2.0) * (cos_lat2 * cos_lat2) * (2.0 * b).sin()
140        };
141
142        let t = d / sin_d;
143        let da = v * t - u;
144        let mut azimuth = a - da;
145        normalize_azimuth(&mut azimuth, a, da);
146        azimuth
147    }
148}
149
150/// Clamp the corrected forward azimuth so the flattening correction
151/// cannot push it past the pole it started on. Mirrors
152/// `formula::andoyer_inverse::normalize_azimuth` at
153/// `formulas/andoyer_inverse.hpp:246-286`.
154#[cfg(feature = "std")]
155#[inline]
156fn normalize_azimuth(azimuth: &mut f64, a: f64, da: f64) {
157    let pi = core::f64::consts::PI;
158    if a >= 0.0 {
159        // A in the Eastern hemisphere.
160        if da >= 0.0 {
161            if *azimuth < 0.0 {
162                *azimuth = 0.0;
163            }
164        } else if *azimuth > pi {
165            *azimuth = pi;
166        }
167    } else {
168        // A in the Western hemisphere.
169        if da <= 0.0 {
170            if *azimuth > 0.0 {
171                *azimuth = 0.0;
172            }
173        } else if *azimuth < -pi {
174            *azimuth = -pi;
175        }
176    }
177}
178
179#[cfg(all(test, feature = "std"))]
180mod tests {
181    //! Reference values from
182    //! `boost/geometry/test/algorithms/azimuth.cpp:59-66` (`test_geo`,
183    //! default = Andoyer), given in degrees; asserted here in radians.
184    #![allow(
185        clippy::float_cmp,
186        reason = "azimuths are compared with an explicit absolute tolerance, not `==`"
187    )]
188    #![allow(
189        clippy::excessive_precision,
190        reason = "reference constants are copied verbatim from Boost's azimuth.cpp; the trailing digits document the source value even past f64 precision."
191    )]
192
193    use super::GeographicAzimuth;
194    use crate::azimuth::AzimuthStrategy;
195    use geometry_adapt::{Adapt, WithCs};
196    use geometry_cs::{Degree, Geographic};
197
198    type Gg = WithCs<Adapt<[f64; 2]>, Geographic<Degree>>;
199
200    #[inline]
201    fn gg(lon: f64, lat: f64) -> Gg {
202        WithCs::new(Adapt([lon, lat]))
203    }
204
205    /// `azimuth.cpp:61` — `(0,0) → (0,0)` returns `0`.
206    #[test]
207    fn coincident_is_zero() {
208        let got = GeographicAzimuth::WGS84.azimuth(&gg(0., 0.), &gg(0., 0.));
209        assert!(got.abs() < 1e-12);
210    }
211
212    /// `azimuth.cpp:62` — `(0,0) → (1,1) = 45.187718848°`.
213    #[test]
214    fn diagonal() {
215        let got = GeographicAzimuth::WGS84.azimuth(&gg(0., 0.), &gg(1., 1.));
216        let expected = 45.187_718_848_049_521_f64.to_radians();
217        assert!((got - expected).abs() < 1e-9, "got {got}");
218    }
219
220    /// `azimuth.cpp:63` — `(0,0) → (100,1) = 88.986933066°`.
221    #[test]
222    fn shallow_east() {
223        let got = GeographicAzimuth::WGS84.azimuth(&gg(0., 0.), &gg(100., 1.));
224        let expected = 88.986_933_066_023_497_f64.to_radians();
225        assert!((got - expected).abs() < 1e-9, "got {got}");
226    }
227
228    /// `azimuth.cpp:64` — `(0,0) → (-1,1) = -45.187718848°`.
229    #[test]
230    fn diagonal_west() {
231        let got = GeographicAzimuth::WGS84.azimuth(&gg(0., 0.), &gg(-1., 1.));
232        let expected = (-45.187_718_848_049_521_f64).to_radians();
233        assert!((got - expected).abs() < 1e-9, "got {got}");
234    }
235
236    /// The clamp branches of `normalize_azimuth`
237    /// (`andoyer_inverse.hpp:246-286`): the flattening correction must
238    /// not push an azimuth past 0 / ±π on the side it started.
239    #[test]
240    fn normalize_azimuth_clamps_all_four_quadrants() {
241        use super::normalize_azimuth;
242        let pi = core::f64::consts::PI;
243
244        // A ≥ 0, dA ≥ 0: an azimuth pushed below 0 clamps to 0.
245        let mut az = -0.1;
246        normalize_azimuth(&mut az, 0.05, 0.15);
247        assert_eq!(az, 0.0);
248
249        // A ≥ 0, dA < 0: an azimuth pushed above π clamps to π.
250        let mut az = pi + 0.1;
251        normalize_azimuth(&mut az, pi - 0.05, -0.15);
252        assert_eq!(az, pi);
253
254        // A < 0, dA ≤ 0: an azimuth pushed above 0 clamps to 0.
255        let mut az = 0.1;
256        normalize_azimuth(&mut az, -0.05, -0.15);
257        assert_eq!(az, 0.0);
258
259        // A < 0, dA > 0: an azimuth pushed below −π clamps to −π.
260        let mut az = -pi - 0.1;
261        normalize_azimuth(&mut az, -pi + 0.05, 0.15);
262        assert_eq!(az, -pi);
263
264        // In-range azimuths pass through untouched.
265        let mut az = 0.5;
266        normalize_azimuth(&mut az, 0.4, -0.1);
267        assert_eq!(az, 0.5);
268
269        let mut az = -0.5;
270        normalize_azimuth(&mut az, -0.4, -0.1);
271        assert_eq!(az, -0.5);
272    }
273}