Skip to content

Commit ff2df67

Browse files
ZhuochengShangjiayuasuCopilotKontinuation
authored
[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]>
1 parent 1c0890f commit ff2df67

File tree

7 files changed

+386
-1
lines changed

7 files changed

+386
-1
lines changed
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
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+
package org.apache.sedona.common.geography;
20+
21+
import com.google.common.geometry.*;
22+
import java.util.ArrayList;
23+
import java.util.List;
24+
import org.apache.sedona.common.S2Geography.*;
25+
26+
public class Functions {
27+
28+
private static final double EPSILON = 1e-9;
29+
30+
private static boolean nearlyEqual(double a, double b) {
31+
if (Double.isNaN(a) || Double.isNaN(b)) {
32+
return false;
33+
}
34+
return Math.abs(a - b) < EPSILON;
35+
}
36+
37+
public static Geography getEnvelope(Geography geography, boolean splitAtAntiMeridian) {
38+
if (geography == null) return null;
39+
S2LatLngRect rect = geography.region().getRectBound();
40+
double lngLo = rect.lngLo().degrees();
41+
double latLo = rect.latLo().degrees();
42+
double lngHi = rect.lngHi().degrees();
43+
double latHi = rect.latHi().degrees();
44+
45+
if (nearlyEqual(latLo, latHi) && nearlyEqual(lngLo, lngHi)) {
46+
S2Point point = S2LatLng.fromDegrees(latLo, lngLo).toPoint();
47+
Geography pointGeo = new SinglePointGeography(point);
48+
pointGeo.setSRID(geography.getSRID());
49+
return pointGeo;
50+
}
51+
52+
Geography envelope;
53+
if (splitAtAntiMeridian && rect.lng().isInverted()) {
54+
// Crossing → split into two polygons
55+
S2Polygon left = rectToPolygon(lngLo, latLo, 180.0, latHi);
56+
S2Polygon right = rectToPolygon(-180.0, latLo, lngHi, latHi);
57+
envelope =
58+
new MultiPolygonGeography(Geography.GeographyKind.MULTIPOLYGON, List.of(left, right));
59+
} else {
60+
envelope = new PolygonGeography(rectToPolygon(lngLo, latLo, lngHi, latHi));
61+
}
62+
envelope.setSRID(geography.getSRID());
63+
return envelope;
64+
}
65+
66+
/**
67+
* Build an S2Polygon rectangle (lng/lat in degrees), CCW ring: (lo,lo) → (hi,lo) → (hi,hi) →
68+
* (lo,hi).
69+
*/
70+
private static S2Polygon rectToPolygon(double lngLo, double latLo, double lngHi, double latHi) {
71+
ArrayList<S2Point> v = new ArrayList<>(4);
72+
v.add(S2LatLng.fromDegrees(latLo, lngLo).toPoint());
73+
v.add(S2LatLng.fromDegrees(latLo, lngHi).toPoint());
74+
v.add(S2LatLng.fromDegrees(latHi, lngHi).toPoint());
75+
v.add(S2LatLng.fromDegrees(latHi, lngLo).toPoint());
76+
77+
S2Loop loop = new S2Loop(v);
78+
// Optional: normalize for canonical orientation (keeps the smaller-area side)
79+
loop.normalize();
80+
81+
return new S2Polygon(loop);
82+
}
83+
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
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+
package org.apache.sedona.common.Geography;
20+
21+
import static org.junit.Assert.assertEquals;
22+
import static org.junit.Assert.assertTrue;
23+
24+
import com.google.common.geometry.S2LatLng;
25+
import com.google.common.geometry.S2LatLngRect;
26+
import com.google.common.geometry.S2Loop;
27+
import com.google.common.geometry.S2Point;
28+
import org.apache.sedona.common.S2Geography.Geography;
29+
import org.apache.sedona.common.S2Geography.PolygonGeography;
30+
import org.apache.sedona.common.geography.Constructors;
31+
import org.apache.sedona.common.geography.Functions;
32+
import org.junit.Test;
33+
import org.locationtech.jts.io.ParseException;
34+
35+
public class FunctionTest {
36+
private static final double EPS = 1e-9;
37+
38+
private static void assertDegAlmostEqual(double a, double b) {
39+
assertTrue("exp=" + b + ", got=" + a, Math.abs(a - b) <= EPS);
40+
}
41+
42+
private static void assertLatLng(S2Point p, double expLatDeg, double expLngDeg) {
43+
S2LatLng ll = new S2LatLng(p).normalized();
44+
assertDegAlmostEqual(ll.latDegrees(), expLatDeg);
45+
assertDegAlmostEqual(ll.lngDegrees(), expLngDeg);
46+
}
47+
48+
/** Assert a *single* rectangular envelope polygon has these 4 corners in SW→SE→NE→NW order. */
49+
private static void assertRectLoopVertices(
50+
S2Loop loop, double latLo, double lngLo, double latHi, double lngHi) {
51+
assertEquals("rect must have 4 vertices", 4, loop.numVertices());
52+
// SW
53+
assertLatLng(loop.vertex(0), latLo, lngLo);
54+
// SE
55+
assertLatLng(loop.vertex(1), latLo, lngHi);
56+
// NE
57+
assertLatLng(loop.vertex(2), latHi, lngHi);
58+
// NW
59+
assertLatLng(loop.vertex(3), latHi, lngLo);
60+
}
61+
62+
@Test
63+
public void envelope_noSplit_antimeridian() throws Exception {
64+
String wkt = "MULTIPOINT ((-179 0), (179 1), (-180 10))";
65+
Geography g = Constructors.geogFromWKT(wkt, 4326);
66+
PolygonGeography env = (PolygonGeography) Functions.getEnvelope(g, /*split*/ false);
67+
68+
S2LatLngRect r = g.region().getRectBound();
69+
assertTrue(r.lng().isInverted());
70+
assertDegAlmostEqual(r.latLo().degrees(), 0.0);
71+
assertDegAlmostEqual(r.latHi().degrees(), 10.0);
72+
assertDegAlmostEqual(r.lngLo().degrees(), 179.0);
73+
assertDegAlmostEqual(r.lngHi().degrees(), -179.0);
74+
75+
S2Loop loop = env.polygon.getLoops().get(0);
76+
assertRectLoopVertices(loop, /*latLo*/ 0, /*lngLo*/ 179, /*latHi*/ 10, /*lngHi*/ -179);
77+
}
78+
79+
@Test
80+
public void envelope_netherlands_perVertex() throws Exception {
81+
String nl =
82+
"POLYGON ((3.314971 50.80372, 7.092053 50.80372, 7.092053 53.5104, 3.314971 53.5104, 3.314971 50.80372))";
83+
Geography g = Constructors.geogFromWKT(nl, 4326);
84+
Geography env = Functions.getEnvelope(g, true);
85+
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))";
86+
assertEquals(expectedWKT, env.toString());
87+
}
88+
89+
@Test
90+
public void envelope_fiji_split_perVertex() throws Exception {
91+
// <-------------------- WESTERN HEMISPHERE | EASTERN HEMISPHERE -------------------->
92+
//
93+
// Longitude: ... -179.8° -180°| 180° 177.3° ...
94+
// ----------------------------------+--------------------------------------------
95+
// |
96+
// Latitude |
97+
// -16° +------------------------+ +------------------------+
98+
// | | | |
99+
// | POLYGON 2 | | POLYGON 1 |
100+
// | | | |
101+
// -18.3° +------------------------+ +------------------------+
102+
// |
103+
// |
104+
// ^
105+
// |
106+
// Antimeridian
107+
// (The map's seam at 180°)
108+
String fiji =
109+
"MULTIPOLYGON ("
110+
+ "((177.285 -18.28799, 180 -18.28799, 180 -16.02088, 177.285 -16.02088, 177.285 -18.28799)),"
111+
+ "((-180 -18.28799, -179.7933 -18.28799, -179.7933 -16.02088, -180 -16.02088, -180 -18.28799))"
112+
+ ")";
113+
Geography g = Constructors.geogFromWKT(fiji, 4326);
114+
Geography env = Functions.getEnvelope(g, /*split*/ true);
115+
String expectedWKT =
116+
"SRID=4326; MULTIPOLYGON (((177.3 -18.3, 180 -18.3, 180 -16, 177.3 -16, 177.3 -18.3)), "
117+
+ "((-180 -18.3, -179.8 -18.3, -179.8 -16, -180 -16, -180 -18.3)))";
118+
assertEquals(expectedWKT, env.toString());
119+
120+
expectedWKT =
121+
"SRID=4326; POLYGON ((177.3 -18.3, -179.8 -18.3, -179.8 -16, 177.3 -16, 177.3 -18.3))";
122+
env = Functions.getEnvelope(g, /*split*/ false);
123+
assertEquals(expectedWKT, env.toString());
124+
}
125+
126+
@Test
127+
public void getEnvelopePoint() throws ParseException {
128+
String wkt = "POINT (-180 10)";
129+
Geography geography = Constructors.geogFromWKT(wkt, 0);
130+
Geography envelope = Functions.getEnvelope(geography, false);
131+
assertEquals("POINT (180 10)", envelope.toString());
132+
}
133+
134+
@Test
135+
public void testEnvelopeWKTCompare() throws Exception {
136+
String antarctica = "POLYGON ((-180 -90, -180 -63.27066, 180 -63.27066, 180 -90, -180 -90))";
137+
Geography g = Constructors.geogFromWKT(antarctica, 4326);
138+
Geography env = Functions.getEnvelope(g, true);
139+
140+
String expectedWKT =
141+
"SRID=4326; POLYGON ((-180 -63.3, 180 -63.3, 180 -90, -180 -90, -180 -63.3))";
142+
assertEquals((expectedWKT), (env.toString()));
143+
144+
String multiCountry =
145+
"MULTIPOLYGON (((-180 -90, -180 -63.27066, 180 -63.27066, 180 -90, -180 -90)),"
146+
+ "((3.314971 50.80372, 7.092053 50.80372, 7.092053 53.5104, 3.314971 53.5104, 3.314971 50.80372)))";
147+
g = Constructors.geogFromWKT(multiCountry, 4326);
148+
env = Functions.getEnvelope(g, true);
149+
150+
String expectedWKT2 =
151+
"SRID=4326; POLYGON ((-180 53.5, 180 53.5, 180 -90, -180 -90, -180 53.5))";
152+
assertEquals((expectedWKT2), (env.toString()));
153+
}
154+
}

