Skip to content

Commit 442bb90

Browse files
authored
[GH-2830] Adds Geography dual-dispatch to ST_Length (#2854)
1 parent e7abacd commit 442bb90

7 files changed

Lines changed: 233 additions & 9 deletions

File tree

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

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,39 @@ public static String asText(Geography g) {
183183
return toJTS(g).toText();
184184
}
185185

186-
// ─── Level 2: JTS + S2 geodesic metrics ──────────────────────────────────
186+
// ─── Level 2: Geodesic metrics ───────────────────────────────────────────
187+
188+
/**
189+
* Spherical length in meters of a geography, calculated on the sphere. Edges are interpreted as
190+
* great-circle arcs; the summed arc-angle is scaled by {@link Haversine#AVG_EARTH_RADIUS}.
191+
* Multi-polylines sum the children's lengths; geography collections recurse. Returns {@code 0.0}
192+
* for point/polygon geographies and for {@code null}.
193+
*/
194+
public static double length(Geography g) {
195+
if (g == null) return 0.0;
196+
Geography typed = (g instanceof WKBGeography) ? ((WKBGeography) g).getS2Geography() : g;
197+
double radians = sphericalLength(typed);
198+
return radians * Haversine.AVG_EARTH_RADIUS;
199+
}
200+
201+
/** Arc-angle (radians) of {@code g} on the unit sphere; 0 for non-linear kinds. */
202+
private static double sphericalLength(Geography g) {
203+
if (g instanceof PolylineGeography) {
204+
double sum = 0.0;
205+
for (S2Polyline pl : ((PolylineGeography) g).getPolylines()) {
206+
sum += pl.getArclengthAngle().radians();
207+
}
208+
return sum;
209+
}
210+
if (g instanceof GeographyCollection) {
211+
double sum = 0.0;
212+
for (Geography feature : ((GeographyCollection) g).getFeatures()) {
213+
sum += sphericalLength(feature);
214+
}
215+
return sum;
216+
}
217+
return 0.0;
218+
}
187219

188220
/**
189221
* Spherical area in square meters of a geography, calculated on the sphere. The Earth is modeled
@@ -233,8 +265,7 @@ private static double sphericalArea(Geography g) {
233265

234266
/**
235267
* Geometry-to-geometry geodesic distance in meters. Uses S2ClosestEdgeQuery for true minimum
236-
* distance between any two points on the geometries (not centroid-to-centroid). Consistent with
237-
* sedona-db's s2_distance implementation.
268+
* distance between any two points on the geometries (not centroid-to-centroid).
238269
*/
239270
public static Double distance(Geography g1, Geography g2) {
240271
if (g1 == null || g2 == null) return null;

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

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,48 @@ public void asText_nullHandling() {
332332
assertNull(Functions.asText(null));
333333
}
334334

335-
// ─── Level 2: ST_Area, ST_Distance ───────────────────────────────────────
335+
// ─── Level 2: ST_Length, ST_Area, ST_Distance ────────────────────────────
336+
337+
@Test
338+
public void length_equatorDegree() throws ParseException {
339+
Geography g = Constructors.geogFromWKT("LINESTRING (0 0, 1 0)", 4326);
340+
double len = Functions.length(g);
341+
// Sphere of radius 6371008 m: 1° along a great circle is ~111,195 m.
342+
assertEquals(111195.10, len, 1.0);
343+
}
344+
345+
@Test
346+
public void length_meridianDegree() throws ParseException {
347+
Geography g = Constructors.geogFromWKT("LINESTRING (0 0, 0 1)", 4326);
348+
double len = Functions.length(g);
349+
// Meridians are great circles on a sphere — same length as the equator degree.
350+
assertEquals(111195.10, len, 1.0);
351+
}
352+
353+
@Test
354+
public void length_point_returnsZero() throws ParseException {
355+
Geography g = Constructors.geogFromWKT("POINT (1 2)", 4326);
356+
assertEquals(0.0, Functions.length(g), 0.0);
357+
}
358+
359+
@Test
360+
public void length_polygon_returnsZero() throws ParseException {
361+
Geography g = Constructors.geogFromWKT("POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", 4326);
362+
assertEquals(0.0, Functions.length(g), 0.0);
363+
}
364+
365+
@Test
366+
public void length_multilinestring_sumsChildren() throws ParseException {
367+
Geography g = Constructors.geogFromWKT("MULTILINESTRING ((0 0, 1 0), (5 0, 6 0))", 4326);
368+
double len = Functions.length(g);
369+
// Two disjoint 1° equatorial arcs → 2 * (R * 1° in radians) ≈ 222,390 m.
370+
assertEquals(2 * 111195.10, len, 2.0);
371+
}
372+
373+
@Test
374+
public void length_nullHandling() {
375+
assertEquals(0.0, Functions.length(null), 0.0);
376+
}
336377

337378
@Test
338379
public void area_unitBoxAtEquator() throws ParseException {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,5 +51,6 @@ These functions operate on geography type objects.
5151
| [ST_NPoints](Geography-Functions/ST_NPoints.md) | Integer | Return the number of points (vertices) in a geography. | v1.9.0 |
5252
| [ST_NumGeometries](Geography-Functions/ST_NumGeometries.md) | Integer | Return the number of sub-geometries in a geography (1 for single geometries). | v1.9.1 |
5353
| [ST_Distance](Geography-Functions/ST_Distance.md) | Double | Return the minimum geodesic distance between two geographies in meters. | v1.9.0 |
54+
| [ST_Length](Geography-Functions/ST_Length.md) | Double | Return the spherical length of a geography in meters, summed along great-circle edges. | v1.9.1 |
5455
| [ST_Contains](Geography-Functions/ST_Contains.md) | Boolean | Test whether geography A fully contains geography B. | v1.9.0 |
5556
| [ST_Equals](Geography-Functions/ST_Equals.md) | Boolean | Test whether two geographies are spatially equal. | v1.9.1 |
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
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_Length
21+
22+
Introduction: Returns the spherical length of a geography in meters, calculated on the sphere. The Earth is modeled as a sphere of radius `R = 6 371 008 m` (the authalic Earth radius). Each edge between successive vertices is interpreted as a great-circle arc; the per-edge arc-angles are summed and scaled by `R`.
23+
24+
Multi-linestrings sum the children's lengths; geography collections recurse and add up the lengths of their linear members. Returns `0.0` for non-linear geographies (points, polygons) and for `NULL`.
25+
26+
![ST_Length on a Geography on the sphere](../../../../image/ST_Length_geography/ST_Length_geography.svg "ST_Length on a Geography (sphere-native)")
27+
28+
If you specifically want the WGS84 ellipsoidal length (which differs from the spherical value by up to ~0.5 % depending on latitude), convert via `ST_GeogToGeometry` first and call the Geometry-typed `ST_Length(..., useSpheroid => true)` overload instead.
29+
30+
Format:
31+
32+
`ST_Length (A: Geography)`
33+
34+
Return type: `Double`
35+
36+
Since: `v1.9.1`
37+
38+
SQL Example
39+
40+
```sql
41+
SELECT ST_Length(ST_GeogFromWKT('LINESTRING (0 0, 1 0)'));
42+
```
43+
44+
Output (in meters):
45+
46+
```
47+
111195.10117748393
48+
```
49+
50+
The result is approximately 111.2 km — one degree of arc on a sphere of radius `R = 6 371 008 m`.
Lines changed: 80 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
@@ -252,12 +252,17 @@ private[apache] case class ST_Expand(inputExpressions: Seq[Expression])
252252
}
253253

254254
/**
255-
* Return the length measurement of a Geometry
255+
* Return the length measurement of a Geometry or Geography. Supports both Geometry (JTS, planar
256+
* length in the input's coordinate units) and Geography (S2, geodesic length in meters on the
257+
* WGS84 spheroid) via InferredExpression dual dispatch.
256258
*
257259
* @param inputExpressions
260+
* Geometry or Geography
258261
*/
259262
private[apache] case class ST_Length(inputExpressions: Seq[Expression])
260-
extends InferredExpression(Functions.length _) {
263+
extends InferredExpression(
264+
inferrableFunction1(Functions.length),
265+
inferrableFunction1(org.apache.sedona.common.geography.Functions.length)) {
261266

262267
protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = {
263268
copy(inputExpressions = newChildren)

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

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ import org.locationtech.jts.geom.Point
2727
import org.locationtech.jts.io.WKTReader
2828

2929
/**
30-
* Spark SQL integration tests for Geography ST functions. Tests one representative function per
31-
* architecture level: L1 (ST_NPoints), L2 (ST_Distance), L3 (ST_Contains).
30+
* Spark SQL integration tests for Geography ST functions. Representative functions per
31+
* architecture level: L1 (ST_NPoints), L2 (ST_Distance, ST_Length), L3 (ST_Contains).
3232
*/
3333
class GeographyFunctionTest extends TestBaseScala {
3434

@@ -143,10 +143,26 @@ class GeographyFunctionTest extends TestBaseScala {
143143
}
144144
}
145145

146-
// ─── Level 2: ST_Area, ST_Distance ─────────────────────────────────────
146+
// ─── Level 2: ST_Length, ST_Area, ST_Distance ──────────────────────────
147147

148148
describe("Level 2: Geodesic metrics") {
149149

150+
it("ST_Length along the equator") {
151+
val row = sparkSession
152+
.sql("SELECT ST_Length(ST_GeogFromWKT('LINESTRING (0 0, 1 0)', 4326)) AS l")
153+
.first()
154+
val len = row.getDouble(0)
155+
// Sphere of radius 6371008 m: 1° along a great circle is ~111,195 m.
156+
assertEquals(111195.10, len, 1.0)
157+
}
158+
159+
it("ST_Length of a point returns 0") {
160+
val row = sparkSession
161+
.sql("SELECT ST_Length(ST_GeogFromWKT('POINT (1 2)', 4326)) AS l")
162+
.first()
163+
assertEquals(0.0, row.getDouble(0), 0.0)
164+
}
165+
150166
it("ST_Area unit box at equator") {
151167
val row = sparkSession
152168
.sql("SELECT ST_Area(ST_GeogFromWKT('POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))', 4326)) AS a")

0 commit comments

Comments
 (0)