Skip to content

Commit 36bc179

Browse files
tswastgoogle-labs-jules[bot]gcf-owl-bot[bot]
authored
feat: Implement ST_ISCLOSED geography function (#1789)
* feat: Implement ST_ISCLOSED geography function This commit implements the `ST_ISCLOSED` geography function. The following changes were made: - Added `GeoIsClosedOp` to `bigframes/operations/geo_ops.py`. - Added `st_isclosed` function to `bigframes/bigquery/_operations/geo.py`. - Added `is_closed` property to `GeoSeries` in `bigframes/geopandas/geoseries.py`. - Added system tests for the `is_closed` property. * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md * fix mypy failure * feat: Implement ST_ISCLOSED geography function This commit implements the `ST_ISCLOSED` geography function. The following changes were made: - Added `GeoIsClosedOp` to `bigframes/operations/geo_ops.py`. - Added `st_isclosed` function to `bigframes/bigquery/_operations/geo.py`. - Added `is_closed` property to `GeoSeries` in `bigframes/geopandas/geoseries.py`. - Registered `GeoIsClosedOp` in `bigframes/core/compile/scalar_op_compiler.py` by defining an Ibis UDF and registering the op. - Added system checks for the `is_closed` property. * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md * wait to implement geoseries.is_closed for now * fix doctest * address review comments * Update bigframes/bigquery/_operations/geo.py --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: Owl Bot <gcf-owl-bot[bot]@users.noreply.github.com>
1 parent c5b7fda commit 36bc179

File tree

8 files changed

+150
-2
lines changed

8 files changed

+150
-2
lines changed

bigframes/bigquery/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
st_difference,
3333
st_distance,
3434
st_intersection,
35+
st_isclosed,
3536
st_length,
3637
)
3738
from bigframes.bigquery._operations.json import (
@@ -59,6 +60,7 @@
5960
"st_difference",
6061
"st_distance",
6162
"st_intersection",
63+
"st_isclosed",
6264
"st_length",
6365
# json ops
6466
"json_extract",

bigframes/bigquery/_operations/geo.py

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,66 @@ def st_intersection(
382382
return series._apply_binary_op(other, ops.geo_st_intersection_op)
383383

384384

385+
def st_isclosed(
386+
series: Union[bigframes.series.Series, bigframes.geopandas.GeoSeries],
387+
) -> bigframes.series.Series:
388+
"""
389+
Returns TRUE for a non-empty Geography, where each element in the
390+
Geography has an empty boundary.
391+
392+
.. note::
393+
BigQuery's Geography functions, like `st_isclosed`, interpret the geometry
394+
data type as a point set on the Earth's surface. A point set is a set
395+
of points, lines, and polygons on the WGS84 reference spheroid, with
396+
geodesic edges. See: https://cloud.google.com/bigquery/docs/geospatial-data
397+
398+
**Examples:**
399+
400+
>>> import bigframes.geopandas
401+
>>> import bigframes.pandas as bpd
402+
>>> import bigframes.bigquery as bbq
403+
404+
>>> from shapely.geometry import Point, LineString, Polygon
405+
>>> bpd.options.display.progress_bar = None
406+
407+
>>> series = bigframes.geopandas.GeoSeries(
408+
... [
409+
... Point(0, 0), # Point
410+
... LineString([(0, 0), (1, 1)]), # Open LineString
411+
... LineString([(0, 0), (1, 1), (0, 1), (0, 0)]), # Closed LineString
412+
... Polygon([(0, 0), (1, 1), (0, 1), (0, 0)]),
413+
... None,
414+
... ]
415+
... )
416+
>>> series
417+
0 POINT (0 0)
418+
1 LINESTRING (0 0, 1 1)
419+
2 LINESTRING (0 0, 1 1, 0 1, 0 0)
420+
3 POLYGON ((0 0, 1 1, 0 1, 0 0))
421+
4 None
422+
dtype: geometry
423+
424+
>>> bbq.st_isclosed(series)
425+
0 True
426+
1 False
427+
2 True
428+
3 False
429+
4 <NA>
430+
dtype: boolean
431+
432+
Args:
433+
series (bigframes.pandas.Series | bigframes.geopandas.GeoSeries):
434+
A series containing geography objects.
435+
436+
Returns:
437+
bigframes.pandas.Series:
438+
Series of booleans indicating whether each geometry is closed.
439+
"""
440+
series = series._apply_unary_op(ops.geo_st_isclosed_op)
441+
series.name = None
442+
return series
443+
444+
385445
def st_length(
386446
series: Union[bigframes.series.Series, bigframes.geopandas.GeoSeries],
387447
*,
@@ -407,6 +467,7 @@ def st_length(
407467
>>> import bigframes.geopandas
408468
>>> import bigframes.pandas as bpd
409469
>>> import bigframes.bigquery as bbq
470+
410471
>>> from shapely.geometry import Polygon, LineString, Point, GeometryCollection
411472
>>> bpd.options.display.progress_bar = None
412473
@@ -419,8 +480,6 @@ def st_length(
419480
... ]
420481
... )
421482
422-
Default behavior (use_spheroid=False):
423-
424483
>>> result = bbq.st_length(series)
425484
>>> result
426485
0 111195.101177

bigframes/core/compile/scalar_op_compiler.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1073,6 +1073,11 @@ def geo_st_intersection_op_impl(x: ibis_types.Value, y: ibis_types.Value):
10731073
)
10741074

10751075

1076+
@scalar_op_compiler.register_unary_op(ops.geo_st_isclosed_op, pass_op=False)
1077+
def geo_st_isclosed_op_impl(x: ibis_types.Value):
1078+
return st_isclosed(x)
1079+
1080+
10761081
@scalar_op_compiler.register_unary_op(ops.geo_x_op)
10771082
def geo_x_op_impl(x: ibis_types.Value):
10781083
return typing.cast(ibis_types.GeoSpatialValue, x).x()
@@ -2191,6 +2196,11 @@ def str_lstrip_op( # type: ignore[empty-body]
21912196
"""Remove leading and trailing characters."""
21922197

21932198

2199+
@ibis_udf.scalar.builtin
2200+
def st_isclosed(a: ibis_dtypes.geography) -> ibis_dtypes.boolean: # type: ignore
2201+
"""Checks if a geography is closed."""
2202+
2203+
21942204
@ibis_udf.scalar.builtin(name="rtrim")
21952205
def str_rstrip_op( # type: ignore[empty-body]
21962206
x: ibis_dtypes.String, to_strip: ibis_dtypes.String

bigframes/geopandas/geoseries.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,15 @@ def boundary(self) -> bigframes.series.Series: # type: ignore
6363
series.name = None
6464
return series
6565

66+
@property
67+
def is_closed(self) -> bigframes.series.Series:
68+
# TODO(tswast): GeoPandas doesn't treat Point as closed. Use ST_LENGTH
69+
# when available to filter out "closed" shapes that return false in
70+
# GeoPandas.
71+
raise NotImplementedError(
72+
f"GeoSeries.is_closed is not supported. Use bigframes.bigquery.st_isclosed(series), instead. {constants.FEEDBACK_LINK}"
73+
)
74+
6675
@classmethod
6776
def from_wkt(cls, data, index=None) -> GeoSeries:
6877
series = bigframes.series.Series(data, index=index)

bigframes/operations/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@
9898
geo_st_geogfromtext_op,
9999
geo_st_geogpoint_op,
100100
geo_st_intersection_op,
101+
geo_st_isclosed_op,
101102
geo_x_op,
102103
geo_y_op,
103104
GeoStDistanceOp,
@@ -386,6 +387,7 @@
386387
"geo_st_geogfromtext_op",
387388
"geo_st_geogpoint_op",
388389
"geo_st_intersection_op",
390+
"geo_st_isclosed_op",
389391
"GeoStLengthOp",
390392
"geo_x_op",
391393
"geo_y_op",

bigframes/operations/geo_ops.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@
5454
name="geo_st_geogpoint", type_signature=op_typing.BinaryNumericGeo()
5555
)
5656

57+
geo_st_isclosed_op = base_ops.create_unary_op(
58+
name="geo_st_isclosed",
59+
type_signature=op_typing.FixedOutputType(
60+
dtypes.is_geo_like, dtypes.BOOL_DTYPE, description="geo-like"
61+
),
62+
)
63+
5764
geo_x_op = base_ops.create_unary_op(
5865
name="geo_x",
5966
type_signature=op_typing.FixedOutputType(

tests/system/small/bigquery/test_geo.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,3 +418,40 @@ def test_geo_st_intersection_with_similar_geometry_objects():
418418
check_exact=False,
419419
rtol=0.1,
420420
)
421+
422+
423+
def test_geo_st_isclosed():
424+
bf_gs = bigframes.geopandas.GeoSeries(
425+
[
426+
Point(0, 0), # Point
427+
LineString([(0, 0), (1, 1)]), # Open LineString
428+
LineString([(0, 0), (1, 1), (0, 1), (0, 0)]), # Closed LineString
429+
Polygon([(0, 0), (1, 1), (0, 1)]), # Open polygon
430+
GeometryCollection(), # Empty GeometryCollection
431+
bigframes.geopandas.GeoSeries.from_wkt(["GEOMETRYCOLLECTION EMPTY"]).iloc[
432+
0
433+
], # Also empty
434+
None, # Should be filtered out by dropna
435+
],
436+
index=[0, 1, 2, 3, 4, 5, 6],
437+
)
438+
bf_result = bbq.st_isclosed(bf_gs).to_pandas()
439+
440+
# Expected results based on ST_ISCLOSED documentation:
441+
expected_data = [
442+
True, # Point: True
443+
False, # Open LineString: False
444+
True, # Closed LineString: True
445+
False, # Polygon: False (only True if it's a full polygon)
446+
False, # Empty GeometryCollection: False (An empty GEOGRAPHY isn't closed)
447+
False, # GEOMETRYCOLLECTION EMPTY: False
448+
None,
449+
]
450+
expected_series = pd.Series(data=expected_data, dtype="boolean")
451+
452+
pd.testing.assert_series_equal(
453+
bf_result,
454+
expected_series,
455+
# We default to Int64 (nullable) dtype, but pandas defaults to int64 index.
456+
check_index_type=False,
457+
)

third_party/bigframes_vendored/geopandas/geoseries.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,3 +483,25 @@ def intersection(self: GeoSeries, other: GeoSeries) -> GeoSeries: # type: ignor
483483
each aligned geometry with other.
484484
"""
485485
raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE)
486+
487+
@property
488+
def is_closed(self: GeoSeries) -> bigframes.series.Series:
489+
"""
490+
[Not Implemented] Use ``bigframes.bigquery.st_isclosed(series)``
491+
instead to return a boolean indicating if a shape is closed.
492+
493+
In GeoPandas, this returns a Series of booleans with value True if a
494+
LineString's or LinearRing's first and last points are equal.
495+
496+
Returns False for any other geometry type.
497+
498+
Returns:
499+
bigframes.pandas.Series:
500+
Series of booleans.
501+
502+
Raises:
503+
NotImplementedError:
504+
GeoSeries.is_closed is not supported. Use
505+
``bigframes.bigquery.st_isclosed(series)``, instead.
506+
"""
507+
raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE)

0 commit comments

Comments
 (0)