docs/api/sql/geography/Function.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,25 @@
1616
specific language governing permissions and limitations
1717
under the License.
1818
-->
19+
20+
## ST_Envelope
21+
22+
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.
23+
24+
Format:
25+
26+
`ST_Envelope (A: Geography, splitAtAntiMeridian: Boolean)`
27+
28+
Since: `v1.8.0`
29+
30+
SQL Example
31+
32+
```sql
33+
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);
34+
```
35+
36+
Output:
37+
38+
```
39+
POLYGON ((177.3 -18.3, -179.8 -18.3, -179.8 -16, 177.3 -16, 177.3 -18.3))
40+
```

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,9 @@ private[apache] case class ST_ShiftLongitude(inputExpressions: Seq[Expression])
215215
* @param inputExpressions
216216
*/
217217
private[apache] case class ST_Envelope(inputExpressions: Seq[Expression])
218-
extends InferredExpression(Functions.envelope _) {
218+
extends InferredExpression(
219+
inferrableFunction1(Functions.envelope),
220+
inferrableFunction2(org.apache.sedona.common.geography.Functions.getEnvelope)) {
219221

220222
protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = {
221223
copy(inputExpressions = newChildren)

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,11 @@ object st_functions {
196196
def ST_Envelope(geometry: Column): Column = wrapExpression[ST_Envelope](geometry)
197197
def ST_Envelope(geometry: String): Column = wrapExpression[ST_Envelope](geometry)
198198

199+
def ST_Envelope(geography: Column, split: Boolean): Column =
200+
wrapExpression[ST_Envelope](geography, split)
201+
def ST_Envelope(geography: String, split: Boolean): Column =
202+
wrapExpression[ST_Envelope](geography, split)
203+
199204
def ST_Expand(geometry: Column, uniformDelta: Column) =
200205
wrapExpression[ST_Expand](geometry, uniformDelta)
201206
def ST_Expand(geometry: String, uniformDelta: String) =
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
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+
package org.apache.sedona.sql.geography
20+
21+
import org.apache.sedona.common.S2Geography.Geography
22+
import org.apache.sedona.sql.TestBaseScala
23+
import org.apache.spark.sql.functions.{col, lit}
24+
import org.apache.spark.sql.sedona_sql.expressions.{ST_Envelope, st_constructors, st_functions}
25+
import org.junit.Assert.assertEquals
26+
27+
class FunctionsDataFrameAPITest extends TestBaseScala {
28+
import sparkSession.implicits._
29+
30+
it("Passed ST_Envelope antarctica") {
31+
val antarctica =
32+
"POLYGON ((-180 -90, -180 -63.27066, 180 -63.27066, 180 -90, -180 -90))"
33+
val df = sparkSession
34+
.sql(s"SELECT '$antarctica' AS wkt")
35+
.select(st_constructors.ST_GeogFromWKT(col("wkt"), lit(4326)).as("geog"))
36+
.select(st_functions.ST_Envelope(col("geog"), split = true))
37+
.as("env")
38+
39+
val env = df.first().get(0).asInstanceOf[Geography]
40+
val expectedWKT =
41+
"SRID=4326; POLYGON ((-180 -63.3, 180 -63.3, 180 -90, -180 -90, -180 -63.3))";
42+
assertEquals(expectedWKT, env.toString)
43+
}
44+
45+
it("Passed ST_Envelope Fiji") {
46+
val fiji =
47+
"MULTIPOLYGON (" + "((177.285 -18.28799, 180 -18.28799, 180 -16.02088, 177.285 -16.02088, 177.285 -18.28799))," +
48+
"((-180 -18.28799, -179.7933 -18.28799, -179.7933 -16.02088, -180 -16.02088, -180 -18.28799))" + ")"
49+
50+
val df = sparkSession
51+
.sql(s"SELECT '$fiji' AS wkt")
52+
.select(st_constructors.ST_GeogFromWKT(col("wkt"), lit(4326)).as("geog"))
53+
.select(st_functions.ST_Envelope(col("geog"), split = false))
54+
.as("env")
55+
56+
val env = df.first().get(0).asInstanceOf[Geography]
57+
val expectedWKT =
58+
"SRID=4326; POLYGON ((177.3 -18.3, -179.8 -18.3, -179.8 -16, 177.3 -16, 177.3 -18.3))";
59+
assertEquals(expectedWKT, env.toString)
60+
}
61+
62+
}

0 commit comments

Comments
 (0)