Skip to content
This repository was archived by the owner on Apr 1, 2026. It is now read-only.
2 changes: 2 additions & 0 deletions bigframes/bigquery/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
st_difference,
st_distance,
st_intersection,
st_isclosed,
)
from bigframes.bigquery._operations.json import (
json_extract,
Expand All @@ -58,6 +59,7 @@
"st_difference",
"st_distance",
"st_intersection",
"st_isclosed",
# json ops
"json_extract",
"json_extract_array",
Expand Down
59 changes: 59 additions & 0 deletions bigframes/bigquery/_operations/geo.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,3 +380,62 @@ def st_intersection(
each aligned geometry with other.
"""
return series._apply_binary_op(other, ops.geo_st_intersection_op)


def st_isclosed(
series: Union[bigframes.series.Series, bigframes.geopandas.GeoSeries],
) -> bigframes.series.Series:
"""
Returns TRUE for a non-empty Geography, where each element in the
Geography has an empty boundary.

.. note::
BigQuery's Geography functions, like `st_isclosed`, interpret the geometry
data type as a point set on the Earth's surface. A point set is a set
of points, lines, and polygons on the WGS84 reference spheroid, with
geodesic edges. See: https://cloud.google.com/bigquery/docs/geospatial-data

**Examples:**

>>> import bigframes.geopandas
>>> import bigframes.pandas as bpd
>>> import bigframes.bigquery as bbq
>>> from shapely.geometry import Point, LineString, Polygon
>>> bpd.options.display.progress_bar = None

>>> series = bigframes.geopandas.GeoSeries(
... [
... Point(0, 0), # Point
... LineString([(0, 0), (1, 1)]), # Open LineString
... LineString([(0, 0), (1, 1), (0, 1), (0, 0)]), # Closed LineString
... Polygon([(0, 0), (1, 1), (0, 1), (0, 0)]),
... None,
... ]
... )
>>> series
0 POINT (0 0)
1 LINESTRING (0 0, 1 1)
2 LINESTRING (0 0, 1 1, 0 1, 0 0)
3 POLYGON ((0 0, 1 1, 0 1, 0 0))
4 None
dtype: geometry

>>> bbq.st_isclosed(series)
0 True
1 False
2 True
3 False
4 <NA>
dtype: boolean

Args:
series (bigframes.pandas.Series | bigframes.geopandas.GeoSeries):
A series containing geography objects.

Returns:
bigframes.pandas.Series:
Series of booleans indicating whether each geometry is closed.
"""
series = series._apply_unary_op(ops.geo_st_isclosed_op)
series.name = None
return series
10 changes: 10 additions & 0 deletions bigframes/core/compile/scalar_op_compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -1074,6 +1074,11 @@ def geo_st_intersection_op_impl(x: ibis_types.Value, y: ibis_types.Value):
)


@scalar_op_compiler.register_unary_op(ops.geo_st_isclosed_op, pass_op=False)
def geo_st_isclosed_op_impl(x: ibis_types.Value):
return st_isclosed(x)


@scalar_op_compiler.register_unary_op(ops.geo_x_op)
def geo_x_op_impl(x: ibis_types.Value):
return typing.cast(ibis_types.GeoSpatialValue, x).x()
Expand Down Expand Up @@ -2180,6 +2185,11 @@ def str_lstrip_op( # type: ignore[empty-body]
"""Remove leading and trailing characters."""


@ibis_udf.scalar.builtin
def st_isclosed(a: ibis_dtypes.geography) -> ibis_dtypes.boolean: # type: ignore
"""Checks if a geography is closed."""


@ibis_udf.scalar.builtin(name="rtrim")
def str_rstrip_op( # type: ignore[empty-body]
x: ibis_dtypes.String, to_strip: ibis_dtypes.String
Expand Down
9 changes: 9 additions & 0 deletions bigframes/geopandas/geoseries.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,15 @@ def boundary(self) -> bigframes.series.Series: # type: ignore
series.name = None
return series

@property
def is_closed(self) -> bigframes.series.Series:
# TODO(tswast): GeoPandas doesn't treat Point as closed. Use ST_LENGTH
# when available to filter out "closed" shapes that return false in
# GeoPandas.
raise NotImplementedError(
f"GeoSeries.is_closed is not supported. Use bigframes.bigquery.st_isclosed(series), instead. {constants.FEEDBACK_LINK}"
)

@classmethod
def from_wkt(cls, data, index=None) -> GeoSeries:
series = bigframes.series.Series(data, index=index)
Expand Down
2 changes: 2 additions & 0 deletions bigframes/operations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@
geo_st_geogfromtext_op,
geo_st_geogpoint_op,
geo_st_intersection_op,
geo_st_isclosed_op,
geo_x_op,
geo_y_op,
GeoStDistanceOp,
Expand Down Expand Up @@ -385,6 +386,7 @@
"geo_st_geogfromtext_op",
"geo_st_geogpoint_op",
"geo_st_intersection_op",
"geo_st_isclosed_op",
"geo_x_op",
"geo_y_op",
"GeoStDistanceOp",
Expand Down
7 changes: 7 additions & 0 deletions bigframes/operations/geo_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@
name="geo_st_geogpoint", type_signature=op_typing.BinaryNumericGeo()
)

geo_st_isclosed_op = base_ops.create_unary_op(
name="geo_st_isclosed",
type_signature=op_typing.FixedOutputType(
dtypes.is_geo_like, dtypes.BOOL_DTYPE, description="geo-like"
),
)

geo_x_op = base_ops.create_unary_op(
name="geo_x",
type_signature=op_typing.FixedOutputType(
Expand Down
37 changes: 37 additions & 0 deletions tests/system/small/bigquery/test_geo.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,3 +354,40 @@ def test_geo_st_intersection_with_similar_geometry_objects():
check_exact=False,
rtol=0.1,
)


def test_geo_st_isclosed():
bf_gs = bigframes.geopandas.GeoSeries(
[
Point(0, 0), # Point
LineString([(0, 0), (1, 1)]), # Open LineString
LineString([(0, 0), (1, 1), (0, 1), (0, 0)]), # Closed LineString
Polygon([(0, 0), (1, 1), (0, 1)]), # Open polygon
GeometryCollection(), # Empty GeometryCollection
bigframes.geopandas.GeoSeries.from_wkt(["GEOMETRYCOLLECTION EMPTY"]).iloc[
0
], # Also empty
None, # Should be filtered out by dropna
],
index=[0, 1, 2, 3, 4, 5, 6],
)
bf_result = bbq.st_isclosed(bf_gs).to_pandas()

# Expected results based on ST_ISCLOSED documentation:
expected_data = [
True, # Point: True
False, # Open LineString: False
True, # Closed LineString: True
False, # Polygon: False (only True if it's a full polygon)
False, # Empty GeometryCollection: False (An empty GEOGRAPHY isn't closed)
False, # GEOMETRYCOLLECTION EMPTY: False
None,
]
expected_series = pd.Series(data=expected_data, dtype="boolean")

pd.testing.assert_series_equal(
bf_result,
expected_series,
# We default to Int64 (nullable) dtype, but pandas defaults to int64 index.
check_index_type=False,
)
22 changes: 22 additions & 0 deletions third_party/bigframes_vendored/geopandas/geoseries.py
Original file line number Diff line number Diff line change
Expand Up @@ -483,3 +483,25 @@ def intersection(self: GeoSeries, other: GeoSeries) -> GeoSeries: # type: ignor
each aligned geometry with other.
"""
raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE)

@property
def is_closed(self: GeoSeries) -> bigframes.series.Series:
"""
[Not Implemented] Use ``bigframes.bigquery.st_isclosed(series)``
instead to return a boolean indicating if a shape is closed.

In GeoPandas, this returns a Series of booleans with value True if a
LineString's or LinearRing's first and last points are equal.

Returns False for any other geometry type.

Returns:
bigframes.pandas.Series:
Series of booleans.

Raises:
NotImplementedError:
GeoSeries.is_closed is not supported. Use
``bigframes.bigquery.st_isclosed(series)``, instead.
"""
raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE)