blob: f71e938c82b6e94a392512a2479e2d26bfa414b4 [file]
# 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.
import re
from pyspark.sql.functions import col, expr, lit
from sedona.spark.sql import st_constructors as stc
from sedona.spark.sql import st_functions as stf
from sedona.spark.sql import st_predicates as stp
from tests.test_base import TestBase
def _parse_point_xy(wkt):
"""Extract (x, y) from a 'POINT (x y)' string."""
m = re.match(
r"\s*POINT\s*\(\s*(-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)\s+(-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)\s*\)\s*$",
wkt,
)
assert m is not None, f"unparsable POINT WKT: {wkt!r}"
return float(m.group(1)), float(m.group(2))
class TestGeographyConstructorsDataFrameAPI(TestBase):
"""Exercise every ST_Geog* constructor through its typed Python wrapper."""
def test_st_geog_from_wkt(self):
df = self.spark.sql("SELECT 'POINT (1 2)' AS wkt").select(
stc.ST_GeogFromWKT(col("wkt"), lit(4326)).alias("g")
)
ewkt = df.select(stf.ST_AsEWKT(col("g"))).first()[0]
assert ewkt == "SRID=4326; POINT (1 2)"
def test_st_geog_from_wkt_no_srid(self):
df = self.spark.sql("SELECT 'POINT (1 2)' AS wkt").select(
stc.ST_GeogFromWKT(col("wkt")).alias("g")
)
wkt = df.select(stf.ST_AsText(col("g"))).first()[0]
# S2 round-trip introduces sub-nanometer floating-point drift; allow a loose
# tolerance instead of comparing the WKT string verbatim.
x, y = _parse_point_xy(wkt)
assert abs(x - 1.0) < 1e-9
assert abs(y - 2.0) < 1e-9
def test_st_geog_from_text(self):
df = self.spark.sql("SELECT 'POINT (3 4)' AS wkt").select(
stc.ST_GeogFromText(col("wkt"), lit(4326)).alias("g")
)
ewkt = df.select(stf.ST_AsEWKT(col("g"))).first()[0]
assert ewkt == "SRID=4326; POINT (3 4)"
def test_st_geog_from_wkb(self):
# WKB for POINT (10 15) in little-endian
wkb_bytes = bytes(
[1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 36, 64, 0, 0, 0, 0, 0, 0, 46, 64]
)
df = self.spark.createDataFrame([(wkb_bytes,)], ["wkb"]).select(
stc.ST_GeogFromWKB(col("wkb")).alias("g")
)
wkt = df.select(stf.ST_AsText(col("g"))).first()[0]
assert wkt == "POINT (10 15)"
def test_st_geog_from_ewkb(self):
# EWKB for SRID=4326; LINESTRING (-2.1 -0.4, -1.5 -0.7)
ewkb_bytes = bytes(
[
1,
2,
0,
0,
32,
230,
16,
0,
0,
2,
0,
0,
0,
0,
0,
0,
0,
132,
214,
0,
192,
0,
0,
0,
0,
128,
181,
214,
191,
0,
0,
0,
96,
225,
239,
247,
191,
0,
0,
0,
128,
7,
93,
229,
191,
]
)
df = self.spark.createDataFrame([(ewkb_bytes,)], ["wkb"]).select(
stc.ST_GeogFromEWKB(col("wkb")).alias("g")
)
ewkt = df.select(stf.ST_AsEWKT(col("g"))).first()[0]
assert ewkt.startswith("SRID=4326; LINESTRING")
def test_st_geog_from_ewkt(self):
df = self.spark.sql("SELECT 'SRID=4269;POINT (5 6)' AS ewkt").select(
stc.ST_GeogFromEWKT(col("ewkt")).alias("g")
)
ewkt = df.select(stf.ST_AsEWKT(col("g"))).first()[0]
assert ewkt == "SRID=4269; POINT (5 6)"
def test_st_geog_from_geohash(self):
df = self.spark.sql("SELECT '9q9j8ue2v71y5zzy0s4q' AS geohash").select(
stc.ST_GeogFromGeoHash(col("geohash"), 4).alias("g")
)
wkt = df.select(stf.ST_AsText(col("g"))).first()[0]
assert wkt.startswith("POLYGON")
def test_st_geog_from_geohash_no_precision(self):
df = self.spark.sql("SELECT '9q9' AS geohash").select(
stc.ST_GeogFromGeoHash(col("geohash")).alias("g")
)
wkt = df.select(stf.ST_AsText(col("g"))).first()[0]
assert wkt.startswith("POLYGON")
def test_st_geogcoll_from_text(self):
wkt_in = "GEOMETRYCOLLECTION (POINT (1 2), LINESTRING (0 0, 1 1))"
df = self.spark.sql(f"SELECT '{wkt_in}' AS wkt").select(
stc.ST_GeogCollFromText(col("wkt"), lit(4326)).alias("g")
)
ewkt = df.select(stf.ST_AsEWKT(col("g"))).first()[0]
assert ewkt.startswith("SRID=4326; GEOMETRYCOLLECTION")
def test_st_geog_to_geometry(self):
df = (
self.spark.sql("SELECT 'POINT (7 8)' AS wkt")
.select(stc.ST_GeogFromWKT(col("wkt"), lit(4326)).alias("g"))
.select(stc.ST_GeogToGeometry(col("g")).alias("geom"))
)
wkt = df.select(stf.ST_AsText(col("geom"))).first()[0]
# S2 round-trip introduces sub-nanometer floating-point drift on the geography
# → geometry conversion path; compare numerically with a loose tolerance.
x, y = _parse_point_xy(wkt)
assert abs(x - 7.0) < 1e-9
assert abs(y - 8.0) < 1e-9
def test_st_geom_to_geography(self):
df = (
self.spark.sql("SELECT 'POINT (9 10)' AS wkt")
.select(stc.ST_GeomFromWKT(col("wkt"), lit(4326)).alias("geom"))
.select(stc.ST_GeomToGeography(col("geom")).alias("g"))
)
ewkt = df.select(stf.ST_AsEWKT(col("g"))).first()[0]
assert ewkt == "SRID=4326; POINT (9 10)"
class TestGeographyFunctionsDataFrameAPI(TestBase):
"""Exercise dual-dispatch ST functions/predicates against Geography columns
via the typed Python DataFrame API."""
def _geog(self, wkt, srid=4326):
return stc.ST_GeogFromWKT(lit(wkt), lit(srid))
def test_st_distance(self):
df = self.spark.range(1).select(
stf.ST_Distance(self._geog("POINT (0 0)"), self._geog("POINT (1 1)")).alias(
"d"
)
)
d = df.first()[0]
assert 155000 < d < 160000 # ~157km on a sphere
def test_st_length(self):
df = self.spark.range(1).select(
stf.ST_Length(self._geog("LINESTRING (0 0, 1 0)")).alias("l")
)
l = df.first()[0]
assert 110000 < l < 112000
def test_st_length_of_point(self):
df = self.spark.range(1).select(
stf.ST_Length(self._geog("POINT (1 2)")).alias("l")
)
assert df.first()[0] == 0.0
def test_st_area(self):
df = self.spark.range(1).select(
stf.ST_Area(self._geog("POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))")).alias("a")
)
a = df.first()[0]
# 1°×1° box near equator on R=6371008m sphere ≈ 1.2364e10 m²
assert 1.23e10 < a < 1.24e10
def test_st_centroid(self):
df = self.spark.range(1).select(
stf.ST_Centroid(self._geog("POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))")).alias(
"c"
)
)
wkt = df.select(stf.ST_AsText(col("c"))).first()[0]
assert wkt.startswith("POINT")
def test_st_buffer(self):
# The Python `stf.ST_Buffer` wrapper defaults `useSpheroid=False` which dispatches
# to the 3-arg `(geom, buf, useSpheroid)` overload; Geography rejects any boolean
# `useSpheroid` argument because Geography is always spheroidal. Pass
# `useSpheroid=None` so the wrapper falls through to the 2-arg form, which is
# what Geography supports.
df = self.spark.range(1).select(
stf.ST_Buffer(
self._geog("POINT (0 0)"), lit(1000.0), useSpheroid=None
).alias("b")
)
wkt = df.select(stf.ST_AsText(col("b"))).first()[0]
assert wkt.startswith("POLYGON")
def test_st_envelope(self):
# Geography ST_Envelope is the 2-arg `splitAtAntiMeridian` form; the 1-arg form
# is geometry-only. Use a SQL expression to invoke the 2-arg overload.
df = (
self.spark.range(1)
.select(self._geog("POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))").alias("g"))
.select(expr("ST_Envelope(g, true)").alias("e"))
)
wkt = df.select(stf.ST_AsText(col("e"))).first()[0]
assert wkt.startswith("POLYGON")
def test_st_npoints(self):
df = self.spark.range(1).select(
stf.ST_NPoints(self._geog("LINESTRING (0 0, 1 1, 2 2)")).alias("n")
)
assert df.first()[0] == 3
def test_st_contains(self):
df = self.spark.range(1).select(
stp.ST_Contains(
self._geog("POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"),
self._geog("POINT (0.5 0.5)"),
).alias("r")
)
assert df.first()[0] is True
def test_st_within(self):
df = self.spark.range(1).select(
stp.ST_Within(
self._geog("POINT (0.5 0.5)"),
self._geog("POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"),
).alias("r")
)
assert df.first()[0] is True
def test_st_dwithin(self):
df = self.spark.range(1).select(
stp.ST_DWithin(
self._geog("POINT (0 0)"),
self._geog("POINT (0 1)"),
lit(200000.0),
).alias("r")
)
assert df.first()[0] is True
def test_st_equals(self):
df = self.spark.range(1).select(
stp.ST_Equals(
self._geog("POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"),
self._geog("POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"),
).alias("r")
)
assert df.first()[0] is True
def test_st_intersects(self):
df = self.spark.range(1).select(
stp.ST_Intersects(
self._geog("POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))"),
self._geog("POLYGON ((1 1, 3 1, 3 3, 1 3, 1 1))"),
).alias("r")
)
assert df.first()[0] is True