This is an automated email from the ASF dual-hosted git repository.
jiayu pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/sedona.git
The following commit(s) were added to refs/heads/master by this push:
new ff2df67a5a [GH-2286] ST_Envelope on Geography (#2285)
ff2df67a5a is described below
commit ff2df67a5ac96a9ccea1602e21708b579fc1affc
Author: Zhuocheng Shang <[email protected]>
AuthorDate: Fri Aug 22 00:41:29 2025 -0700
[GH-2286] ST_Envelope on Geography (#2285)
* ST_Envelope on Geography
* example of split polygons at antimeridian
* ass SQL and DataFrameAPI integration
* avoid ambiguous in NULL inpur
* update the doc file
* empty commit to retrigger ci
* Apply suggestions from code review
Co-authored-by: Copilot <[email protected]>
* Rename FucntionTest.java to FunctionTest.java
* Remove unused files
---------
Co-authored-by: Jia Yu <[email protected]>
Co-authored-by: Copilot <[email protected]>
Co-authored-by: Kristin Cowalcijk <[email protected]>
---
.../apache/sedona/common/geography/Functions.java | 83 +++++++++++
.../sedona/common/Geography/FunctionTest.java | 154 +++++++++++++++++++++
docs/api/sql/geography/Function.md | 22 +++
.../sql/sedona_sql/expressions/Functions.scala | 4 +-
.../sql/sedona_sql/expressions/st_functions.scala | 5 +
.../sql/geography/FunctionsDataFrameAPITest.scala | 62 +++++++++
.../sedona/sql/geography/FunctionsTest.scala | 57 ++++++++
7 files changed, 386 insertions(+), 1 deletion(-)
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
new file mode 100644
index 0000000000..996c2ec7eb
--- /dev/null
+++ b/common/src/main/java/org/apache/sedona/common/geography/Functions.java
@@ -0,0 +1,83 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sedona.common.geography;
+
+import com.google.common.geometry.*;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.sedona.common.S2Geography.*;
+
+public class Functions {
+
+ private static final double EPSILON = 1e-9;
+
+ private static boolean nearlyEqual(double a, double b) {
+ if (Double.isNaN(a) || Double.isNaN(b)) {
+ return false;
+ }
+ return Math.abs(a - b) < EPSILON;
+ }
+
+ public static Geography getEnvelope(Geography geography, boolean
splitAtAntiMeridian) {
+ if (geography == null) return null;
+ S2LatLngRect rect = geography.region().getRectBound();
+ double lngLo = rect.lngLo().degrees();
+ double latLo = rect.latLo().degrees();
+ double lngHi = rect.lngHi().degrees();
+ double latHi = rect.latHi().degrees();
+
+ if (nearlyEqual(latLo, latHi) && nearlyEqual(lngLo, lngHi)) {
+ S2Point point = S2LatLng.fromDegrees(latLo, lngLo).toPoint();
+ Geography pointGeo = new SinglePointGeography(point);
+ pointGeo.setSRID(geography.getSRID());
+ return pointGeo;
+ }
+
+ Geography envelope;
+ if (splitAtAntiMeridian && rect.lng().isInverted()) {
+ // Crossing → split into two polygons
+ S2Polygon left = rectToPolygon(lngLo, latLo, 180.0, latHi);
+ S2Polygon right = rectToPolygon(-180.0, latLo, lngHi, latHi);
+ envelope =
+ new MultiPolygonGeography(Geography.GeographyKind.MULTIPOLYGON,
List.of(left, right));
+ } else {
+ envelope = new PolygonGeography(rectToPolygon(lngLo, latLo, lngHi,
latHi));
+ }
+ envelope.setSRID(geography.getSRID());
+ return envelope;
+ }
+
+ /**
+ * Build an S2Polygon rectangle (lng/lat in degrees), CCW ring: (lo,lo) →
(hi,lo) → (hi,hi) →
+ * (lo,hi).
+ */
+ private static S2Polygon rectToPolygon(double lngLo, double latLo, double
lngHi, double latHi) {
+ ArrayList<S2Point> v = new ArrayList<>(4);
+ v.add(S2LatLng.fromDegrees(latLo, lngLo).toPoint());
+ v.add(S2LatLng.fromDegrees(latLo, lngHi).toPoint());
+ v.add(S2LatLng.fromDegrees(latHi, lngHi).toPoint());
+ v.add(S2LatLng.fromDegrees(latHi, lngLo).toPoint());
+
+ S2Loop loop = new S2Loop(v);
+ // Optional: normalize for canonical orientation (keeps the smaller-area
side)
+ loop.normalize();
+
+ return new S2Polygon(loop);
+ }
+}
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
new file mode 100644
index 0000000000..c7f1f6dcb3
--- /dev/null
+++ b/common/src/test/java/org/apache/sedona/common/Geography/FunctionTest.java
@@ -0,0 +1,154 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sedona.common.Geography;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import com.google.common.geometry.S2LatLng;
+import com.google.common.geometry.S2LatLngRect;
+import com.google.common.geometry.S2Loop;
+import com.google.common.geometry.S2Point;
+import org.apache.sedona.common.S2Geography.Geography;
+import org.apache.sedona.common.S2Geography.PolygonGeography;
+import org.apache.sedona.common.geography.Constructors;
+import org.apache.sedona.common.geography.Functions;
+import org.junit.Test;
+import org.locationtech.jts.io.ParseException;
+
+public class FunctionTest {
+ private static final double EPS = 1e-9;
+
+ private static void assertDegAlmostEqual(double a, double b) {
+ assertTrue("exp=" + b + ", got=" + a, Math.abs(a - b) <= EPS);
+ }
+
+ private static void assertLatLng(S2Point p, double expLatDeg, double
expLngDeg) {
+ S2LatLng ll = new S2LatLng(p).normalized();
+ assertDegAlmostEqual(ll.latDegrees(), expLatDeg);
+ assertDegAlmostEqual(ll.lngDegrees(), expLngDeg);
+ }
+
+ /** Assert a *single* rectangular envelope polygon has these 4 corners in
SW→SE→NE→NW order. */
+ private static void assertRectLoopVertices(
+ S2Loop loop, double latLo, double lngLo, double latHi, double lngHi) {
+ assertEquals("rect must have 4 vertices", 4, loop.numVertices());
+ // SW
+ assertLatLng(loop.vertex(0), latLo, lngLo);
+ // SE
+ assertLatLng(loop.vertex(1), latLo, lngHi);
+ // NE
+ assertLatLng(loop.vertex(2), latHi, lngHi);
+ // NW
+ assertLatLng(loop.vertex(3), latHi, lngLo);
+ }
+
+ @Test
+ public void envelope_noSplit_antimeridian() throws Exception {
+ String wkt = "MULTIPOINT ((-179 0), (179 1), (-180 10))";
+ Geography g = Constructors.geogFromWKT(wkt, 4326);
+ PolygonGeography env = (PolygonGeography) Functions.getEnvelope(g,
/*split*/ false);
+
+ S2LatLngRect r = g.region().getRectBound();
+ assertTrue(r.lng().isInverted());
+ assertDegAlmostEqual(r.latLo().degrees(), 0.0);
+ assertDegAlmostEqual(r.latHi().degrees(), 10.0);
+ assertDegAlmostEqual(r.lngLo().degrees(), 179.0);
+ assertDegAlmostEqual(r.lngHi().degrees(), -179.0);
+
+ S2Loop loop = env.polygon.getLoops().get(0);
+ assertRectLoopVertices(loop, /*latLo*/ 0, /*lngLo*/ 179, /*latHi*/ 10,
/*lngHi*/ -179);
+ }
+
+ @Test
+ public void envelope_netherlands_perVertex() throws Exception {
+ String nl =
+ "POLYGON ((3.314971 50.80372, 7.092053 50.80372, 7.092053 53.5104,
3.314971 53.5104, 3.314971 50.80372))";
+ Geography g = Constructors.geogFromWKT(nl, 4326);
+ Geography env = Functions.getEnvelope(g, true);
+ String expectedWKT = "SRID=4326; POLYGON ((3.3 50.8, 7.1 50.8, 7.1 53.5,
3.3 53.5, 3.3 50.8))";
+ assertEquals(expectedWKT, env.toString());
+ }
+
+ @Test
+ public void envelope_fiji_split_perVertex() throws Exception {
+ // <-------------------- WESTERN HEMISPHERE | EASTERN HEMISPHERE
-------------------->
+ //
+ // Longitude: ... -179.8° -180°| 180° 177.3° ...
+ //
----------------------------------+--------------------------------------------
+ // |
+ // Latitude |
+ // -16° +------------------------+ +------------------------+
+ // | | | |
+ // | POLYGON 2 | | POLYGON 1 |
+ // | | | |
+ // -18.3° +------------------------+ +------------------------+
+ // |
+ // |
+ // ^
+ // |
+ // Antimeridian
+ // (The map's seam at 180°)
+ String fiji =
+ "MULTIPOLYGON ("
+ + "((177.285 -18.28799, 180 -18.28799, 180 -16.02088, 177.285
-16.02088, 177.285 -18.28799)),"
+ + "((-180 -18.28799, -179.7933 -18.28799, -179.7933 -16.02088,
-180 -16.02088, -180 -18.28799))"
+ + ")";
+ Geography g = Constructors.geogFromWKT(fiji, 4326);
+ Geography env = Functions.getEnvelope(g, /*split*/ true);
+ String expectedWKT =
+ "SRID=4326; MULTIPOLYGON (((177.3 -18.3, 180 -18.3, 180 -16, 177.3
-16, 177.3 -18.3)), "
+ + "((-180 -18.3, -179.8 -18.3, -179.8 -16, -180 -16, -180
-18.3)))";
+ assertEquals(expectedWKT, env.toString());
+
+ expectedWKT =
+ "SRID=4326; POLYGON ((177.3 -18.3, -179.8 -18.3, -179.8 -16, 177.3
-16, 177.3 -18.3))";
+ env = Functions.getEnvelope(g, /*split*/ false);
+ assertEquals(expectedWKT, env.toString());
+ }
+
+ @Test
+ public void getEnvelopePoint() throws ParseException {
+ String wkt = "POINT (-180 10)";
+ Geography geography = Constructors.geogFromWKT(wkt, 0);
+ Geography envelope = Functions.getEnvelope(geography, false);
+ assertEquals("POINT (180 10)", envelope.toString());
+ }
+
+ @Test
+ public void testEnvelopeWKTCompare() throws Exception {
+ String antarctica = "POLYGON ((-180 -90, -180 -63.27066, 180 -63.27066,
180 -90, -180 -90))";
+ Geography g = Constructors.geogFromWKT(antarctica, 4326);
+ Geography env = Functions.getEnvelope(g, true);
+
+ String expectedWKT =
+ "SRID=4326; POLYGON ((-180 -63.3, 180 -63.3, 180 -90, -180 -90, -180
-63.3))";
+ assertEquals((expectedWKT), (env.toString()));
+
+ String multiCountry =
+ "MULTIPOLYGON (((-180 -90, -180 -63.27066, 180 -63.27066, 180 -90,
-180 -90)),"
+ + "((3.314971 50.80372, 7.092053 50.80372, 7.092053 53.5104,
3.314971 53.5104, 3.314971 50.80372)))";
+ g = Constructors.geogFromWKT(multiCountry, 4326);
+ env = Functions.getEnvelope(g, true);
+
+ String expectedWKT2 =
+ "SRID=4326; POLYGON ((-180 53.5, 180 53.5, 180 -90, -180 -90, -180
53.5))";
+ assertEquals((expectedWKT2), (env.toString()));
+ }
+}
diff --git a/docs/api/sql/geography/Function.md
b/docs/api/sql/geography/Function.md
index ef14affc68..4d8563ed18 100644
--- a/docs/api/sql/geography/Function.md
+++ b/docs/api/sql/geography/Function.md
@@ -16,3 +16,25 @@
specific language governing permissions and limitations
under the License.
-->
+
+## ST_Envelope
+
+Introduction: This function returns the bounding box (envelope) of A. It's
important to note that the bounding box is calculated using a cylindrical
topology, not a spherical one. If the envelope crosses the antimeridian (the
180° longitude line), you can set the split parameter to true. This will return
a Geography object containing two separate Polygon objects, split along that
line.
+
+Format:
+
+`ST_Envelope (A: Geography, splitAtAntiMeridian: Boolean)`
+
+Since: `v1.8.0`
+
+SQL Example
+
+```sql
+SELECT ST_Envelope(ST_GeogFromWKT('MULTIPOLYGON (((177.285 -18.28799, 180
-18.28799, 180 -16.02088, 177.285 -16.02088, 177.285 -18.28799)), ((-180
-18.28799, -179.7933 -18.28799, -179.7933 -16.02088, -180 -16.02088, -180
-18.28799)))'), false);
+```
+
+Output:
+
+```
+POLYGON ((177.3 -18.3, -179.8 -18.3, -179.8 -16, 177.3 -16, 177.3 -18.3))
+```
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 d41140d6cc..8c4ad05f4c 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
@@ -215,7 +215,9 @@ private[apache] case class
ST_ShiftLongitude(inputExpressions: Seq[Expression])
* @param inputExpressions
*/
private[apache] case class ST_Envelope(inputExpressions: Seq[Expression])
- extends InferredExpression(Functions.envelope _) {
+ extends InferredExpression(
+ inferrableFunction1(Functions.envelope),
+
inferrableFunction2(org.apache.sedona.common.geography.Functions.getEnvelope)) {
protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) =
{
copy(inputExpressions = newChildren)
diff --git
a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/st_functions.scala
b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/st_functions.scala
index 5694d897f8..554794fb3b 100644
---
a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/st_functions.scala
+++
b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/st_functions.scala
@@ -196,6 +196,11 @@ object st_functions {
def ST_Envelope(geometry: Column): Column =
wrapExpression[ST_Envelope](geometry)
def ST_Envelope(geometry: String): Column =
wrapExpression[ST_Envelope](geometry)
+ def ST_Envelope(geography: Column, split: Boolean): Column =
+ wrapExpression[ST_Envelope](geography, split)
+ def ST_Envelope(geography: String, split: Boolean): Column =
+ wrapExpression[ST_Envelope](geography, split)
+
def ST_Expand(geometry: Column, uniformDelta: Column) =
wrapExpression[ST_Expand](geometry, uniformDelta)
def ST_Expand(geometry: String, uniformDelta: String) =
diff --git
a/spark/common/src/test/scala/org/apache/sedona/sql/geography/FunctionsDataFrameAPITest.scala
b/spark/common/src/test/scala/org/apache/sedona/sql/geography/FunctionsDataFrameAPITest.scala
new file mode 100644
index 0000000000..be21c792c0
--- /dev/null
+++
b/spark/common/src/test/scala/org/apache/sedona/sql/geography/FunctionsDataFrameAPITest.scala
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sedona.sql.geography
+
+import org.apache.sedona.common.S2Geography.Geography
+import org.apache.sedona.sql.TestBaseScala
+import org.apache.spark.sql.functions.{col, lit}
+import org.apache.spark.sql.sedona_sql.expressions.{ST_Envelope,
st_constructors, st_functions}
+import org.junit.Assert.assertEquals
+
+class FunctionsDataFrameAPITest extends TestBaseScala {
+ import sparkSession.implicits._
+
+ it("Passed ST_Envelope antarctica") {
+ val antarctica =
+ "POLYGON ((-180 -90, -180 -63.27066, 180 -63.27066, 180 -90, -180 -90))"
+ val df = sparkSession
+ .sql(s"SELECT '$antarctica' AS wkt")
+ .select(st_constructors.ST_GeogFromWKT(col("wkt"), lit(4326)).as("geog"))
+ .select(st_functions.ST_Envelope(col("geog"), split = true))
+ .as("env")
+
+ val env = df.first().get(0).asInstanceOf[Geography]
+ val expectedWKT =
+ "SRID=4326; POLYGON ((-180 -63.3, 180 -63.3, 180 -90, -180 -90, -180
-63.3))";
+ assertEquals(expectedWKT, env.toString)
+ }
+
+ it("Passed ST_Envelope Fiji") {
+ val fiji =
+ "MULTIPOLYGON (" + "((177.285 -18.28799, 180 -18.28799, 180 -16.02088,
177.285 -16.02088, 177.285 -18.28799))," +
+ "((-180 -18.28799, -179.7933 -18.28799, -179.7933 -16.02088, -180
-16.02088, -180 -18.28799))" + ")"
+
+ val df = sparkSession
+ .sql(s"SELECT '$fiji' AS wkt")
+ .select(st_constructors.ST_GeogFromWKT(col("wkt"), lit(4326)).as("geog"))
+ .select(st_functions.ST_Envelope(col("geog"), split = false))
+ .as("env")
+
+ val env = df.first().get(0).asInstanceOf[Geography]
+ val expectedWKT =
+ "SRID=4326; POLYGON ((177.3 -18.3, -179.8 -18.3, -179.8 -16, 177.3 -16,
177.3 -18.3))";
+ assertEquals(expectedWKT, env.toString)
+ }
+
+}
diff --git
a/spark/common/src/test/scala/org/apache/sedona/sql/geography/FunctionsTest.scala
b/spark/common/src/test/scala/org/apache/sedona/sql/geography/FunctionsTest.scala
new file mode 100644
index 0000000000..7f3af6eb2e
--- /dev/null
+++
b/spark/common/src/test/scala/org/apache/sedona/sql/geography/FunctionsTest.scala
@@ -0,0 +1,57 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sedona.sql.geography
+
+import org.apache.sedona.common.S2Geography.Geography
+import org.apache.sedona.common.geography.{Constructors, Functions}
+import org.apache.sedona.sql.TestBaseScala
+import org.junit.Assert.assertEquals
+import org.locationtech.jts.geom.{Geometry, PrecisionModel}
+
+class FunctionsTest extends TestBaseScala {
+
+ import sparkSession.implicits._
+
+ it("Passed ST_Envelope antarctica") {
+ val antarctica =
+ "POLYGON ((-180 -90, -180 -63.27066, 180 -63.27066, 180 -90, -180 -90))"
+ var row =
+ sparkSession.sql(s"SELECT ST_Envelope(ST_GeogFromWKT('$antarctica'),
true) AS env").first()
+ var env = row.get(0).asInstanceOf[Geography]
+ var expectedWKT = "POLYGON ((-180 -63.3, 180 -63.3, 180 -90, -180 -90,
-180 -63.3))";
+ assertEquals(expectedWKT, env.toString)
+ }
+
+ it("Passed ST_Envelope Fiji") {
+ val fiji =
+ "MULTIPOLYGON (" + "((177.285 -18.28799, 180 -18.28799, 180 -16.02088,
177.285 -16.02088, 177.285 -18.28799))," +
+ "((-180 -18.28799, -179.7933 -18.28799, -179.7933 -16.02088, -180
-16.02088, -180 -18.28799))" + ")"
+
+ val row =
+ sparkSession.sql(s"SELECT ST_Envelope(ST_GeogFromEWKT('$fiji'), false)
AS env").first()
+ val env = row.get(0).asInstanceOf[Geography]
+ val expectedWKT = "POLYGON ((177.3 -18.3, -179.8 -18.3, -179.8 -16, 177.3
-16, 177.3 -18.3))"
+ assertEquals(expectedWKT, env.toString)
+ }
+
+ it("Passed ST_Envelope null") {
+ val functionDf = sparkSession.sql("select ST_Envelope(null, false)")
+ assert(functionDf.first().get(0) == null)
+ }
+}