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}