From 2ec6cd1b65705bbdcd987148d37a04aa31cbd716 Mon Sep 17 00:00:00 2001 From: zhangfengcdt Date: Wed, 22 Apr 2026 09:42:20 -0700 Subject: [PATCH 1/3] [GH-2830] Adds Geography dual-dispatch to ST_Length --- .../sedona/common/geography/Functions.java | 7 +++ .../sedona/common/Geography/FunctionTest.java | 29 +++++++++++- docs/api/sql/geography/Geography-Functions.md | 1 + .../Geography-Functions/ST_Length.md | 44 +++++++++++++++++++ .../sedona_sql/expressions/Functions.scala | 4 +- .../sql/geography/GeographyFunctionTest.scala | 18 +++++++- 6 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 docs/api/sql/geography/Geography-Functions/ST_Length.md diff --git a/common/src/main/java/org/apache/sedona/common/geography/Functions.java b/common/src/main/java/org/apache/sedona/common/geography/Functions.java index e3166fa9e0f..2dcc87378a4 100644 --- a/common/src/main/java/org/apache/sedona/common/geography/Functions.java +++ b/common/src/main/java/org/apache/sedona/common/geography/Functions.java @@ -23,6 +23,7 @@ import java.util.List; import org.apache.sedona.common.S2Geography.*; import org.apache.sedona.common.sphere.Haversine; +import org.apache.sedona.common.sphere.Spheroid; import org.locationtech.jts.geom.Geometry; public class Functions { @@ -87,6 +88,12 @@ public static int nPoints(Geography g) { // ─── Level 2: JTS + S2 geodesic metrics ────────────────────────────────── + /** Geodesic length in meters (WGS84 spheroid). Returns 0 for non-linear geographies. */ + public static double length(Geography g) { + if (g == null) return 0.0; + return Spheroid.length(toJTS(g)); + } + /** * Geometry-to-geometry geodesic distance in meters. Uses S2ClosestEdgeQuery for true minimum * distance between any two points on the geometries (not centroid-to-centroid). Consistent with diff --git a/common/src/test/java/org/apache/sedona/common/Geography/FunctionTest.java b/common/src/test/java/org/apache/sedona/common/Geography/FunctionTest.java index 6787c5ad876..a59d798f3ec 100644 --- a/common/src/test/java/org/apache/sedona/common/Geography/FunctionTest.java +++ b/common/src/test/java/org/apache/sedona/common/Geography/FunctionTest.java @@ -144,7 +144,34 @@ public void nPoints_polygon() throws ParseException { assertEquals(5, Functions.nPoints(g)); } - // ─── Level 2: ST_Distance ──────────────────────────────────────────────── + // ─── Level 2: ST_Length, ST_Distance ───────────────────────────────────── + + @Test + public void length_equatorDegree() throws ParseException { + Geography g = Constructors.geogFromWKT("LINESTRING (0 0, 1 0)", 4326); + double len = Functions.length(g); + // WGS84 spheroid: 1° along the equator is ~111,319 meters + assertEquals(111319.0, len, 5.0); + } + + @Test + public void length_meridianDegree() throws ParseException { + Geography g = Constructors.geogFromWKT("LINESTRING (0 0, 0 1)", 4326); + double len = Functions.length(g); + // WGS84 spheroid: 1° along a meridian is ~110,574 meters (at the equator) + assertEquals(110574.0, len, 50.0); + } + + @Test + public void length_point_returnsZero() throws ParseException { + Geography g = Constructors.geogFromWKT("POINT (1 2)", 4326); + assertEquals(0.0, Functions.length(g), 0.0); + } + + @Test + public void length_nullHandling() { + assertEquals(0.0, Functions.length(null), 0.0); + } @Test public void distance_twoPoints() throws ParseException { diff --git a/docs/api/sql/geography/Geography-Functions.md b/docs/api/sql/geography/Geography-Functions.md index 7648b0c0a1e..ddc5776efdf 100644 --- a/docs/api/sql/geography/Geography-Functions.md +++ b/docs/api/sql/geography/Geography-Functions.md @@ -45,4 +45,5 @@ These functions operate on geography type objects. | [ST_Envelope](Geography-Functions/ST_Envelope.md) | Geography | Return the bounding box (envelope) of a geography. Supports anti-meridian splitting. | v1.8.0 | | [ST_NPoints](Geography-Functions/ST_NPoints.md) | Integer | Return the number of points (vertices) in a geography. | v1.9.0 | | [ST_Distance](Geography-Functions/ST_Distance.md) | Double | Return the minimum geodesic distance between two geographies in meters. | v1.9.0 | +| [ST_Length](Geography-Functions/ST_Length.md) | Double | Return the geodesic length of a geography in meters (WGS84 spheroid). | v1.9.1 | | [ST_Contains](Geography-Functions/ST_Contains.md) | Boolean | Test whether geography A fully contains geography B. | v1.9.0 | diff --git a/docs/api/sql/geography/Geography-Functions/ST_Length.md b/docs/api/sql/geography/Geography-Functions/ST_Length.md new file mode 100644 index 00000000000..e19a56261ad --- /dev/null +++ b/docs/api/sql/geography/Geography-Functions/ST_Length.md @@ -0,0 +1,44 @@ + + +# ST_Length + +Introduction: Returns the geodesic length of a geography object in meters, calculated on the WGS84 spheroid using GeographicLib. Only linestring and multilinestring geographies have non-zero length. Returns 0 for points and polygons. + +Format: + +`ST_Length (A: Geography)` + +Return type: `Double` + +Since: `v1.9.1` + +SQL Example + +```sql +SELECT ST_Length(ST_GeogFromWKT('LINESTRING (0 0, 1 1)')); +``` + +Output: + +``` +156899.56829134026 +``` + +The result is approximately 157 km — the geodesic length of a line from (0,0) to (1,1). diff --git a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Functions.scala b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Functions.scala index 839ec2140f8..4b2283072e5 100644 --- a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Functions.scala +++ b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Functions.scala @@ -247,7 +247,9 @@ private[apache] case class ST_Expand(inputExpressions: Seq[Expression]) * @param inputExpressions */ private[apache] case class ST_Length(inputExpressions: Seq[Expression]) - extends InferredExpression(Functions.length _) { + extends InferredExpression( + inferrableFunction1(Functions.length), + inferrableFunction1(org.apache.sedona.common.geography.Functions.length)) { protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = { copy(inputExpressions = newChildren) diff --git a/spark/common/src/test/scala/org/apache/sedona/sql/geography/GeographyFunctionTest.scala b/spark/common/src/test/scala/org/apache/sedona/sql/geography/GeographyFunctionTest.scala index 7fe76730d27..dc1d1423862 100644 --- a/spark/common/src/test/scala/org/apache/sedona/sql/geography/GeographyFunctionTest.scala +++ b/spark/common/src/test/scala/org/apache/sedona/sql/geography/GeographyFunctionTest.scala @@ -89,10 +89,26 @@ class GeographyFunctionTest extends TestBaseScala { } } - // ─── Level 2: ST_Distance ────────────────────────────────────────────── + // ─── Level 2: ST_Length, ST_Distance ─────────────────────────────────── describe("Level 2: Geodesic metrics") { + it("ST_Length along the equator") { + val row = sparkSession + .sql("SELECT ST_Length(ST_GeogFromWKT('LINESTRING (0 0, 1 0)', 4326)) AS l") + .first() + val len = row.getDouble(0) + // WGS84 spheroid: 1° along the equator is ~111,319 meters + assertEquals(111319.0, len, 5.0) + } + + it("ST_Length of a point returns 0") { + val row = sparkSession + .sql("SELECT ST_Length(ST_GeogFromWKT('POINT (1 2)', 4326)) AS l") + .first() + assertEquals(0.0, row.getDouble(0), 0.0) + } + it("ST_Distance between two points") { val row = sparkSession .sql(""" From c340114f5d10ffc3c8f88c8c3323e3c59cf25073 Mon Sep 17 00:00:00 2001 From: zhangfengcdt Date: Wed, 22 Apr 2026 11:57:28 -0700 Subject: [PATCH 2/3] address copilot comments --- .../java/org/apache/sedona/common/geography/Functions.java | 2 +- docs/api/sql/geography/Geography-Functions/ST_Length.md | 2 +- .../apache/spark/sql/sedona_sql/expressions/Functions.scala | 5 ++++- .../apache/sedona/sql/geography/GeographyFunctionTest.scala | 4 ++-- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/common/src/main/java/org/apache/sedona/common/geography/Functions.java b/common/src/main/java/org/apache/sedona/common/geography/Functions.java index 2dcc87378a4..787c8a6cbe9 100644 --- a/common/src/main/java/org/apache/sedona/common/geography/Functions.java +++ b/common/src/main/java/org/apache/sedona/common/geography/Functions.java @@ -86,7 +86,7 @@ public static int nPoints(Geography g) { return toJTS(g).getNumPoints(); } - // ─── Level 2: JTS + S2 geodesic metrics ────────────────────────────────── + // ─── Level 2: Geodesic metrics (mix of S2 and WGS84 spheroid backends) ── /** Geodesic length in meters (WGS84 spheroid). Returns 0 for non-linear geographies. */ public static double length(Geography g) { diff --git a/docs/api/sql/geography/Geography-Functions/ST_Length.md b/docs/api/sql/geography/Geography-Functions/ST_Length.md index e19a56261ad..e7a7192dc68 100644 --- a/docs/api/sql/geography/Geography-Functions/ST_Length.md +++ b/docs/api/sql/geography/Geography-Functions/ST_Length.md @@ -19,7 +19,7 @@ # ST_Length -Introduction: Returns the geodesic length of a geography object in meters, calculated on the WGS84 spheroid using GeographicLib. Only linestring and multilinestring geographies have non-zero length. Returns 0 for points and polygons. +Introduction: Returns the geodesic length of a geography object in meters, calculated on the WGS84 spheroid using GeographicLib. Linestring and multilinestring geographies have non-zero length, and geometrycollection geographies return the sum of the lengths of their linear components. Returns 0 for points and polygons. Format: diff --git a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Functions.scala b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Functions.scala index 4b2283072e5..a83432ac33c 100644 --- a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Functions.scala +++ b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Functions.scala @@ -242,9 +242,12 @@ private[apache] case class ST_Expand(inputExpressions: Seq[Expression]) } /** - * Return the length measurement of a Geometry + * Return the length measurement of a Geometry or Geography. Supports both Geometry (JTS, planar + * length in the input's coordinate units) and Geography (S2, geodesic length in meters on the + * WGS84 spheroid) via InferredExpression dual dispatch. * * @param inputExpressions + * Geometry or Geography */ private[apache] case class ST_Length(inputExpressions: Seq[Expression]) extends InferredExpression( diff --git a/spark/common/src/test/scala/org/apache/sedona/sql/geography/GeographyFunctionTest.scala b/spark/common/src/test/scala/org/apache/sedona/sql/geography/GeographyFunctionTest.scala index dc1d1423862..44e74e40351 100644 --- a/spark/common/src/test/scala/org/apache/sedona/sql/geography/GeographyFunctionTest.scala +++ b/spark/common/src/test/scala/org/apache/sedona/sql/geography/GeographyFunctionTest.scala @@ -26,8 +26,8 @@ import org.junit.Assert.{assertEquals, assertNotNull, assertTrue} import org.locationtech.jts.geom.Geometry /** - * Spark SQL integration tests for Geography ST functions. Tests one representative function per - * architecture level: L1 (ST_NPoints), L2 (ST_Distance), L3 (ST_Contains). + * Spark SQL integration tests for Geography ST functions. Representative functions per + * architecture level: L1 (ST_NPoints), L2 (ST_Distance, ST_Length), L3 (ST_Contains). */ class GeographyFunctionTest extends TestBaseScala { From 2fb44a2dd3206e8c44056be8186a4ba6ef03148d Mon Sep 17 00:00:00 2001 From: zhangfengcdt Date: Tue, 28 Apr 2026 10:12:40 -0700 Subject: [PATCH 3/3] change to use s2 library --- .../sedona/common/geography/Functions.java | 36 +++++++-- .../sedona/common/Geography/FunctionTest.java | 22 ++++- docs/api/sql/geography/Geography-Functions.md | 2 +- .../Geography-Functions/ST_Length.md | 16 ++-- .../ST_Length_geography.svg | 80 +++++++++++++++++++ .../sql/geography/GeographyFunctionTest.scala | 4 +- 6 files changed, 142 insertions(+), 18 deletions(-) create mode 100644 docs/image/ST_Length_geography/ST_Length_geography.svg diff --git a/common/src/main/java/org/apache/sedona/common/geography/Functions.java b/common/src/main/java/org/apache/sedona/common/geography/Functions.java index 787c8a6cbe9..287674d6ac0 100644 --- a/common/src/main/java/org/apache/sedona/common/geography/Functions.java +++ b/common/src/main/java/org/apache/sedona/common/geography/Functions.java @@ -23,7 +23,6 @@ import java.util.List; import org.apache.sedona.common.S2Geography.*; import org.apache.sedona.common.sphere.Haversine; -import org.apache.sedona.common.sphere.Spheroid; import org.locationtech.jts.geom.Geometry; public class Functions { @@ -86,18 +85,43 @@ public static int nPoints(Geography g) { return toJTS(g).getNumPoints(); } - // ─── Level 2: Geodesic metrics (mix of S2 and WGS84 spheroid backends) ── + // ─── Level 2: Geodesic metrics ─────────────────────────────────────────── - /** Geodesic length in meters (WGS84 spheroid). Returns 0 for non-linear geographies. */ + /** + * Spherical length in meters of a geography, calculated on the sphere. Edges are interpreted as + * great-circle arcs; the summed arc-angle is scaled by {@link Haversine#AVG_EARTH_RADIUS}. + * Multi-polylines sum the children's lengths; geography collections recurse. Returns {@code 0.0} + * for point/polygon geographies and for {@code null}. + */ public static double length(Geography g) { if (g == null) return 0.0; - return Spheroid.length(toJTS(g)); + Geography typed = (g instanceof WKBGeography) ? ((WKBGeography) g).getS2Geography() : g; + double radians = sphericalLength(typed); + return radians * Haversine.AVG_EARTH_RADIUS; + } + + /** Arc-angle (radians) of {@code g} on the unit sphere; 0 for non-linear kinds. */ + private static double sphericalLength(Geography g) { + if (g instanceof PolylineGeography) { + double sum = 0.0; + for (S2Polyline pl : ((PolylineGeography) g).getPolylines()) { + sum += pl.getArclengthAngle().radians(); + } + return sum; + } + if (g instanceof GeographyCollection) { + double sum = 0.0; + for (Geography feature : ((GeographyCollection) g).getFeatures()) { + sum += sphericalLength(feature); + } + return sum; + } + return 0.0; } /** * Geometry-to-geometry geodesic distance in meters. Uses S2ClosestEdgeQuery for true minimum - * distance between any two points on the geometries (not centroid-to-centroid). Consistent with - * sedona-db's s2_distance implementation. + * distance between any two points on the geometries (not centroid-to-centroid). */ public static Double distance(Geography g1, Geography g2) { if (g1 == null || g2 == null) return null; diff --git a/common/src/test/java/org/apache/sedona/common/Geography/FunctionTest.java b/common/src/test/java/org/apache/sedona/common/Geography/FunctionTest.java index a59d798f3ec..729bf43b62d 100644 --- a/common/src/test/java/org/apache/sedona/common/Geography/FunctionTest.java +++ b/common/src/test/java/org/apache/sedona/common/Geography/FunctionTest.java @@ -150,16 +150,16 @@ public void nPoints_polygon() throws ParseException { public void length_equatorDegree() throws ParseException { Geography g = Constructors.geogFromWKT("LINESTRING (0 0, 1 0)", 4326); double len = Functions.length(g); - // WGS84 spheroid: 1° along the equator is ~111,319 meters - assertEquals(111319.0, len, 5.0); + // Sphere of radius 6371008 m: 1° along a great circle is ~111,195 m. + assertEquals(111195.10, len, 1.0); } @Test public void length_meridianDegree() throws ParseException { Geography g = Constructors.geogFromWKT("LINESTRING (0 0, 0 1)", 4326); double len = Functions.length(g); - // WGS84 spheroid: 1° along a meridian is ~110,574 meters (at the equator) - assertEquals(110574.0, len, 50.0); + // Meridians are great circles on a sphere — same length as the equator degree. + assertEquals(111195.10, len, 1.0); } @Test @@ -168,6 +168,20 @@ public void length_point_returnsZero() throws ParseException { assertEquals(0.0, Functions.length(g), 0.0); } + @Test + public void length_polygon_returnsZero() throws ParseException { + Geography g = Constructors.geogFromWKT("POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", 4326); + assertEquals(0.0, Functions.length(g), 0.0); + } + + @Test + public void length_multilinestring_sumsChildren() throws ParseException { + Geography g = Constructors.geogFromWKT("MULTILINESTRING ((0 0, 1 0), (5 0, 6 0))", 4326); + double len = Functions.length(g); + // Two disjoint 1° equatorial arcs → 2 * (R * 1° in radians) ≈ 222,390 m. + assertEquals(2 * 111195.10, len, 2.0); + } + @Test public void length_nullHandling() { assertEquals(0.0, Functions.length(null), 0.0); diff --git a/docs/api/sql/geography/Geography-Functions.md b/docs/api/sql/geography/Geography-Functions.md index ddc5776efdf..65b6048e51c 100644 --- a/docs/api/sql/geography/Geography-Functions.md +++ b/docs/api/sql/geography/Geography-Functions.md @@ -45,5 +45,5 @@ These functions operate on geography type objects. | [ST_Envelope](Geography-Functions/ST_Envelope.md) | Geography | Return the bounding box (envelope) of a geography. Supports anti-meridian splitting. | v1.8.0 | | [ST_NPoints](Geography-Functions/ST_NPoints.md) | Integer | Return the number of points (vertices) in a geography. | v1.9.0 | | [ST_Distance](Geography-Functions/ST_Distance.md) | Double | Return the minimum geodesic distance between two geographies in meters. | v1.9.0 | -| [ST_Length](Geography-Functions/ST_Length.md) | Double | Return the geodesic length of a geography in meters (WGS84 spheroid). | v1.9.1 | +| [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 | | [ST_Contains](Geography-Functions/ST_Contains.md) | Boolean | Test whether geography A fully contains geography B. | v1.9.0 | diff --git a/docs/api/sql/geography/Geography-Functions/ST_Length.md b/docs/api/sql/geography/Geography-Functions/ST_Length.md index e7a7192dc68..2cbfbb1b870 100644 --- a/docs/api/sql/geography/Geography-Functions/ST_Length.md +++ b/docs/api/sql/geography/Geography-Functions/ST_Length.md @@ -19,7 +19,13 @@ # ST_Length -Introduction: Returns the geodesic length of a geography object in meters, calculated on the WGS84 spheroid using GeographicLib. Linestring and multilinestring geographies have non-zero length, and geometrycollection geographies return the sum of the lengths of their linear components. Returns 0 for points and polygons. +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`. + +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`. + +![ST_Length on a Geography on the sphere](../../../../image/ST_Length_geography/ST_Length_geography.svg "ST_Length on a Geography (sphere-native)") + +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. Format: @@ -32,13 +38,13 @@ Since: `v1.9.1` SQL Example ```sql -SELECT ST_Length(ST_GeogFromWKT('LINESTRING (0 0, 1 1)')); +SELECT ST_Length(ST_GeogFromWKT('LINESTRING (0 0, 1 0)')); ``` -Output: +Output (in meters): ``` -156899.56829134026 +111195.10117748393 ``` -The result is approximately 157 km — the geodesic length of a line from (0,0) to (1,1). +The result is approximately 111.2 km — one degree of arc on a sphere of radius `R = 6 371 008 m`. diff --git a/docs/image/ST_Length_geography/ST_Length_geography.svg b/docs/image/ST_Length_geography/ST_Length_geography.svg new file mode 100644 index 00000000000..06fd8d4e7b7 --- /dev/null +++ b/docs/image/ST_Length_geography/ST_Length_geography.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + ST_Length on a Geography (sphere-native) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + θ₁ + θ₂ + θ₃ + + + edges are great-circle arcs + + + + + + + How length is measured + + + + 1 + For each edge, compute its + arc-angle θᵢ on the unit sphere. + + + + 2 + Sum the arc-angles across + all edges and sub-features. + + + + 3 + Multiply by Earth radius R + (6 371 008 m) to get meters. + + + + + length = R · Σ θᵢ + + + diff --git a/spark/common/src/test/scala/org/apache/sedona/sql/geography/GeographyFunctionTest.scala b/spark/common/src/test/scala/org/apache/sedona/sql/geography/GeographyFunctionTest.scala index 44e74e40351..760d44af91b 100644 --- a/spark/common/src/test/scala/org/apache/sedona/sql/geography/GeographyFunctionTest.scala +++ b/spark/common/src/test/scala/org/apache/sedona/sql/geography/GeographyFunctionTest.scala @@ -98,8 +98,8 @@ class GeographyFunctionTest extends TestBaseScala { .sql("SELECT ST_Length(ST_GeogFromWKT('LINESTRING (0 0, 1 0)', 4326)) AS l") .first() val len = row.getDouble(0) - // WGS84 spheroid: 1° along the equator is ~111,319 meters - assertEquals(111319.0, len, 5.0) + // Sphere of radius 6371008 m: 1° along a great circle is ~111,195 m. + assertEquals(111195.10, len, 1.0) } it("ST_Length of a point returns 0") {