| # 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 |