diff --git a/bigframes/core/compile/ibis_compiler/operations/geo_ops.py b/bigframes/core/compile/ibis_compiler/operations/geo_ops.py new file mode 100644 index 0000000000..e5daa541b7 --- /dev/null +++ b/bigframes/core/compile/ibis_compiler/operations/geo_ops.py @@ -0,0 +1,103 @@ +# Copyright 2025 Google LLC +# +# Licensed 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. + +""" +BigFrames -> Ibis compilation for the operations in bigframes.operations.geo_ops. + +Please keep implementations in sequential order by op name. +""" + +from __future__ import annotations + +from bigframes_vendored.ibis.expr import datatypes as ibis_dtypes +from bigframes_vendored.ibis.expr import types as ibis_types +from bigframes_vendored.ibis.udf import scalar as ibis_udf # type: ignore + +from bigframes.core.compile.ibis_compiler.scalar_op_compiler import scalar_op_compiler +from bigframes.operations import geo_ops + +register_unary_op = scalar_op_compiler.register_unary_op +register_binary_op = scalar_op_compiler.register_binary_op + + +@ibis_udf.scalar.builtin("ST_IsEmpty") +def st_isempty(x: ibis_dtypes.GeoSpatial) -> ibis_types.BooleanValue: + raise NotImplementedError() + + +@register_unary_op(geo_ops.geo_st_isempty_op) +def geo_st_isempty_op_impl(x: ibis_types.Value): + return st_isempty(x) + + +@ibis_udf.scalar.builtin("ST_GeometryType") +def st_geometrytype(x: ibis_dtypes.GeoSpatial) -> ibis_types.StringValue: + raise NotImplementedError() + + +@register_unary_op(geo_ops.geo_st_geometrytype_op) +def geo_st_geometrytype_op_impl(x: ibis_types.Value): + return st_geometrytype(x) + + +@ibis_udf.scalar.builtin("ST_IsRing") +def st_isring(x: ibis_dtypes.GeoSpatial) -> ibis_types.BooleanValue: + raise NotImplementedError() + + +@register_unary_op(geo_ops.geo_st_isring_op) +def geo_st_isring_op_impl(x: ibis_types.Value): + return st_isring(x) + + +@ibis_udf.scalar.builtin("ST_EQUALS") +def st_equals( + x: ibis_dtypes.GeoSpatial, y: ibis_dtypes.GeoSpatial +) -> ibis_types.BooleanValue: + raise NotImplementedError() + + +@ibis_udf.scalar.builtin("ST_SIMPLIFY") +def st_simplify( + x: ibis_dtypes.GeoSpatial, tolerance: ibis_types.NumericValue +) -> ibis_dtypes.GeoSpatial: + raise NotImplementedError() + + +@register_unary_op(geo_ops.geo_st_issimple_op) +def geo_st_issimple_op_impl(x: ibis_types.Value): + simplified = st_simplify(x, 0.0) + return st_equals(x, simplified) + + +@ibis_udf.scalar.builtin("ST_ISVALID") +def st_isvalid(x: ibis_dtypes.GeoSpatial) -> ibis_types.BooleanValue: + raise NotImplementedError() + + +@register_unary_op(geo_ops.geo_st_isvalid_op) +def geo_st_isvalid_op_impl(x: ibis_types.Value): + return st_isvalid(x) + + +@ibis_udf.scalar.builtin("ST_UNION") +def st_union( + x: ibis_dtypes.GeoSpatial, y: ibis_dtypes.GeoSpatial +) -> ibis_dtypes.GeoSpatial: + raise NotImplementedError() + + +@register_binary_op(geo_ops.geo_st_union_op) +def geo_st_union_op_impl(x: ibis_types.Value, y: ibis_types.Value) -> ibis_types.Value: + return st_union(x, y) diff --git a/bigframes/geopandas/geoseries.py b/bigframes/geopandas/geoseries.py index f3558e4b34..18be398a07 100644 --- a/bigframes/geopandas/geoseries.py +++ b/bigframes/geopandas/geoseries.py @@ -74,6 +74,36 @@ def is_closed(self) -> bigframes.series.Series: f"GeoSeries.is_closed is not supported. Use bigframes.bigquery.st_isclosed(series), instead. {constants.FEEDBACK_LINK}" ) + @property + def is_empty(self) -> bigframes.series.Series: + series = self._apply_unary_op(ops.geo_st_isempty_op) + series.name = "is_empty" + return series + + @property + def geom_type(self) -> bigframes.series.Series: + series = self._apply_unary_op(ops.geo_st_geometrytype_op) + series.name = "geom_type" + return series + + @property + def is_ring(self) -> bigframes.series.Series: + series = self._apply_unary_op(ops.geo_st_isring_op) + series.name = "is_ring" + return series + + @property + def is_simple(self) -> bigframes.series.Series: + series = self._apply_unary_op(ops.geo_st_issimple_op) + series.name = "is_simple" + return series + + @property + def is_valid(self) -> bigframes.series.Series: + series = self._apply_unary_op(ops.geo_st_isvalid_op) + series.name = "is_valid" + return series + @classmethod def from_wkt( cls, @@ -123,3 +153,6 @@ def distance(self: GeoSeries, other: GeoSeries) -> bigframes.series.Series: # t def intersection(self: GeoSeries, other: GeoSeries) -> bigframes.series.Series: # type: ignore return self._apply_binary_op(other, ops.geo_st_intersection_op) + + def union(self: GeoSeries, other: GeoSeries) -> bigframes.series.Series: # type: ignore + return self._apply_binary_op(other, ops.geo_st_union_op) diff --git a/bigframes/operations/__init__.py b/bigframes/operations/__init__.py index b14d15245a..ddec8c5b46 100644 --- a/bigframes/operations/__init__.py +++ b/bigframes/operations/__init__.py @@ -106,8 +106,14 @@ geo_st_difference_op, geo_st_geogfromtext_op, geo_st_geogpoint_op, + geo_st_geometrytype_op, geo_st_intersection_op, geo_st_isclosed_op, + geo_st_isempty_op, + geo_st_isring_op, + geo_st_issimple_op, + geo_st_isvalid_op, + geo_st_union_op, geo_x_op, geo_y_op, GeoStBufferOp, @@ -406,6 +412,12 @@ "geo_st_geogpoint_op", "geo_st_intersection_op", "geo_st_isclosed_op", + "geo_st_isempty_op", + "geo_st_geometrytype_op", + "geo_st_isring_op", + "geo_st_issimple_op", + "geo_st_isvalid_op", + "geo_st_union_op", "GeoStBufferOp", "GeoStLengthOp", "geo_x_op", diff --git a/bigframes/operations/geo_ops.py b/bigframes/operations/geo_ops.py index 3b7754a47a..6a7eb7287a 100644 --- a/bigframes/operations/geo_ops.py +++ b/bigframes/operations/geo_ops.py @@ -84,6 +84,51 @@ ) geo_st_isclosed_op = GeoStIsclosedOp() +GeoStIsemptyOp = base_ops.create_unary_op( + name="geo_st_isempty", + type_signature=op_typing.FixedOutputType( + dtypes.is_geo_like, dtypes.BOOL_DTYPE, description="geo-like" + ), +) +geo_st_isempty_op = GeoStIsemptyOp() + +GeoStGeometrytypeOp = base_ops.create_unary_op( + name="geo_st_geometrytype", + type_signature=op_typing.FixedOutputType( + dtypes.is_geo_like, dtypes.STRING_DTYPE, description="geo-like" + ), +) +geo_st_geometrytype_op = GeoStGeometrytypeOp() + +GeoStIsringOp = base_ops.create_unary_op( + name="geo_st_isring", + type_signature=op_typing.FixedOutputType( + dtypes.is_geo_like, dtypes.BOOL_DTYPE, description="geo-like" + ), +) +geo_st_isring_op = GeoStIsringOp() + +GeoStIssimpleOp = base_ops.create_unary_op( + name="geo_st_issimple", + type_signature=op_typing.FixedOutputType( + dtypes.is_geo_like, dtypes.BOOL_DTYPE, description="geo-like" + ), +) +geo_st_issimple_op = GeoStIssimpleOp() + +GeoStIsvalidOp = base_ops.create_unary_op( + name="geo_st_isvalid", + type_signature=op_typing.FixedOutputType( + dtypes.is_geo_like, dtypes.BOOL_DTYPE, description="geo-like" + ), +) +geo_st_isvalid_op = GeoStIsvalidOp() + +GeoStUnionOp = base_ops.create_binary_op( + name="geo_st_union", type_signature=op_typing.BinaryGeo() +) +geo_st_union_op = GeoStUnionOp() + GeoXOp = base_ops.create_unary_op( name="geo_x", type_signature=op_typing.FixedOutputType( diff --git a/noxfile.py b/noxfile.py index f2be8045b1..bdb3d06a49 100644 --- a/noxfile.py +++ b/noxfile.py @@ -60,7 +60,7 @@ "setup.py", ] -DEFAULT_PYTHON_VERSION = "3.10" +DEFAULT_PYTHON_VERSION = "3.12" # Cloud Run Functions supports Python versions up to 3.12 # https://cloud.google.com/run/docs/runtimes/python @@ -285,6 +285,8 @@ def mypy(session): "types-PyYAML", "polars", "anywidget", + "types-shapely", + "types-geopandas", ] ) | set(SYSTEM_TEST_STANDARD_DEPENDENCIES) diff --git a/tests/system/small/geopandas/test_geoseries.py b/tests/system/small/geopandas/test_geoseries.py index a2f0759161..dacb5401aa 100644 --- a/tests/system/small/geopandas/test_geoseries.py +++ b/tests/system/small/geopandas/test_geoseries.py @@ -17,9 +17,9 @@ import re import bigframes_vendored.constants as constants -import geopandas # type: ignore -from geopandas.array import GeometryDtype # type:ignore -import geopandas.testing # type:ignore +import geopandas as gpd +from geopandas.array import GeometryDtype +import geopandas.testing import google.api_core.exceptions import pandas as pd import pytest @@ -245,7 +245,7 @@ def test_geo_boundary(session: bigframes.session.Session): bf_result = bf_s.geo.boundary.to_pandas() pd_result = pd_s.boundary - geopandas.testing.assert_geoseries_equal( + geopandas.testing.assert_geoseries_equal( # type: ignore bf_result, pd_result, check_series_type=False, @@ -468,6 +468,88 @@ def test_geo_intersection_with_similar_geometry_objects( assert expected.iloc[2].equals(bf_result.iloc[2]) +def test_geo_is_valid(session: bigframes.session.Session): + gseries = geopandas.GeoSeries.from_wkt( + [ + "POLYGON ((0 0, 1 1, 0 1, 0 0))", + "POLYGON ((0 0, 1 1, 1 0, 0 1, 0 0))", + ] + ) + bf_gseries = bigframes.geopandas.GeoSeries(gseries, session=session) + result = gpd.GeoSeries(bf_gseries.is_valid.to_pandas()) + expected = gseries.is_valid + assert_series_equal( + expected, result, check_index=False, check_names=False, check_dtype=False + ) + + +def test_geo_is_simple(session: bigframes.session.Session): + gseries = geopandas.GeoSeries.from_wkt( + [ + "LINESTRING (0 0, 1 1)", + "LINESTRING (0 0, 1 1, 0 1, 1 0)", + ] + ) + bf_gseries = bigframes.geopandas.GeoSeries(gseries, session=session) + result = gpd.GeoSeries(bf_gseries.is_simple.to_pandas()) + expected = gseries.is_simple + assert_series_equal( + expected, result, check_index=False, check_names=False, check_dtype=False + ) + + +def test_geo_geom_type(session: bigframes.session.Session): + gseries = geopandas.GeoSeries.from_wkt( + [ + "POINT (0 0)", + "POLYGON ((0 0, 1 1, 0 1, 0 0))", + ] + ) + bf_gseries = bigframes.geopandas.GeoSeries(gseries, session=session) + result = gpd.GeoSeries(bf_gseries.geom_type.to_pandas()) + expected = gseries.geom_type + assert_series_equal( + expected, result, check_index=False, check_names=False, check_dtype=False + ) + + +def test_geo_union(session: bigframes.session.Session): + gseries1 = geopandas.GeoSeries.from_wkt( + [ + "POINT (0 0)", + "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + ] + ) + gseries2 = geopandas.GeoSeries.from_wkt( + [ + "POINT (1 1)", + "POLYGON ((2 0, 3 0, 3 1, 2 1, 2 0))", + ] + ) + bf_gseries1 = bigframes.geopandas.GeoSeries(gseries1, session=session) + bf_gseries2 = bigframes.geopandas.GeoSeries(gseries2, session=session) + result = bf_gseries1.union(bf_gseries2).to_pandas() + expected = gseries1.union(gseries2) + geopandas.testing.assert_geoseries_equal( # type: ignore + gpd.GeoSeries(result), expected, check_series_type=False + ) + + +def test_geo_is_ring(session: bigframes.session.Session): + gseries = geopandas.GeoSeries.from_wkt( + [ + "LINESTRING (0 0, 1 0, 1 1, 0 1, 0 0)", + "LINESTRING (0 0, 1 1, 1 0, 0 1)", + ] + ) + bf_gseries = bigframes.geopandas.GeoSeries(gseries, session=session) + result = gpd.GeoSeries(bf_gseries.is_ring.to_pandas()) + expected = gseries.is_ring + assert_series_equal( + expected, result, check_index=False, check_names=False, check_dtype=False + ) + + def test_geo_is_closed_not_supported(session: bigframes.session.Session): s = bigframes.series.Series( [ @@ -531,7 +613,7 @@ def test_geo_centroid(session: bigframes.session.Session): # https://gis.stackexchange.com/a/401815/275289 pd_result = pd_s.to_crs("+proj=cea").centroid.to_crs("WGS84") - geopandas.testing.assert_geoseries_equal( + geopandas.testing.assert_geoseries_equal( # type: ignore bf_result, pd_result, check_series_type=False, @@ -569,7 +651,7 @@ def test_geo_convex_hull(session: bigframes.session.Session): bf_result = bf_s.geo.convex_hull.to_pandas() pd_result = pd_s.convex_hull - geopandas.testing.assert_geoseries_equal( + geopandas.testing.assert_geoseries_equal( # type: ignore bf_result, pd_result, check_series_type=False,