Skip to content

Commit 3fd82e9

Browse files
authored
[GH-2830] Adds Geography dual-dispatch to ST_Area (#2853)
1 parent 8c65dc4 commit 3fd82e9

7 files changed

Lines changed: 238 additions & 4 deletions

File tree

common/src/main/java/org/apache/sedona/common/geography/Functions.java

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,52 @@ public static String asText(Geography g) {
105105

106106
// ─── Level 2: JTS + S2 geodesic metrics ──────────────────────────────────
107107

108+
/**
109+
* Spherical area in square meters of a geography, calculated on the sphere. The Earth is modeled
110+
* as a sphere of radius {@link Haversine#AVG_EARTH_RADIUS}; the polygon's interior is integrated
111+
* along great-circle edges and scaled by R squared. Multi-polygons sum the children's areas;
112+
* geography collections recurse. Returns {@code 0.0} for point/line geographies and for {@code
113+
* null}.
114+
*/
115+
public static double area(Geography g) {
116+
if (g == null) return 0.0;
117+
Geography typed = (g instanceof WKBGeography) ? ((WKBGeography) g).getS2Geography() : g;
118+
double steradians = sphericalArea(typed);
119+
// S2 polygons can be wound either CCW (interior is the small side) or CW (interior is the
120+
// complement on the sphere). Some WKT inputs land in the latter form after parsing, which
121+
// makes S2 report the entire sphere minus the visible polygon. Always return the smaller
122+
// of the two regions so the answer is bounded by half the surface of the sphere.
123+
if (steradians > 2.0 * Math.PI) {
124+
steradians = 4.0 * Math.PI - steradians;
125+
}
126+
return steradians * Haversine.AVG_EARTH_RADIUS * Haversine.AVG_EARTH_RADIUS;
127+
}
128+
129+
/** Steradian area of {@code g} on the unit sphere; 0 for non-areal kinds. */
130+
private static double sphericalArea(Geography g) {
131+
if (g instanceof PolygonGeography) {
132+
return ((PolygonGeography) g).polygon.getArea();
133+
}
134+
if (g instanceof MultiPolygonGeography) {
135+
double sum = 0.0;
136+
for (Geography feature : ((MultiPolygonGeography) g).getFeatures()) {
137+
if (feature instanceof PolygonGeography) {
138+
sum += ((PolygonGeography) feature).polygon.getArea();
139+
}
140+
}
141+
return sum;
142+
}
143+
if (g instanceof GeographyCollection) {
144+
double sum = 0.0;
145+
for (Geography feature : ((GeographyCollection) g).getFeatures()) {
146+
sum += sphericalArea(feature);
147+
}
148+
return sum;
149+
}
150+
// Points and polylines have zero area
151+
return 0.0;
152+
}
153+
108154
/**
109155
* Geometry-to-geometry geodesic distance in meters. Uses S2ClosestEdgeQuery for true minimum
110156
* distance between any two points on the geometries (not centroid-to-centroid). Consistent with

common/src/test/java/org/apache/sedona/common/Geography/FunctionTest.java

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,57 @@ public void asText_nullHandling() {
239239
assertNull(Functions.asText(null));
240240
}
241241

242-
// ─── Level 2: ST_Distance ────────────────────────────────────────────────
242+
// ─── Level 2: ST_Area, ST_Distance ───────────────────────────────────────
243+
244+
@Test
245+
public void area_unitBoxAtEquator() throws ParseException {
246+
Geography g = Constructors.geogFromWKT("POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", 4326);
247+
double area = Functions.area(g);
248+
// S2 spherical area of a 1°x1° box near equator on a sphere of radius
249+
// Haversine.AVG_EARTH_RADIUS = 6371008.0 m. Slightly larger than the WGS84-ellipsoid
250+
// value (~1.231e10 m²) by the spheroid/sphere correction (~0.5%). Tolerance of 1e7 m²
251+
// (~0.08%) is well above floating-point drift but tight enough to catch a model swap.
252+
assertEquals(1.2364e10, area, 1e7);
253+
}
254+
255+
@Test
256+
public void area_rightTriangleAtOrigin() throws ParseException {
257+
// Right triangle with vertices (0,0), (0,1), (1,0). The polygon is wound clockwise in
258+
// lat/lon space, which would let a naïve sphere area function return the complementary
259+
// region (almost the whole Earth, ~5.1e14 m²). Asserting the small-side value (~6.18e9 m²)
260+
// proves the orientation-collapse branch is doing its job.
261+
Geography g = Constructors.geogFromWKT("POLYGON ((0 0, 0 1, 1 0, 0 0))", 4326);
262+
double area = Functions.area(g);
263+
assertEquals(6.182489e9, area, 1e6);
264+
}
265+
266+
@Test
267+
public void area_multipolygon_sumsChildren() throws ParseException {
268+
Geography g =
269+
Constructors.geogFromWKT(
270+
"MULTIPOLYGON (((0 0, 1 0, 1 1, 0 1, 0 0)), ((10 10, 11 10, 11 11, 10 11, 10 10)))",
271+
4326);
272+
double area = Functions.area(g);
273+
// ~1.236e10 (1°² near equator) + ~1.216e10 (1°² near 10°N). Tolerance 5e7 m² ~ 0.2%.
274+
assertEquals(2.452e10, area, 5e7);
275+
}
276+
277+
@Test
278+
public void area_point_returnsZero() throws ParseException {
279+
Geography g = Constructors.geogFromWKT("POINT (1 2)", 4326);
280+
assertEquals(0.0, Functions.area(g), 0.0);
281+
}
282+
283+
@Test
284+
public void area_linestring_returnsZero() throws ParseException {
285+
Geography g = Constructors.geogFromWKT("LINESTRING (0 0, 1 1)", 4326);
286+
assertEquals(0.0, Functions.area(g), 0.0);
287+
}
288+
289+
@Test
290+
public void area_nullHandling() {
291+
assertEquals(0.0, Functions.area(null), 0.0);
292+
}
243293

244294
@Test
245295
public void distance_twoPoints() throws ParseException {

docs/api/sql/geography/Geography-Functions.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ These functions operate on geography type objects.
4141

4242
| Function | Return type | Description | Since |
4343
| :--- | :--- | :--- | :--- |
44+
| [ST_Area](Geography-Functions/ST_Area.md) | Double | Return the geodesic area of a geography in square meters (WGS84 spheroid). | v1.9.1 |
4445
| [ST_AsEWKT](Geography-Functions/ST_AsEWKT.md) | String | Return the Extended Well-Known Text representation of a geography. | v1.8.0 |
4546
| [ST_AsText](Geography-Functions/ST_AsText.md) | String | Return the Well-Known Text (WKT) representation of a geography. | v1.9.1 |
4647
| [ST_Envelope](Geography-Functions/ST_Envelope.md) | Geography | Return the bounding box (envelope) of a geography. Supports anti-meridian splitting. | v1.8.0 |
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<!--
2+
Licensed to the Apache Software Foundation (ASF) under one
3+
or more contributor license agreements. See the NOTICE file
4+
distributed with this work for additional information
5+
regarding copyright ownership. The ASF licenses this file
6+
to you under the Apache License, Version 2.0 (the
7+
"License"); you may not use this file except in compliance
8+
with the License. You may obtain a copy of the License at
9+
10+
http://www.apache.org/licenses/LICENSE-2.0
11+
12+
Unless required by applicable law or agreed to in writing,
13+
software distributed under the License is distributed on an
14+
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
KIND, either express or implied. See the License for the
16+
specific language governing permissions and limitations
17+
under the License.
18+
-->
19+
20+
# ST_Area
21+
22+
Introduction: Returns the spherical area of a geography in square meters, calculated on the sphere. The Earth is modeled as a sphere of radius `R = 6 371 008 m` (the authalic Earth radius); the result is the area of the polygon's interior on that sphere. Returns `0.0` for non-areal geographies (points, linestrings) and for `NULL`.
23+
24+
Multi-polygons sum the children's areas; geography collections recurse into their members.
25+
26+
![ST_Area on a Geography on the sphere](../../../../image/ST_Area_geography/ST_Area_geography.svg "ST_Area on a Geography (sphere-native)")
27+
28+
The result is the area of the polygon's user-intended interior. If the input ring happens to be wound in the orientation that would describe the rest of the planet instead, Sedona returns the smaller of the two regions, so the answer is always bounded by half the surface of the Earth (~2.55 × 10¹⁴ m²).
29+
30+
If you specifically want the WGS84 ellipsoidal value (which is ~0.5 % lower for typical shapes), convert via `ST_GeogToGeometry` first and use the geometry overload:
31+
32+
```sql
33+
SELECT ST_Area(ST_GeogToGeometry(geog), true) FROM …;
34+
```
35+
36+
Format:
37+
38+
`ST_Area (A: Geography)`
39+
40+
Return type: `Double`
41+
42+
Since: `v1.9.1`
43+
44+
SQL Example
45+
46+
```sql
47+
SELECT ST_Area(ST_GeogFromWKT('POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))'));
48+
```
49+
50+
Output (in m²):
51+
52+
```
53+
1.2364028804392242E10
54+
```
55+
56+
That is approximately 12,364 km² — the spherical area of a 1°×1° box near the equator.
Lines changed: 58 additions & 0 deletions
Loading

spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Functions.scala

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -268,12 +268,17 @@ private[apache] case class ST_Length2D(inputExpressions: Seq[Expression])
268268
}
269269

270270
/**
271-
* Return the area measurement of a Geometry.
271+
* Return the area measurement of a Geometry or Geography. Supports both Geometry (JTS, planar
272+
* area in the input's coordinate units) and Geography (S2, geodesic area in square meters on the
273+
* WGS84 spheroid) via InferredExpression dual dispatch.
272274
*
273275
* @param inputExpressions
276+
* Geometry or Geography
274277
*/
275278
private[apache] case class ST_Area(inputExpressions: Seq[Expression])
276-
extends InferredExpression(Functions.area _) {
279+
extends InferredExpression(
280+
inferrableFunction1(Functions.area),
281+
inferrableFunction1(org.apache.sedona.common.geography.Functions.area)) {
277282

278283
protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = {
279284
copy(inputExpressions = newChildren)

spark/common/src/test/scala/org/apache/sedona/sql/geography/GeographyFunctionTest.scala

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,10 +130,28 @@ class GeographyFunctionTest extends TestBaseScala {
130130
}
131131
}
132132

133-
// ─── Level 2: ST_Distance ──────────────────────────────────────────────
133+
// ─── Level 2: ST_Area, ST_Distance ─────────────────────────────────────
134134

135135
describe("Level 2: Geodesic metrics") {
136136

137+
it("ST_Area unit box at equator") {
138+
val row = sparkSession
139+
.sql("SELECT ST_Area(ST_GeogFromWKT('POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))', 4326)) AS a")
140+
.first()
141+
val area = row.getDouble(0)
142+
// Spherical area of a 1°×1° box near the equator on a sphere of radius 6371008.0 m.
143+
// Tolerance 1e7 m² (~0.08%) absorbs floating-point drift while staying tight enough to
144+
// catch a model swap.
145+
assertEquals(1.2364e10, area, 1e7)
146+
}
147+
148+
it("ST_Area of a point returns 0") {
149+
val row = sparkSession
150+
.sql("SELECT ST_Area(ST_GeogFromWKT('POINT (1 2)', 4326)) AS a")
151+
.first()
152+
assertEquals(0.0, row.getDouble(0), 0.0)
153+
}
154+
137155
it("ST_Distance between two points") {
138156
val row = sparkSession
139157
.sql("""

0 commit comments

Comments
 (0)