Skip to main content

geometry_strategy/geographic/
area.rs

1//! Geographic (spheroidal) surface area via the authalic-sphere
2//! approximation.
3//!
4//! Mirrors the *intent* of
5//! `boost::geometry::strategy::area::geographic` from
6//! `boost/geometry/strategies/geographic/area.hpp`, but **not** its full
7//! numerics. Boost's geographic area is a Gauss–Legendre / Clenshaw
8//! series over the spheroid
9//! (`boost/geometry/formulas/area_formulas.hpp`, ~400 lines of
10//! coefficient tables plus a per-segment geodesic-inverse call). That
11//! series is out of scope here.
12//!
13//! Instead this strategy maps the ellipsoid onto its **authalic sphere**
14//! — the sphere of radius `R_A` that has the *same total surface area*
15//! as the ellipsoid — and applies the exact spherical trapezoidal
16//! excess rule on it (the same kernel as
17//! [`SphericalArea`](crate::spherical::SphericalArea)):
18//!
19//! ```text
20//! R_A = sqrt( a²/2 · (1 + (1 − e²)/e · atanh(e)) )
21//! A   = R_A² · Σ trapezoidal_excess(edgeᵢ)
22//! ```
23//!
24//! # Precision
25//!
26//! The authalic-sphere area is a standard, *correct* approximation (it
27//! preserves the ellipsoid's total surface area exactly), but it is not
28//! Boost's Vincenty-grade series. For a 1° × 1° box near the equator on
29//! WGS84 it returns ≈ `12_364` km² against Boost's ≈ `12_309` km² — a
30//! ~0.45 % divergence. Callers needing exact spheroidal area should wait
31//! for the series port; this strategy is the honest, low-error default
32//! so the family dispatch (`area(&geographic_polygon)`) has a working
33//! implementation rather than a `todo!`.
34
35use geometry_cs::{CoordinateSystem, GeographicFamily, Spheroid};
36use geometry_tag::SameAs;
37use geometry_trait::{Point, PointOrder, Polygon, Ring};
38
39use crate::area::AreaStrategy;
40
41#[cfg(feature = "std")]
42use crate::normalise::HasAngularUnits;
43#[cfg(feature = "std")]
44use crate::spherical::area::excess_accumulator;
45
46/// Geographic surface area via the authalic-sphere approximation.
47///
48/// Carries the reference [`Spheroid`]; `Default::default()` produces
49/// [`GeographicArea::WGS84`]. See the module docs for the precision
50/// caveat.
51#[derive(Debug, Clone, Copy)]
52pub struct GeographicArea {
53    /// Reference ellipsoid the area is measured on.
54    pub spheroid: Spheroid,
55}
56
57impl GeographicArea {
58    /// Area on the WGS84 reference ellipsoid — the default for nearly
59    /// every real geographic dataset (matches `Andoyer::WGS84`).
60    pub const WGS84: Self = Self {
61        spheroid: Spheroid::WGS84,
62    };
63
64    /// The authalic radius `R_A` of this spheroid — the radius of the
65    /// sphere with the same total surface area as the ellipsoid.
66    #[cfg(feature = "std")]
67    #[inline]
68    #[must_use]
69    fn authalic_radius(&self) -> f64 {
70        let a = self.spheroid.equatorial_radius;
71        let e2 = self.spheroid.eccentricity_squared();
72        let e = e2.sqrt();
73        // R_A = sqrt( a²/2 · (1 + (1 − e²)/e · atanh(e)) ).
74        (a * a / 2.0 * (1.0 + (1.0 - e2) / e * e.atanh())).sqrt()
75    }
76}
77
78impl Default for GeographicArea {
79    #[inline]
80    fn default() -> Self {
81        Self::WGS84
82    }
83}
84
85/// Geographic surface area for a [`Polygon`] — the authalic-sphere
86/// approximation applied per ring (outer positive, holes negative).
87///
88/// Separate from [`GeographicArea`] for the same Rust-coherence reason
89/// [`SphericalPolygonArea`](crate::spherical::SphericalPolygonArea) is
90/// separate from [`SphericalArea`](crate::spherical::SphericalArea).
91#[derive(Debug, Clone, Copy)]
92pub struct GeographicPolygonArea {
93    /// Reference ellipsoid, forwarded to the per-ring [`GeographicArea`].
94    pub spheroid: Spheroid,
95}
96
97impl GeographicPolygonArea {
98    /// Area on the WGS84 reference ellipsoid. See [`GeographicArea::WGS84`].
99    pub const WGS84: Self = Self {
100        spheroid: Spheroid::WGS84,
101    };
102}
103
104impl Default for GeographicPolygonArea {
105    #[inline]
106    fn default() -> Self {
107        Self::WGS84
108    }
109}
110
111// ---- Ring ------------------------------------------------------------
112
113#[cfg(feature = "std")]
114impl<R> AreaStrategy<R> for GeographicArea
115where
116    R: Ring,
117    <R::Point as Point>::Cs: CoordinateSystem,
118    <<R::Point as Point>::Cs as CoordinateSystem>::Family: SameAs<GeographicFamily>,
119    R::Point: Point<Scalar = f64>,
120    <R::Point as Point>::Cs: HasAngularUnits,
121{
122    type Out = f64;
123
124    #[inline]
125    fn area(&self, r: &R) -> f64 {
126        // Exact spherical trapezoidal excess evaluated on the authalic
127        // sphere. The excess kernel is family-agnostic (it only reads
128        // radian lon/lat), so it feeds a geographic ring identically;
129        // the `SameAs<GeographicFamily>` fence above is what enforces
130        // the geographic-only rule on this public strategy.
131        let radius = self.authalic_radius();
132        let signed = excess_accumulator::<R>(r) * radius * radius;
133        match r.point_order() {
134            PointOrder::Clockwise => signed,
135            PointOrder::CounterClockwise => -signed,
136        }
137    }
138}
139
140// ---- Polygon ---------------------------------------------------------
141
142#[cfg(feature = "std")]
143impl<P> AreaStrategy<P> for GeographicPolygonArea
144where
145    P: Polygon,
146    GeographicArea: AreaStrategy<P::Ring, Out = f64>,
147{
148    type Out = f64;
149
150    #[inline]
151    fn area(&self, p: &P) -> f64 {
152        let ring = GeographicArea {
153            spheroid: self.spheroid,
154        };
155        let mut total = ring.area(p.exterior());
156        for inner in p.interiors() {
157            total += ring.area(inner);
158        }
159        total
160    }
161}
162
163#[cfg(all(test, feature = "std"))]
164mod tests {
165    //! Reference values from
166    //! `boost/geometry/test/algorithms/area/area_geo.cpp` — a 1° × 1°
167    //! box near the equator on WGS84 ≈ `12_309` km². The authalic-sphere
168    //! approximation lands within ~0.5 %.
169    #![allow(
170        clippy::float_cmp,
171        reason = "areas are compared with an explicit relative tolerance, not `==`"
172    )]
173
174    use super::{GeographicArea, GeographicPolygonArea};
175    use crate::area::AreaStrategy;
176    use geometry_adapt::{Adapt, WithCs};
177    use geometry_cs::{Degree, Geographic};
178    use geometry_model::{Polygon, Ring};
179
180    type Gg = WithCs<Adapt<[f64; 2]>, Geographic<Degree>>;
181
182    #[inline]
183    fn gg(lon: f64, lat: f64) -> Gg {
184        WithCs::new(Adapt([lon, lat]))
185    }
186
187    /// `area_geo.cpp` — 1° × 1° box near the equator ≈ `12_309` km²
188    /// (12.309e9 m²). Approximation tolerance 2 %.
189    #[test]
190    fn one_degree_box_near_equator_wgs84() {
191        let r: Ring<Gg> = Ring::from_vec(vec![
192            gg(0., 0.),
193            gg(1., 0.),
194            gg(1., 1.),
195            gg(0., 1.),
196            gg(0., 0.),
197        ]);
198        // The ring above is wound so that the trapezoidal excess is
199        // negative on a default (clockwise) ring; take the magnitude.
200        let got = GeographicArea::WGS84.area(&r).abs();
201        let expected = 12_309e6;
202        assert!(
203            (got - expected).abs() / expected < 0.02,
204            "got {} km² expected ~12309 km²",
205            got / 1e6
206        );
207    }
208
209    /// The polygon path matches the ring path (no holes).
210    #[test]
211    fn polygon_box_matches_ring() {
212        let pg: Polygon<Gg> = Polygon::new(Ring::from_vec(vec![
213            gg(0., 0.),
214            gg(1., 0.),
215            gg(1., 1.),
216            gg(0., 1.),
217            gg(0., 0.),
218        ]));
219        let got = GeographicPolygonArea::WGS84.area(&pg).abs();
220        let expected = 12_309e6;
221        assert!(
222            (got - expected).abs() / expected < 0.02,
223            "got {}",
224            got / 1e6
225        );
226    }
227}